Implements specs/v3.md phases 1–6. Users can now define a complete trading strategy in a single YAML file without writing Python. Test suite: 305 → 344 passing.
src/strategies/config.py— typedStrategyConfigPydantic model:AgentConfig(name, model, prompt, weight, is_veto),DeliberationConfig(optional),ScoringConfig(confidence_floor, trade_threshold),RiskRules(stop_loss/take_profit SLTPRule + max_position_pct),ValidationConfig(max_drawdown_pct, atr_spike_multiplier, min_news_count);model_validatorenforces weight-sum=1.0 and at-most-one veto agentsrc/strategies/loader.py—load_strategy(path): loads YAML, validates schema, and checks all prompt files exist on disk; prompt paths are resolved relative to project rootsrc/strategies/context.py—render_context_block(ctx): renders the fullMarketContextas a structured text block (PRICE / INDICATORS / NEWS / SENTIMENT / ON-CHAIN / MACRO / PORTFOLIO sections); single shared input for all agents in a generic runsrc/strategies/runner.py—run_strategy_council(ctx, config, client): config-driven generic runner; runs all agents in parallel; short-circuits to HOLD on veto; callscompute_generic_score; optionally runs deliberation; computes SL/TP viacompute_sl_tp; returnsGenericCouncilResultsrc/strategies/scoring.py—compute_generic_score(outputs, config): config-driven confidence-weighted signed-score; weights normalised at call time; confidence_floor and trade_threshold sourced fromScoringConfig;score_to_position_size_pct(score, config)scales size from |score|src/strategies/risk_rules.py—compute_sl_tp(rules, ctx, entry_price): computes SL/TP prices fromRiskRules; supportsatr_multiple(price ± N×ATR-14),fixed_pct(price × (1 ± pct)), andnone(0.0, 0.0); SL clamped > 0strategies/default.yaml— current v2 council expressed as a strategy file (technical 0.40 / sentiment 0.25 / fundamental 0.35 / risk veto; deliberation enabled; scoring thresholds and validation thresholds matching current hardcoded constants)strategies/example.yaml— fully-commented example strategy (momentum+macro+risk_guardveto); shows every config optionprompts/deliberation_generic_v1.txt— built-in deliberation prompt for generic runs; requests a 2–4 sentence narrative synthesisprompts/agent_template.txt— starting-point prompt for directional agents with annotated output schema and confidence calibration guideprompts/veto_agent_template.txt— starting-point prompt for veto agents withVetoAgentOutputschema guidanceprompts/risk_manager_veto_v1.txt— purpose-built veto prompt fordefault.yaml's risk agent (cleaner than repurposingrisk_manager_v2.txt)tests/test_strategy_loader.py(12 tests) — valid configs, weight validation, veto count, missing fields, missing prompt files,default.yamlandexample.yamlsmoke loadstests/test_strategy_scoring.py(16 tests) — all directions, confidence floor exclusion, threshold gating, weighted score formula, SL/TP for all three rule typestests/test_strategy_runner.py(8 tests) — BUY/HOLD/SELL signals, veto short-circuit, veto=false passthrough, SL/TP on result, conviction derivation, deliberation failure non-fatal, no-veto-agent skips veto calltests/test_strategy_parity.py(3 tests) — BUY/HOLD/veto scenarios confirm generic and legacy scorers agree on identical inputs
src/models.py— addedGenericAgentOutput,VetoAgentOutput,GenericCouncilOutputs,GenericDeliberationOutput(additive; all existing models unchanged)src/validation.py—check_drawdown_haltandcheck_volatility_haltnow acceptmax_drawdown_pctandatr_spike_multiplierparams (backward-compatible defaults match previous constants);validate_contextaccepts both params and passes them throughsrc/data/assembler.py—assemble_contextacceptsmax_drawdown_pctandatr_spike_multiplierparams and passes them tovalidate_contextsrc/main.py— added--strategy PATHCLI argument; when provided, callsrun_strategy_councilinstead of legacyrun_council; validation params derived from strategy configsrc/backtest/signals.py—generate_signalsacceptsstrategy_configparam; routes to generic runner when provided; agent logs written per-agent-name for generic runssrc/backtest/engine.py—run_backtestacceptsstrategy_configparam;_parse_argsadds--strategy PATHpyproject.toml— addedpyyaml>=6.0as explicit dependency (was transitively available; now declared)
prompts/risk_manager_v2.txt— rewritten to prevent the model from anchoringrecommended_stop_lossto historical BTC ATH levels (~$70–74k) instead of computing downward from the current price. Key changes: explicitly states the system is long-only (removed the "above entry for SELL" clause that confused the model); requiresrecommended_stop_loss MUST be LESS THAN the current price; provides the placement formula as a concrete equation (stop = current_price − multiplier × ATR-14); adds a worked example with actual numbers ($23k BTC / $1,500 ATR-14); adds "DO NOT use historical ATH prices as reference points"src/agents/risk.py—PROMPT_FILEflipped fromrisk_manager_v1.txttorisk_manager_v2.txtsrc/backtest/signals.py— new_sanitize_stop_levels(close_price, sl_pct, tp_pct, atr_14): code-level guard that corrects invalid stop/target ratios regardless of LLM output. Ifsl_pct ≥ 1.0(stop above entry) or≤ 0, replaces with2×ATR below entryfloored at 80% of entry. Iftp_pct ≤ 1.0(target below entry), replaces with3×ATR above entry(preserves ≥1.5 R:R). Applied immediately after the raw ratio computation in the signal loop.
tests/test_backtest.py— newTestSanitizeStopLevels(14 cases): ATH-anchoring bug reproduction, ATR fallback formula verification, 80% floor enforcement, valid values pass through unchanged, tp-below-entry correction, R:R ≥ 1.5 check after sanitization, zero-ATR and zero-close edge cases. Test suite: 291 → 305 passing.
Implements specs/v2.md phases 1–4. Phase 5 (tuning + holdout backtest) is pending real API runs. Test suite: 235 → 283 passing.
src/data/news_historical.py—fetch_news_for_date(as_of): GDELT DOC 2.0artlistquery filtered to a source allowlist (Reuters, Bloomberg, CoinDesk, WSJ, FT, CoinTelegraph, Forbes, CNBC, The Block, Decrypt); strictas_of - 24hpublish-lag cutoff; disk cache attmp/cache/gdelt/YYYY-MM-DD.json; returns[]on HTTP error so the caller can fall back to the stubsrc/data/onchain_historical.py—fetch_onchain_for_date(as_of): CoinMetrics Community API (community-api.coinmetrics.io/v4/timeseries/asset-metrics); preserves existingOnchainDatafield names but fills them with documented proxies — MVRV (CapMrktCurUSD / CapRealUSD) assopr, 7-day transfer-volume momentum asexchange_net_flow_btc, active-address count / 1000 aswhale_transactions_24h; slices strictlydate < as_of.date(); disk cache attmp/cache/coinmetrics/src/data/macro.py—fetch_macro_for_date(as_of): yfinance bundle forDX-Y.NYB,^VIX,^GSPC,^TNX(divides by 10 to correct the yfinance ×10 quote); computesmacro_biasfrom VIX band, DXY 20-session trend, and SPX 20-session trend; falls back tomacro_bias=neutralon any empty framesrc/models.py— newMacroDatamodel (vix, dxy, spx_20d_change_pct, tnx_yield_pct, macro_bias); new optionalmacro: MacroData | None = Nonefield onMarketContext; newscore: float = 0.0field onDeliberationOutputsrc/agents/scoring.py—compute_signed_score(outputs): confidence-weighted signed-score over the three directional agents (weights 0.40 tech / 0.25 sentiment / 0.35 fundamental); confidence floor 30 (agents below abstain rather than dilute); threshold 0.25 to trade; risk-veto overrides unconditionally.score_to_position_size_pct(score)derives size from|score|, capped at 20%prompts/technical_analyst_v2.txt,sentiment_analyst_v2.txt,fundamental_analyst_v2.txt,deliberation_v2.txt— v1 prompts retained in-tree; v2 prompts instruct directional agents to use the full 0–100 confidence range and be more assertive in clear regimes; deliberation rewritten to produce narrative only (scoring happens in Python); fundamental prompt documents the MVRV-as-SOPR proxy so the agent reasons about it correctlytests/test_news_historical.py,tests/test_onchain_historical.py,tests/test_macro.py— full coverage with mocked HTTP and yfinance; tests lookahead boundaries, cache round-trips, fallback pathstests/test_agents.py—TestComputeSignedScore(8 cases: all-BUY / all-SELL / veto override / all-HOLD / confidence floor / split directions / sub-threshold / all-abstain) andTestScoreToPositionSize(4 cases); newtest_scored_signal_overrides_llm_deliberationverifying the runner discards the LLM'sfinal_signaland substitutes the deterministic score
src/validation.py—validate_news_countandvalidate_contexttakemin_news_items: int = 10; backtest callers pass 5 (GDELT allowlist can be thin on some dates)src/data/assembler.py— newmacro_override: MacroData | Noneparameter; newmin_news_items: int = 10parameter plumbed through tovalidate_contextsrc/agents/deliberation.py—PROMPT_FILEflipped todeliberation_v2.txtsrc/agents/technical.py,sentiment.py,fundamental.py—PROMPT_FILEflipped to_v2.txtvariantssrc/agents/fundamental.py— user-message builder now includes aMacro backdrop:block whenctx.macrois populated; on-chain field labels re-worded to match the v2 proxy semanticssrc/agents/runner.py— importscompute_signed_score; after deliberation, callsllm_deliberation.model_copy(update={"final_signal": scored_signal, "score": score})so the deterministic scorer owns the final signal while the LLM still supplies narrative; risk-veto short-circuit preserved (skips the deliberation LLM call entirely)src/backtest/signals.py— replacesNEUTRAL_NEWS_STUBwithfetch_news_for_date(stub fallback when < 5 items); replacesNEUTRAL_ONCHAIN_STUBwithfetch_onchain_for_date; threadsfetch_macro_for_dateintomacro_override; sizes positions viamin(score_to_position_size_pct(score), risk.approved_position_size_pct);SIGNAL_COLUMNSgains ascorecolumn (surfaced in the CSV and the DataFrame)README.md— new v2 phase table; lists each module created
src/backtest/signals.py—_append_agent_log(path, date, data): appends a JSON Lines entry{"date": "...", "output": {...}}to an agent file and closes immediately;generate_signalsaccepts a newagent_log_dir: Path | Noneparameter and calls this after each successful cycle, writingtechnical_analyst.json,sentiment_analyst.json,fundamental_analyst.json,risk_manager.json, anddeliberation.jsonundertmp/YYYYMMDD_HHMMSS/agents/src/backtest/engine.py— derivesagent_log_dir = run_dir / "agents"and passes it togenerate_signals; end-of-run summary now prints theagents/folder
src/models.py—RiskManagerOutput: addedfield_validatoronrecommended_stop_loss,recommended_take_profit, andrisk_reward_ratioto strip commas before float parsing, fixingAgentErrorwhen the model returns formatted numbers like'71,001.50'
src/backtest/engine.py—_setup_run_dirdefault base changed fromruns/totmp/; timestamped run folders now live undertmp/YYYYMMDD_HHMMSS/src/backtest/engine.py—run_backtestnow resolvescsv_pathbefore callinggenerate_signalsand passes it in; the post-hocsignals_df.to_csv()call is removedsrc/backtest/signals.py—generate_signalsaccepts a newcsv_path: Path | Noneparameter; when set, opens the file before the signal loop, writes the header row, then flushes one CSV row per completed cycle so the file is preserved if the run is killed mid-way
src/backtest/engine.py—_setup_run_dir(base): createsruns/YYYYMMDD_HHMMSS/on each invocation; adds alogging.StreamHandlerpointing atrun.loginside that directory (StreamHandler flushes after every record); teessys.stdoutandsys.stderrthrough_TeeStreamso allprint()output is also captured; usesbuffering=1(line-buffered) so every newline triggers an OS write and output is preserved if the process is killed_TeeStream: lightweight wrapper that mirrors writes to two streams simultaneouslyrun_backtestnow accepts an optionalrun_dir: Pathparameter; when provided, the signals CSV is written into that directory instead of the working directory- CSV filename changed from
backtest_signals_<full-datetime-with-tz>.csv(contained colons, broke on some OS) tobacktest_signals_YYYYMMDD_YYYYMMDD.csv(start/end dates) - End-of-run summary prints the run folder path, CSV filename, and log filename
run_backtestpreviously calleddatetime.now()twice for the CSV filename, producing a different timestamp in the returnedcsv_paththan the one used when writing the file
src/agents/base.py— replaced free-form text generation + markdown-fence stripping with Anthropic tool use (tools+tool_choice={"type": "tool"}); the API now returns atool_useblock whose.inputis a guaranteed parsed dict, eliminating thenon-JSON outputerrors caused by models wrapping responses in```json ```fences with trailing prose- Removed
_extract_jsonhelper andimport json(no longer needed) AgentErrornow raised with"tool_use block"message when the response contains no tool-use block (e.g. unexpectedend_turn)
tests/test_agents.py— updated_mock_response/_mock_clientto return a mock withtype="tool_use"and.input=payloadinstead of a text block; replacedtest_strips_markdown_fencesandtest_raises_on_non_json(scenarios no longer possible) withtest_raises_on_missing_tool_use_block; removed unusedimport json; 235 tests passing
src/backtest/engine.py— switched fromBacktesttoFractionalBacktest(backtesting.lib); BTC trades in fractional units so any--capitalvalue now works without orders being silently canceledsrc/backtest/signals.py— replacedsl_price/tp_price(raw USD) withsl_pct/tp_pct(ratio relative to close, e.g. 0.95 = 5% stop below close);FractionalBacktestscales OHLCV prices internally (to satoshi units) so absolute USD prices caused constraint failures (SL < execution_price < TPalways failed against the scaled price)src/backtest/engine.pystrategy —next()now computessl = Close * sl_pctandtp = Close * tp_pct, which scales correctly under any price transformationtests/test_backtest.py— updated all test fixtures and_make_signals_dfto usesl_pct/tp_pct;_run_strategycash reverted to$10,000(fractional support makes large cash workaround unnecessary)
src/main.py,src/backtest/engine.py— addedload_dotenv()call at startup;.envfile was never being read, soANTHROPIC_API_KEYand all other env vars were silently missing at runtime
src/backtest/data.py—fetch_full_ohlcvrewritten to useyfinance.download("BTC-USD")instead of CCXT; removes exchange parameter and pagination loop; handles MultiIndex columns (yfinance 0.2+); UTC-localizes indexsrc/backtest/engine.py—run_backtestno longer creates a Kraken exchange for data fetching;get_exchangeimport removedpyproject.toml— addedyfinance>=0.2.0
- Kraken's public OHLC API only serves the most recent ~720 daily candles regardless of the
sinceparameter; requesting data older than that (e.g.--start 2024-01-01from April 2026) returned 0 candles. yfinance provides BTC-USD history back to 2014 with no API key.
tests/test_backtest.py— replaced CCXT-basedfetch_full_ohlcvtests with yfinance mocks; removedget_exchangemock fromrun_backtesttests
src/data/price.py— corrected CCXT symbol from"XBT/USD"to"BTC/USD"; CCXT normalizes Kraken's internal XBT ticker to BTC in its unified API, soXBT/USDraisesBadSymbolat runtime
src/data/price.py— replacedccxt.binancewithccxt.kraken;SYMBOLchanged from"BTC/USDT"to"XBT/USD"; removed Binance testnet URL logic; credentials now read fromKRAKEN_API_KEY/KRAKEN_SECRET;sandboxparam retained for interface compat but ignored (Kraken has no spot sandbox)src/execution/router.py—SYMBOL = "XBT/USD"src/execution/state.py— default symbol updated to"XBT/USD"src/execution/orders.py— docstring updated (Binance → Kraken)src/db/store.py— default symbol inget_open_tradeupdated to"XBT/USD"src/main.py—SYMBOL = "XBT/USD"; env var docs reflect Kraken credentials; paper trading note updated (DRY_RUN=1 replaces testnet)src/data/assembler.py— docstring updated.env.example— replacedBINANCE_TESTNET_API_KEY/SECRETandBINANCE_API_KEY/SECRETwithKRAKEN_API_KEY/KRAKEN_SECRETtests/test_execution.py,tests/test_db.py,tests/test_reporting.py— all"BTC/USDT"references updated to"XBT/USD"
Why: Binance is geo-restricted in the United States. Kraken is fully US-accessible, free for market data, and provides BTC/USD history back to 2013 via CCXT public endpoints.
Paper trading: Kraken has no spot sandbox. Paper trading uses DRY_RUN=1 (signals logged, no orders placed). Live trading requires KRAKEN_API_KEY + KRAKEN_SECRET.
src/backtest/__init__.py— new backtest packagesrc/backtest/data.py—fetch_full_ohlcv(paginated CCXT download),slice_ohlcv(strict no-lookahead enforcement),fetch_historical_sentiment(Alternative.me Fear & Greed history),get_sentiment_for_date(nearest-earlier fallback),NEUTRAL_NEWS_STUB(15 generic items),NEUTRAL_ONCHAIN_STUBsrc/backtest/signals.py—generate_signals: async LLM council loop over each historical date;_PortfolioTrackerdataclass (cash/position/drawdown tracking);SIGNAL_COLUMNSconstant; graceful HOLD fallback onAgentErrorsrc/backtest/engine.py—LLMCouncilStrategy(backtesting.py Strategy replaying pre-computed signals);run_backtestasync runner (fetch → signals → simulate → formatted stats dict); CLI viapython -m src.backtest.engine --start --end --capitaltests/test_backtest.py— 26 tests:TestBacktestData(slice no-lookahead, pagination, sentiment fallbacks, news stub),TestGenerateSignals(column schema, date count, lookahead guard, error fallback, portfolio tracker),TestRunBacktest(strategy execution, stop-loss trigger, stats keys)backtesting>=0.3.3added topyproject.toml
src/data/price.py— addedsince: int | None = Nonetofetch_ohlcvfor historical backfillsrc/data/assembler.py— addedtimestamp,news_override,sentiment_override,onchain_override,skip_freshnessoptional params toassemble_context; backward compatible (all default toNone/False)src/validation.py— addedskip_freshness: bool = Falsetovalidate_context; whenTrue, skips price freshness check (used by backtest engine)tests/test_assembler.py— updated to cover new override parameterstests/test_validation.py— updated to coverskip_freshnessflag
src/backtest/data.py—fetch_full_ohlcvnow correctly handles timezone-awareend_datearguments usingtz_localize/tz_convertinstead ofpd.Timestamp(dt, tz=...)which raises on already-aware datetimestests/test_backtest.py—_run_strategydefault cash raised from $10k to $1M so 20% position sizing can purchase at least one whole BTC unit at realistic price levels ($40k–$50k) without backtesting.py silently canceling orders
src/db/schema.py— addedweekly_summariestable (window_start,window_end,summary,metrics_json)src/db/store.py— addedget_closed_trades_in_window,get_reflections_for_trades,get_cycles_in_window,save_weekly_summary,get_weekly_summariessrc/reporting/__init__.py— new reporting packagesrc/reporting/metrics.py—compute_sharpe_ratio(annualised, sqrt(252)),compute_max_drawdown(equity-curve walk),compute_expectancy(avg_win × win_rate − avg_loss × loss_rate),compute_council_consistency(unanimous rate, confidence spread, veto rate),compute_all_metrics(all four, with target-met flags)src/reporting/weekly.py—run_weekly_summary: loads week's trades + reflections + cycles, computes metrics, calls claude-sonnet-4-6 withweekly_summary_v1.txt, persists summary to DB; runnable aspython -m src.reporting.weeklyprompts/weekly_summary_v1.txt— system prompt for the weekly summary agent (5 structured sections: performance verdict, pattern analysis, agent accuracy ranking, prompt refinement recommendations, next-period watchpoints)src/main.py—TRADING_MODEwiring (paper= testnet,live= mainnet withCOUNCIL_LIVE_CONFIRMED=1safety gate);--weeklyCLI flag; weekly APScheduler job (Sunday 08:00 UTC).env.example— documentedTRADING_MODE,COUNCIL_LIVE_CONFIRMED,BINANCE_API_KEY/SECRET,COUNCIL_DB_PATH,DRY_RUNtests/test_reporting.py— 49 tests: Sharpe, max drawdown, expectancy, consistency, all-metrics, weekly message builder,run_weekly_summary, store windowed queries
src/db/schema.py— SQLAlchemy table definitions (cycles,trades,portfolio_state,reflections)src/db/store.py— CRUD operations:log_cycle,open_trade,close_trade,get_open_trade,get_portfolio_state,update_portfolio_state,save_reflection,get_reflectionsrc/execution/orders.py—place_market_buy,place_market_sell,place_stop_loss,cancel_orderwith retry logic (max 3 attempts, exponential back-off)src/execution/state.py—build_portfolio_dict: loads live portfolio state from DB for context assemblysrc/execution/router.py—route_signal: translatesCouncilResult→ exchange orders + DB updates;reconcile_open_position: detects stop-outs at cycle startsrc/agents/reflection.py— post-trade reflection agent (claude-sonnet-4-6)prompts/reflection_v1.txt— system prompt for the reflection agentsrc/main.py— full cycle entry point; supports one-shot and--schedule(APScheduler daily at 00:05 UTC);DRY_RUN=1skips order executiontests/test_db.py— 16 tests for DB schema and CRUDtests/test_execution.py— 22 tests for orders, state, and routertests/test_reflection.py— 8 tests for the reflection agentsqlalchemy>=2.0.0andapscheduler>=3.10.0added to dependencies
src/models.py— extended with all agent output models:TechnicalAnalystOutput,SentimentAnalystOutput,FundamentalAnalystOutput,RiskManagerOutput,CouncilOutputs,DeliberationOutput,AgentWeightssrc/agents/base.py—call_agent()helper: loads prompt file, calls Anthropic API attemperature=0, strips markdown fences from response, validates JSON against Pydantic schema; raisesAgentErroron any failuresrc/agents/technical.py— Technical Analyst agent (claude-haiku-4-5); analyses RSI momentum, MACD crosses, Bollinger Band position, EMA alignment, ATR volatilitysrc/agents/sentiment.py— Sentiment Analyst agent (claude-haiku-4-5); analyses news tone, fear/greed index, social volume, dominant narrativesrc/agents/fundamental.py— Fundamental Analyst agent (claude-haiku-4-5); analyses exchange net flows, whale transactions, SOPR, macro biassrc/agents/risk.py— Risk Manager agent (claude-sonnet-4-6); evaluates portfolio risk, outputs position size, stop-loss, take-profit, R:R; has veto authoritysrc/agents/deliberation.py— Deliberation Chair agent (claude-sonnet-4-6); synthesises all four agent outputs into final BUY/SELL/HOLD signal with conviction levelsrc/agents/runner.py—run_council(): runs all four agents in parallel viaasyncio.gather(); short-circuits to HOLD immediately if risk manager vetoes (skips deliberation); returnsCouncilResultprompts/technical_analyst_v1.txt— system prompt for Technical Analystprompts/sentiment_analyst_v1.txt— system prompt for Sentiment Analystprompts/fundamental_analyst_v1.txt— system prompt for Fundamental Analystprompts/risk_manager_v1.txt— system prompt for Risk Managerprompts/deliberation_v1.txt— system prompt for Deliberation Chair (includes consensus rules: all-3-agree ≥70% → full size; 2-of-3 ≥60% → 50% size; else HOLD)tests/test_agents.py— 38 tests:call_agentbase utility, individual agent schema validation, deliberation consensus logic, runner veto short-circuit, prompt file existence checks
src/models.py— Pydantic v2 models for the full context schema:PriceData,IndicatorData,NewsItem,SentimentData,OnchainData,PortfolioData,MarketContext; literal types forDirection,MACDSignal,BBPosition,Regime, etc.src/data/price.py— CCXT Binance testnet OHLCV fetcher (fetch_ohlcv,build_price_data); raisesValueErrorif fewer than 200 candles returnedsrc/data/indicators.py—compute_indicators(): RSI-14, MACD cross detection, Bollinger Band position, EMA 20/50/200, ATR-14, ATR-30 rolling average, regime classifier (trending/ranging/high_volatility via ADX + ATR)src/data/news.py— async CryptoPanic fetcher; returns empty list if API key absentsrc/data/sentiment.py— async Alternative.me Fear/Greed Index fetcher; LunarCrush stub returning neutral defaultssrc/data/onchain.py— Glassnode/CryptoQuant stubs returning zero defaultssrc/data/assembler.py—assemble_context(): fetches OHLCV synchronously (ccxt is sync), fetches news/sentiment/on-chain in parallel withasyncio.gather(), assemblesMarketContext, runs Tier-0 validationsrc/validation.py—validate_context(): price freshness check (< 5 min), minimum news count (≥ 10), numeric range checks;HardRuleViolationraised for drawdown halt (> 15% from peak) and ATR volatility halt (ATR-14 > 2× ATR-30 avg)tests/test_indicators.py— 35 tests for all indicator computationstests/test_assembler.py— context assembly tests with mocked data feeds and default portfoliotests/test_validation.py— 35 tests for price freshness, news count, numeric ranges, drawdown halt, volatility halt