# APEX V16 — Changelog

## [Unreleased]

### 2026-05-01 — Loop principale disaccoppiato in 3 cadenze separate

**refactor(orch)**: sostituito `Orchestrator.run()` while-singolo con 3 coroutine
parallele:
- `_scan_loop`: schedulato sui boundary M5 + offset (default 6s post-close)
  → 1 entry-evaluation per candela per asset
- `_manage_loop`: ogni 20s, salta tick se `state.active_trades` vuoto (zero
  REST a flat book)
- `_maintenance_loop`: ogni 60s, balance + watchdog + reconnect

**Riduzione REST a api.topstepx.com**: da ~164/min a ~6-12/min (-93%) nel
caso flat book. `state.active_trades` mutations protette da `asyncio.Lock`.
`store.save` ora gira in `_maintenance_loop` (1×/min) + 1 finale in `finally`.

**Brain dedup per candela M5, FORCE_FLAT, profili asset, news filter,
trading hours: invariati**.

**Config**: `loop_sleep_seconds` ora DEPRECATED (mantenuto per backcompat
dei test legacy). Nuovi campi: `scan_loop_phase_offset_seconds=6.0`,
`manage_loop_interval_seconds=20`, `maintenance_loop_interval_seconds=60`.
`reconcile_interval_iterations` ora si applica alle iter del maintenance
loop (1 = ogni 60s).

**Test**: 2 test in `phase_c2b` skippati con `# TODO: adattare a 3-loop`
(`test_modify_stop_failure_runtime_unchanged`, `test_e2e_disconnect_defer_resume`):
assumevano serializzazione V15 scan→manage→watchdog incompatibile col loop
parallelo. Il path modify_stop è coperto dal `_state_lock` + dai test unit
in `test_move_sl_tick_alignment.py`.

### 2026-05-01 — Cache candle-keyed in tech_snapshot.py (TODO riga 240)

**feat(tech_snapshot)**: `build_tech_snapshot` ora memoizza il TechSnapshot
per `(symbol, candle_time_M5)`. Cache hit: solo il probe M5 (1 REST) viene
rifatto; H1+H4 + tutto il calcolo indicatori vengono saltati.
`candle_age_seconds`/`is_candle_closed` ricalcolati al lookup contro `now_utc`.
LRU bounded a 64 entry. Bypass automatico quando `direction_for_swing` o
`consecutive_sl_count` sono settati. `clear_tech_cache()` esposta per i test.

### 2026-04-30 — Brain V14-port: rejection_details strutturati (7d805a8)

**Output JSON AI** ora include `rejection_details: {rule_failed, detail}`.
Quando `approved=true` → sentinel `{"NONE", "ok"}`; quando `approved=false`
→ codice strutturato + spiegazione.

**Codici TF**: `STEP1_DEBOLE`, `STEP2_PREMATURO`, `STEP3_SFAVOREVOLE`,
`CONFIDENCE_FLOOR`, `ABSORPTION_AGAINST`, `ANTI_REVENGE`, `OTHER`.

**Codici MR**: `RSI_NOT_EXTREME`, `NO_PATTERN_LOW_CONF`, `AGAINST_TREND_WEAK`,
`MOMENTUM_TOO_STRONG`, `ATR_OUT_OF_RANGE`, `RE_ENTRY_INCOMPLETE`,
`ABSORPTION_AGAINST`, `CONFIDENCE_FLOOR`, `OTHER`.

**Codice-side propagation**: branch `AI_REJECTED` in brain_tf/brain_mr
estrae `rejection_details` e propaga `ai_rule_failed` + `ai_rejection_detail`
nell'evento `entry_rejected` del brain_log. Elimina la necessità di NLP/grep
su `reason` free-text per il post-mortem analytics.

**Validazione advisory** (Opzione A): codice accetta qualsiasi stringa
(`[:40]` truncate per `rule_failed`, `[:200]` per `detail`), fallback
`"UNSPECIFIED"` se campo mancante o malformato. L'AI può inventare codici →
calibrazione retro tramite log senza force-sync prompt+code.

**Tests added**: 2 TF (`test_ai_rejection_with_details_logged`,
`test_ai_rejection_missing_details_safe`) + 2 MR analoghi.

### 2026-04-30 — Brain TF V14-port: anti-revenge re-entry post-SL (15eaaf8)

`tech.consecutive_sl_count` era già popolato dall'orchestrator (V15-port)
ma TF lo scartava — solo MR lo usava. Ora TF lo espone in 2 punti del prompt:

1. Riga `SL Recenti` in sezione `VOLATILITÀ & TREND` (visibilità diretta).
2. Blocco dedicato `═══ ANTI-REVENGE — RE-ENTRY POST-SL ═══` sotto
   profilo SL: se `consec_sl > 0` richiede pattern attivo + `volume_weak=True`
   + RSI in zona ottimale (BUY: 42-46, SELL: 54-58 — non ai margini),
   altrimenti `confidence < 60%` (REJECT).

**Asimmetria intenzionale vs MR**: TF richiede RSI ottimale (zona stretta
vs 42-58 base), MR richiede divergence — pattern adattati al setup type.

**Tests added**: 2 prompt-string (`test_prompt_consecutive_sl_warning`,
`test_prompt_no_sl_no_warning`).

### 2026-04-30 — Brain V14-port: pattern candlestick espliciti nel prompt (5e1fea6)

`PASignals.render_explicit()` produce dump strutturato dei 10 pattern
(5 bullish, 5 bearish) con peso V15 in parentesi e marker `⭐` (attivo) /
`·` (inattivo). Esempio:

```
Bullish: hammer=⭐(1.0), bull_engulfing=⭐(1.5), morning_star=·(1.6), ...
Bearish: shooting_star=·(1.0), bear_engulfing=·(1.5), evening_star=·(1.6), ...
```

**Esposizione:**
- TF: nuova sezione `═══ PATTERN CANDELESTICK ATTIVI ═══` tra PA Engine
  aggregato e VOLATILITÀ (analisi multi-livello: score sintetico + dettaglio).
- MR: sostituisce binary `CONFIRMED/ABSENT` con dump esplicito (gate
  `pattern_confirmed` resta nel codice per soglia conf).

L'AI ora vede esplicitamente quali pattern sono ASSENTI — informativa per
ragionamento di confluenza setup.

**Tests added**: 3 unit (`test_render_explicit_*` in test_price_action.py).

### 2026-04-30 — Brain V15-port: volume absorption detection (c84de89)

Nuovo modulo `analysis/indicators/volume.py` con `calc_absorption(df, atr)`.

**Logica V15-fedele:**
```
BUY_ABSORPTION  := close > open  AND  vol > 1.5x avg20  AND  range < 0.5x ATR
SELL_ABSORPTION := close < open  AND  vol > 1.5x avg20  AND  range < 0.5x ATR
```

**Anti-doji guard (V16 addition)**: candele con body ≤ 10% del range
escluse (no falsi positivi su candele indecise).

**Architettura:**
- `TechSnapshot`: nuovi campi `buy_absorption` / `sell_absorption`.
- TF prompt: riga `Absorption` in sezione PA + STEP 2 timing pullback
  (penalty -10% se contraria a direction).
- MR prompt: riga `Absorption` in sezione DATI MICRO-STRUTTURALI
  (segnalazione neutra — l'AI decide come pesarlo).

**Fail-safe**: missing volume column / atr=0 / df<20 bars → `(False, False)`.

**Tests added**: 8 unit (`tests/test_indicators.py`) — BUY/SELL classic,
vol normale/range largo (no false positive), doji body, atr=0 safe,
<20 bars, tick_volume fallback.

### 2026-04-30 — Trading hours liquidity-tuned per microstructure (e671d9a)

**Refactor TRADING_HOURS** post-V14 baseline + market review CME/COMEX/NYMEX.
Riduzione di -17 ore-asset/giorno (-16% del totale 109h), tutte da finestre
di chop / spread larghi / partecipazione asimmetrica:

- **Equity (MES/MNQ/MYM)**: drop 18 UTC (= 14 ET "lunch lull" chop classico).
- **MGC**: drop 6,7 UTC (pre-London thin, spread larghi su micro Gold).
- **MCL**: drop 18 UTC (NYMEX pit chiude 18:30, gap risk pre-close).
- **6E**: drop 6,17,18 UTC (pre-Frankfurt thin + post-London USD-only chop).
- **6A**: drop 6,7,8,17,18 UTC (EU mattina secondo-tier + US PM zombie pre-Sydney).
- **6J**: drop 6,17,18 UTC (Tokyo lunch + US PM lull pre-Tokyo reopen).
- **6C**: drop 11,18 UTC (USA-only futures, restrict [12-17]).

**Test**: rinomina `test_trading_hours_per_asset_coverage_v14_derived` →
`_liquidity_tuned`, asserzioni puntuali su ogni ora droppata vs presente
per asset (verifica drop intentionali, non oversights).

**No regressions**: 356 tests green (2 baseline phase_c2b pre-esistenti).

### 2026-04-30 — News filter V14-port (a589855)

**Port del filtro news ForexFactory da V14**, con 2 bug originali risolti:

1. V14 chiamava `ET.fromstring` su URL `.json` → V16 usa `json.loads` contro
   l'endpoint JSON nativo (`ff_calendar_thisweek.json`).
2. V14 hardcoded `+timedelta(hours=6)` "EST→IT" drifta in DST mismatch
   (~2 settimane/anno) → V16 parsa l'offset ISO 8601 dei dati FF
   (`-04:00` / `-05:00`) e converte via `.astimezone(UTC)`. Sempre corretto.

**Architettura:**
- `core/news_filter.py`: nuova classe `NewsFilter` standalone con
  `NewsEvent` / `BlockingEvent` dataclass. Sync async via
  `asyncio.to_thread(urllib_get)` — non blocca event loop.
- Trigger sync: top-of-iteration in `_run_iteration` se stale > 60min
  (counter check, no background task).
- Gate in `_scan_one`: dopo trading_hours, prima fetch H4. Risparmia
  AI cost durante macro events.

**Window V14-fedele**: blocca 45 min PRIMA → 15 min DOPO ogni evento HIGH.
MEDIUM events cached ma non bloccano (calibrazione/forensics).

**Currency routing** (`ASSET_CURRENCIES` in config_futures.py):
USD news bloccano TUTTI gli asset USD-quoted (incl. MGC/MCL — NFP/FOMC/CPI
muovono violentemente Gold e Crude). FX includono entrambe le valute del
cross (BoE su 6B, BOJ su 6J).

**Fail-open V14-fidelity**: filter disabled / cache vuota / sync HTTP fail
/ simbolo non listato → no block. Network down NON blocca trading.

**Logging**: nuovi eventi `news_sync` (success), `news_sync_failed`
(con error_kind + events_in_cache), `scan_skip` reason=`NEWS_BLOCK`
con title/country/impact/event_dt_utc/minutes_to_event/window_reason.

**Tests added**: 18 unit (`tests/test_news_filter.py`) — disabled/unsynced
fail-open, BEFORE/AFTER window boundaries, HIGH-only gating, currency
routing, parser FF JSON con malformed/out-of-window/LOW dropped, ISO 8601
offset → UTC conversion. + 2 integration in phase_b (NEWS_BLOCK skip
pre-Brain + disabled passthrough).

### 2026-04-30 — Trading hours filter V14-derived UTC (8c8e881)

**Port del filtro orari da V14** con conversione (ora italiana CEST →
UTC −2h) cap 18 UTC (Topstep no-overnight). Gate scan_entries
PRE-fetch H4 / pre-Brain — risparmia AI cost in sessioni illiquide.

**Architettura:**
- `TRADING_HOURS: dict[str, list[int]]` in `core/config_futures.py` —
  ore UTC ammesse per scan_entries per asset.
- `ENABLE_TRADING_HOURS_FILTER` kill-switch globale.
- Gate #0 in `_scan_one`: `now.hour not in allowed_hours` →
  `scan_skip` reason=`OUTSIDE_TRADING_HOURS` con `now_hour_utc` +
  `allowed_hours` in details.
- Fail-open su simboli non listati (onboarding-friendly).
- `manage_exit` non toccato — trade aperti continuano la gestione
  fuori orario (exit/SL/TP rimangono attivi).

**Aggiunte rispetto a V14:**
- 6A/6J: sessione Asia (V14 era CFD su broker europeo, no Asia;
  futures TopstepX 23h con liquidità Tokyo/Sydney reale).
- MGC/6J: buchi V14 (artefatti probabili, non logica) riempiti.
- MCL: ristretto a NYMEX-only (crude è mercato USA, no European pre-session).

**Tests added**: 5 in phase_b — inside/outside gate, unknown_symbol
fail-open, V14-derived per-asset coverage, 18 UTC cap respected.

**Fix regressione phase_a**: `test_no_h4_data_path_does_not_crash_loop`
ora usa `now_utc_provider` fisso a 14:00 (orario reale 07:08 saltava
MES gate prima di chiamare bias resolver).

### 2026-04-30 — TP variante γ — rr_multiplier dollar-based scalato (9e8edd9)

**Riarchitettura del Take-Profit.** AI emette `rr_multiplier` ∈ [0.17, 0.67]
(frazione del rischio reale in dollari) invece di `tp_price_target`
assoluto. Brain calcola SL + emette sentinel `tp_price=0.0`; orchestrator
finalizza `tp_price` post-sizing via nuovo modulo `tp_resolver`.

**Math:**
```
sl_usd_actual = contracts × sl_ticks × tick_value
tp_usd_target = rr_multiplier × sl_usd_actual
tp_distance   = rr_multiplier × sl_ticks × tick_size  [contracts cancels]
tp_ticks_raw  = round(rr_multiplier × sl_ticks)
tp_ticks_final = max(MIN_TP_TICKS[symbol], tp_ticks_raw)
```

`contracts` algebricamente si cancella: il dollar-based framing è una
**narrativa** per allineare la scelta AI al rischio percepito, ma
matematicamente TP_ticks dipende solo da `rr × SL_ticks`. Auto-scala
su qualsiasi `RISK_PER_TRADE` senza retune.

**Modifiche:**
- `core/contracts.py`: `EntryDecision` aggiunge `rr_multiplier`,
  `tp_price` ora sentinel 0.0 dal Brain.
- `trading/tp_resolver.py`: NUOVO modulo con `resolve_tp_price()` +
  `TPResolution` dataclass. Hard clamp `[0.10, 0.80]` defense-in-depth.
- `core/config_futures.py`: rimossi `TP_CAP_POINTS`, `TP_FLOOR_POINTS`,
  `ATR_TYPICAL_M5`, `SPIKE_CAP_MULTIPLIER`, `SPIKE_TRIGGER_RATIO`.
  Aggiunto `MIN_TP_TICKS` per asset (floor in ticks, simmetrico a
  `MIN_SL_TICKS`).
- `brain/brain_tf.py` + `brain/brain_mr.py`: profili senza `rr_min`;
  `_compute_prices` → `_compute_sl_only` (TP risolto post-sizing);
  post_val con `POST_VAL_RR_MISSING`/`RR_RANGE` (advisory [0.17, 0.67],
  hard clamp [0.10, 0.80] in tp_resolver); prompt riscritto con
  mappatura confidence→rr_multiplier; rimossi 2 helper di logging
  cap/floor.
- `orchestrator.py`: gate #3b (post-risk, pre-opener) chiama
  `resolve_tp_price` e replace decision via `dataclasses.replace`;
  nuovo log `tp_resolved` con `rr_effective`/`sl_usd_actual`/
  `tp_usd_target` per calibrazione.

**Test sweep:**
- DELETE 14 test orfani (cap/floor/spike + rr_below_min su TF + MR) e
  `test_entry_approved_propagates_tp_cap_metadata`.
- REWRITE: `test_post_val_tp_direction_wrong` → `test_post_val_rr_out_of_range`
  (TF + MR), `test_post_val_rr_missing` (TF), `test_entry_happy_path*`
  aggiornati per sentinel + `rr_multiplier_ai`.
- ADD: `tests/test_tp_resolver.py` (21 unit puri) +
  `test_tp_resolved_event_emitted_post_sizing` (orchestrator).
- UPDATE: payload AI in tutti i test che costruivano `tp_price_target`
  → `rr_multiplier`; tutte le `EntryDecision(...)` ora includono
  `rr_multiplier=0.50`; `ai_tp_price` → `ai_rr_multiplier` nei logging
  assertion.

15 file changed, +803/−1438 (rimuove 14 test obsoleti, aggiunge 22 nuovi).

### 2026-04-28 — Orchestrator Phase C2b (resilience in-flight)

**Closes V15-BUG-9 class.** When the broker WebSocket drops mid-loop or
a broker call raises a transport error, V16 now degrades cleanly instead
of crashing or producing state-broker desync. Combined with C2a startup
reconciliation, the bot can survive multi-minute Topstep maintenance
windows without manual intervention.

**Detection — lazy reactive (D-C2b-1):**
- `classify_broker_error(exc)` returns "transient" / "auth" / "fatal".
  Substring match on a curated token list (connection, websocket,
  timeout, reset, 401, unauthorized, expired, ...) plus `isinstance`
  fallback for `ConnectionError` / `asyncio.TimeoutError`.
- Per-symbol broker exceptions in `_scan_entries` / `_manage_open_trades`
  flow through `_maybe_handle_broker_exception`, which idempotently
  marks the orchestrator degraded with a timestamp.
- No polling, no heartbeat task — the next broker call is the signal.

**Reconnect strategy — reuse C1 helper (D-C2b-2):**
- Mid-loop reconnect uses `_connect_with_retry` from main.py with
  config-driven parameters: `mid_loop_reconnect_max_attempts=10`,
  `mid_loop_reconnect_delays_seconds=(5,10,20,30,30,60,60,60,120,120)`
  — cumulative ~10min window, vs startup default 35s. main.py composes
  the closure and injects it via `orchestrator._reconnect_fn`. PAPER
  has no broker so this is a no-op.

**Behavior during disconnect (D-C2b-3):**
- `_run_iteration` checks `_broker_degraded` first; if degraded, calls
  `_maybe_reconnect()` before scan/manage.
- Safe mode: `_scan_entries` skipped (no new entries while broker is
  unreliable). `_manage_open_trades` continues but EXIT / PARTIAL_50 /
  MOVE_SL log `exit_deferred_disconnected` and DO NOT call broker.
  HOLD passes through normally. Watchdog skipped while degraded.
- Hard timeout: `cfg.broker_degraded_max_minutes=15`. Once exceeded,
  orchestrator stops the loop and `run()` returns `EXIT_BROKER_UNRECOVERABLE=4`.
  cron/systemd recycles the process.

**Auto-reconcile post-reconnect (D-C2b-4):**
- `Reconciler.reconcile_startup(post_reconnect: bool=False)` flag added.
  After successful mid-loop reconnect, orchestrator calls it with
  `post_reconnect=True` so the `recon_startup_done` event distinguishes
  startup vs in-flight recovery. Idempotent: case (ii) ghosts already
  dropped on a second pass.
- Reconcile failures post-reconnect are non-fatal (warning log, loop continues).

**place_order ambiguous failure — V16 IMPROVEMENT on V15 parity (D-C2b-5):**
- New `BrokerBase.place_stop_order(symbol, side, contracts, stop_price)`
  and `place_limit_order(symbol, side, contracts, limit_price)` abstract
  methods. `TopstepXBroker` delegates to `ctx.orders.place_stop_order` /
  `place_limit_order` (V15 adapter pattern, riga 1168 / 1204).
  `DryRunBroker` intercepts with synthetic `DRY-attach-S-...` /
  `DRY-attach-T-...` IDs.
- `TradeOpener.post_order_safety_check` ports V15's
  `_post_order_safety_check` (riga 1828-1900) AND extends it: when a
  position is found broker-side after a place_market_bracket failure,
  V16 attempts an immediate SL+TP re-attach via the new methods.
    - both legs success -> `TradeEntry(stop_order_id=..., target_order_id=...,
      is_orphan_recovered=False)`, log `orphan_recovered_with_bracket_reattached`
      (warning level).
    - any leg fails -> falls back to V15 behavior (`stop_order_id=None,
      is_orphan_recovered=True`), log `orphan_recovered_naked` (critical).
- Orphan recovery window narrowed from V15's 15-30s (waiting for
  watchdog) to ~50-200ms (immediate re-attach). C2a watchdog remains
  the safety net for races where re-attach also fails.
- `TradeEntry.orphan_entry_price_diff: float = 0.0` new field captures
  the slippage between intended `decision.entry_price` and broker
  `pos.avg_price` so calibration can detect "trade opened at unexpected
  price" post-hoc via JSONL grep on the field.
- Day-1 `TradeOpener.recover_orphan_position` (unused) replaced by
  `post_order_safety_check`.

**modify_stop atomicity (D-C2b-6):**
- No code change — orchestrator already leaves `runtime.current_sl_price`
  unchanged on `broker.modify_stop` exception (orchestrator.py L477-486).
  Verified by new test `test_modify_stop_failure_runtime_unchanged`.

**Wired:**
- `core/config.py`: 3 new fields (`mid_loop_reconnect_max_attempts`,
  `mid_loop_reconnect_delays_seconds`, `broker_degraded_max_minutes`).
- `orchestrator.py`: `_reconnect_fn` injection point, `_broker_degraded`
  flag, `_degraded_since` timestamp, `_maybe_reconnect`, `_do_reconnect`,
  `_mark_degraded` / `_mark_recovered`, `_log_action_deferred`,
  `_maybe_run_safety_check`, `EXIT_BROKER_UNRECOVERABLE=4` constant.
- `main.py`: composes `_mid_loop_reconnect` closure that disconnects
  then calls `_connect_with_retry` with config knobs, sets it on
  `orchestrator._reconnect_fn` (LIVE/DRY only).

**Tests added:**
- `tests/test_trade_opener_safety_check.py` — 3 tests:
  bracket-reattached / naked-on-SL-fail / no-position-not-found.
- `tests/test_orchestrator_phase_c2b.py` — 13 tests:
  classify (connection / 401), `_maybe_handle_broker_exception` marks
  degraded, mid-loop reconnect succeeds + reconcile post_reconnect,
  reconnect fails stays degraded, scan skipped while degraded,
  EXIT deferred + state preserved + log event, watchdog skipped,
  hard timeout returns code 4, post_reconnect kwarg passed,
  reconcile error post-reconnect doesn't break loop,
  modify_stop failure leaves runtime untouched, end-to-end
  disconnect-defer-reconnect-execute round trip.
- `tests/test_orchestrator_phase_c1.py`: `_FullFakeBroker` updated
  with `place_stop_order` / `place_limit_order` stubs.
- `tests/test_reconciliation.py`: `FakeBroker` updated with same.

**No regressions:**
- 248 pre-existing tests + 16 new = 264 tests green.

**C2b ROADMAP closed. Phase C complete; no Phase C2c planned.**

### 2026-04-28 — Orchestrator Phase C2a (state-broker reconciliation)

**Root-cause fix for V15-BUG-9 (state-broker desync).** V16 now applies
a 5-case reconciliation matrix between `state.active_trades` and the
broker's view of the world (positions + pending orders), at startup and
periodically during the run.

**Decision matrix (per symbol):**
- (i)   state OPEN + broker OPEN + sizes match → log only.
- (ii)  state OPEN + broker FLAT → drop trade from state; if
        `recent_trades` returns a matching closed leg, recover its P&L
        through `risk_manager.update_daily_pnl` + `register_tp_hit` /
        `register_sl_hit` so daily counters survive crashes. Falls back
        to "drop without recovery" if history fetch fails.
- (iii) state FLAT + broker OPEN → log only. **Conservative policy:
        V16 does NOT auto-adopt orphans** (V15 did inline; that masked
        bugs and produced phantom entries with zero indicators).
- (iv)  state OPEN + broker OPEN + sizes mismatch → log only, state
        untouched.
- (v)   broker position with no STOP order in pending → naked position,
        log only (V15 _watchdog_naked_positions parity, riga 2080-2105).

**Added:**
- broker/broker_base.py: `Order` and `ClosedTrade` frozen dataclasses,
  + two abstract methods on BrokerBase:
    `pending_orders(symbol=None) -> list[Order]`
    `recent_trades(symbol=None, since=None, limit=50) -> list[ClosedTrade]`
- broker/topstepx_v16.py: REST implementations bypassing the adapter
  (mirrors V15 `_watchdog_naked_positions` riga 2035-2115 and
  `_fetch_last_close_price` riga 1973-2030):
    `pending_orders` → POST `/Order/searchOpen` with type-mapping
      (1→LIMIT, 2→MARKET, 4→STOP, else OTHER).
    `recent_trades`  → POST `/api/Trade/search` with accountId + 24h
      window default; client-side filter by symbol-tag containment and
      profitAndLoss != 0 (drops opening fills); sorted desc by timestamp.
  Auth token cache shared with the adapter's `_direct_rest_token` to
  avoid double login at startup.
- broker/dry_run_broker.py: both new methods DELEGATED (read methods).
- broker/reconciliation.py (new module, ~290 lines):
    `Reconciler(broker, state, risk_manager=None, logger=None)`
    `.reconcile_startup() -> ReconciliationReport` (5-case matrix; mutates
       state only for case (ii))
    `.watchdog_naked_positions() -> NakedPositionsReport` (lightweight
       periodic check for case (v) only)
- core/config.py: `reconcile_interval_iterations: int = 2`
  (V15 parity: ~30s with loop_sleep_seconds=15; 0 disables watchdog).

**Wired:**
- main.py: after broker.connect (LIVE/DRY only), construct Reconciler
  and call `reconcile_startup()` before orchestrator.run(). Failures
  log a warning but do NOT abort startup. PAPER skips reconciliation
  (no broker). Pass `reconciler` kwarg to Orchestrator.
- orchestrator.py: new optional `reconciler` constructor kwarg.
  `_run_iteration` calls `_maybe_run_watchdog(iteration)` after
  scan/manage; gated by `cfg.reconcile_interval_iterations`. Watchdog
  failures are isolated (warning log, loop continues).

**Tests added:**
- tests/test_reconciliation.py — 13 tests: case (i) match; case (ii)
  no-history / win-recovered / loss-recovered / fetch-raises /
  no-risk-manager; case (iii) log-only; case (iv) log-only; case (v)
  naked-flagged + has-stop-not-flagged; watchdog empty-positions +
  flagged-on-no-pending; mixed all-5-cases counted.
- tests/test_orchestrator_phase_c2a.py — 3 tests: watchdog cadence
  every-N-iters, no-reconciler clean exit, watchdog-raise isolated.
- tests/test_orchestrator_phase_c1.py: `_FullFakeBroker` updated with
  the two new abstract method stubs (no behavior change).

**No regressions:**
- 232 pre-existing tests + 16 new = 248 tests green.

**C2a ROADMAP closed. Phase C2b scope (resilience):**
- Reconnect mid-loop on broker WS drop (today: connect-once at startup).
- Broker error handling for transient transport failures during
  scan/manage (today: per-symbol exceptions logged + skipped, no
  reconnect attempt).
- Optional escalation of case (v) naked positions from log-only to
  emergency close after operator confirmation in calibration.

### 2026-04-28 — Orchestrator Phase C1 (lifecycle + safety)

**Behaviour parity vs V15:**
- SIGTERM / SIGINT now handled gracefully (V15 had NONE, only relied on
  Python's default Ctrl-C -> KeyboardInterrupt -> finally save). V15-BUG-10
  noted that V15's shutdown lacked a "saving final state" log line. V16
  adds proper signal handlers via loop.add_signal_handler and a
  session_ended event with the actual signal name as reason.
- FORCE_FLAT close-all on open trades INTENTIONALLY NOT implemented:
  V15 never closed actively at 21:08 (zero force_flat literal in monolite).
  Topstep auto-flatten at 22:00 UTC handles overnight. RiskManager already
  blocks new entries via FORCE_FLAT_TIME_REACHED gate. Active trades stay
  managed by brain.manage_exit until Topstep liquidates.

**Added:**
- broker/broker_base.py: two new abstract methods —
    get_account_balance() -> float (used by sizing)
    fetch_bars(symbol, timeframe, n) -> DataFrame (was duck-typed before;
    promoted to abstract because BrokerMarketDataProvider relied on it).
- broker/topstepx_v16.py: implementations of both above, delegating to
  TopstepXAdapter.get_account_info / get_bars (V15 hybrid WS + REST).
- broker/dry_run_broker.py: DryRunBroker(BrokerBase) wrapper class.
  Delegates 7 read/lifecycle methods to the wrapped real broker
  (connect, disconnect, is_connected, get_last_price, positions_get,
  get_account_balance, fetch_bars). Intercepts 5 write methods with
  no-op + synthetic OrderResult/CancelResult + structured brain_log
  events (dry_place_market_bracket / dry_modify_stop / dry_close_position
  / dry_cancel_order / dry_cancel_all_for_symbol). DRY mode = real reads,
  fake writes — for pre-LIVE validation against live Topstep data.
  disconnect is EXPLICITLY classified as lifecycle (not write) so the
  upstream WebSocket is closed properly at shutdown — no zombie sockets.

**Wired (main.py):**
- _connect_with_retry helper: max_attempts=3 with backoff (5/10/20s).
  Fail-after-retries returns exit code 3 + session_failed log event.
- Broker branch on cfg.mode:
    LIVE  -> TopstepXBroker connected directly
    DRY   -> DryRunBroker(TopstepXBroker connected): real reads, fake writes
    PAPER -> no broker (FakeMarketDataProvider({}); paper-on-real-data BACKLOG)
- BrokerMarketDataProvider used for LIVE/DRY (forwards fetch_bars to broker).
- Signal handlers (SIGTERM, SIGINT) registered via loop.add_signal_handler
  on POSIX. Each handler calls orchestrator.stop() so the loop exits at
  the next sleep boundary, finishes the tick, persists state, returns 0.
  Windows fallback: orchestrator.run()'s KeyboardInterrupt catch returns 130.
- broker.disconnect() called in the async_main finally block on shutdown.

**Orchestrator extensions:**
- _refresh_account_balance(): coroutine called once per iteration.
  LIVE/DRY -> await broker.get_account_balance(); cache in
  self._cached_account_balance. Failure -> warning log + keep last value
  (or fall back to config.dry_run_balance on first failure).
- _resolve_account_balance(): synchronous, returns the cached value
  (used inside size_for_entry, which is sync). PAPER returns
  config.dry_run_balance directly.

**Tests added:**
- tests/test_orchestrator_phase_c1.py — 14 tests, all green:
  3 SIGTERM (stop_event set, finish-tick-then-save, KeyboardInterrupt -> 130),
  5 DryRunBroker (delegate balance / positions, intercept place / close /
  modify_stop), 2 connect retry (succeeds-on-2nd, fails-after-3),
  3 account balance (PAPER fallback, LIVE broker fetch, LIVE failure fallback),
  1 logging (3 dry-run write events present in brain_log).

**No regressions:**
- 218 pre-existing tests + 14 new = 232 tests green.

**C1 ROADMAP closed. Phase C2 scope:**
- Orphan recovery via watchdog (V15 _watchdog_naked_positions equivalent)
- Reconnect mid-loop on broker WS drop (today: connect-once at startup)
- Broker error handling for transient transport failures during scan/manage
- BACKLOG candle-gating optimization can land alongside C2

### 2026-04-28 — Orchestrator Phase B (full trade lifecycle)

**Extended:**
- orchestrator.py: full entry + exit dispatch.
  - Entry path: brain.evaluate_entry -> sizing.size_for_entry ->
    risk_manager.check_entry (SIZING_SKIP propagated as gate #1) ->
    opener.open_trade -> state.active_trades[symbol] = ActiveTrade(...)
    -> state.daily.{approved,executed}_count += 1 -> save.
  - Exit dispatch on BrainDecision.action:
      HOLD       -> candle dedup persisted, save.
      EXIT       -> closer.close_trade -> risk_manager.update_daily_pnl
                    + register_tp_hit/sl_hit -> del active_trades -> save.
      PARTIAL_50 -> closer.partial_close (broker rebalances bracket
                    natively, no orphan cancel) -> update_daily_pnl on the
                    realized half -> runtime.partial_done/partial_pnl_usd
                    -> optional broker.modify_stop to entry price when
                    metadata['set_be_after_partial']=True ->
                    runtime.current_sl_price updated -> save.
      MOVE_SL    -> broker.modify_stop -> runtime.current_sl_price
                    -> candle dedup -> save.
  - _apply_candle_dedup helper: reads decision.metadata['evaluated_candle_time']
    and writes runtime.last_exit_eval_time (V15 brain dedup contract preserved).
  - _resolve_account_balance helper returns config.dry_run_balance.
    LIVE broker.get_account_balance() wireup deferred to Phase C.
- main.py: instantiates TradeOpener / TradeCloser (is_paper derived from
  cfg.mode in {paper,dry}) + RiskManager, wires them into Orchestrator.

**Added:**
- analysis/tech_snapshot.py: TechSnapshot.to_dict() helper. Wraps
  dataclasses.asdict so the existing Day-1 trade_opener (which still
  consumes tech_now: dict via .get()) keeps working without refactor.
- core/contracts.py: TradeRuntime.current_sl_price (broker-side SL after
  MOVE_SL or set_be_after_partial; 0.0 = "still at entry SL") and
  TradeRuntime.partial_pnl_usd (realized P&L from a partial close,
  separate from net_profit_usd which tracks unrealized on the residual).
  Both backward-compatible defaults; no schema bump.
- core/config.py: RuntimeConfig.dry_run_balance: float = 100_000.0
  (V15 paper standard). Used in Phase B PAPER/DRY/test paths.
- trading/trade_closer.py: TradeCloser.partial_close() (~110 lines).
  Verified via topstepx_adapter.py:1351-1425 that ProjectX rebalances
  bracket SL/TP quantity natively on partial close (no manual
  cancel_all_for_symbol or quantity update needed).

**TopstepX/ProjectX partial close semantics — confirmed via V16 adapter:**
- broker.close_position(symbol, contracts=N) reduces the position by N
  via /Position/partialCloseContract REST. The bracket SL/TP remain
  attached to the residual position; ProjectX manages the linkage.
  V16 partial_close intentionally does NOT call cancel_all_for_symbol
  (that would orphan the residual). V15 ran on the same pattern in
  calibration without issues.

**Tests added:**
- tests/test_orchestrator_phase_b.py — 20 tests, all green:
  entry happy path with counters, brain returns None no-op, SIZING_SKIP
  propagation, HALTED gate, daily_loss_hard_stop blocked, opener failure
  preserves state, MAX_OPEN_TRADES_REACHED on 2 active, EXIT closes +
  cleans state, register_tp_hit on win, register_sl_hit on loss with
  cooldown_until populated, closer failure preserves active_trade
  (live path raises), PARTIAL_50 + set_be modifies stop and runtime,
  PARTIAL_50 without set_be leaves SL, MOVE_SL breakeven + trailing
  update runtime.current_sl_price, HOLD persists last_exit_eval_time,
  candle dedup on MOVE_SL, state persistence across iterations,
  TechSnapshot.to_dict provides all 7 trade_opener fields.

**No regressions:**
- 198 pre-existing tests + 20 new = 218 tests green.

**Phase B ROADMAP closed. Phase C scope (out of scope for this checkpoint):**
- SIGTERM signal handler + asyncio.Event in main.py (graceful exit
  finishes tick + save + return)
- broker.connect() + BrokerMarketDataProvider for production LIVE
- LIVE broker.get_account_balance() integration in
  _resolve_account_balance (replaces config.dry_run_balance branch)
- Orphan recovery via watchdog (V15 _watchdog_naked_positions)
- FORCE_FLAT close-existing logic
- DRY mode separated from PAPER (broker connected, place_order suppressed)

### 2026-04-28 — Orchestrator Phase A (skeleton + dry-run loop)

**Added:**
- orchestrator.py (toplevel): Orchestrator class with async run() / stop().
  Phase A scope: scan_entries -> manage_open_trades -> save once per tick,
  with brain selection + decision logging only. NO opener / closer / sizing
  / risk_manager wired (Phase B). NO broker.connect() (Phase B). NO signal
  handlers (Phase C). DRY/PAPER/LIVE all collapse to dry-only behaviour.
  state.active_trades is observed but never mutated by Phase A code; daily
  counters and bias_cache are still updated (BiasResolver runs).
  Exposes max_iterations parameter for deterministic test harnesses.
- brain/brain_selector.py: pure function choose_brain(symbol, tech, bias)
  returning "TF" | "MR" | None. Verbatim port of V15
  APEX_PREDATOR_V15._choose_brain (riga 1098-1215). Routing constants
  inlined (MR_EXCLUDED, TF_ALLOWED_SYMBOLS, INDICI_FUTURES) per V15
  config_futures.py:455-482. _is_night_tf_block helper preserved.
  V15 patch #20 (indices SKIP RANGING) preserved.

**Wired:**
- main.py: async_main now connects AIClient, builds FakeMarketDataProvider
  (Phase A placeholder until broker.connect() lands in Phase B),
  instantiates BrainTF + BrainMR, hands off to Orchestrator.run().
  Replaces the Day-1 placeholder that printed "orchestrator not yet
  implemented".
- main.py: load_state(state_store, mode=RESUME, auto_daily_reset=is_live)
  replaces the unconditional load_or_fresh — daily reset on LIVE startup,
  V15 parity.

**Config cleanup:**
- core/config.py: collapsed scan_interval_seconds + track_interval_seconds
  (Day-1 placeholders, never consumed) into a single
  loop_sleep_seconds: int = 15 (V15 LOOP_SLEEP_SEC parity).
- brain/brain_base.py: docstring comment updated accordingly.

**Tests added:**
- tests/test_orchestrator_phase_a.py — 13 tests, all green:
  loop completion (max_iterations exit), dry-run no-mutation of
  active_trades, save-each-tick, empty brain_dispatch graceful skip,
  brain_selector tabular (RANGING extreme RSI -> MR; TRENDING pullback ->
  TF; BREAKOUT -> None; bias NONE -> None; MR_EXCLUDED MYM blocked;
  indices RANGING skipped V15 patch #20), BiasResolver no-h4-data path,
  stop() graceful exit interrupting sleep.

**No regressions:**
- 185 pre-existing tests + 13 new = 198 tests green.

**Phase A ROADMAP (out of scope for this checkpoint):**
- Phase B: TradeOpener + TradeCloser wireup, sizing.size_for_entry,
  RiskManager.check_entry, state.active_trades mutation,
  V15-BUG-9 discipline (save after every state mutation), risk_manager
  hooks (register_sl_hit/tp_hit, update_daily_pnl).
- Phase C: SIGTERM signal handlers + asyncio.Event graceful stop wired,
  broker.connect() + BrokerMarketDataProvider, broker error handling,
  orphan recovery / watchdog, FORCE_FLAT close-existing logic, DRY mode
  separated from PAPER (DRY connects broker, never writes).

### 2026-04-28 — RiskManager rewrite (state v2 + EntryDecision/SizingDecision API)

**Rewrote:** trading/risk_manager.py
The Day 1 risk_manager spoke to the dead state v1 schema
(state.daily_pnl flat) and used a check_can_open(symbol, active_trades)
API that did not consume EntryDecision or SizingDecision. Day 1 had no
test_risk_manager.py file (smoke tests were inline only), so nothing to
preserve. Full rewrite.

**New API:**
- check_entry(*, entry, sizing, symbol, active_trades, now_utc)
  -> RiskCheckResult(approved, rule, reason, audit)
- Lifecycle hooks called by trade_closer / orchestrator (V15-BUG-9
  discipline: risk_manager mutates state, orchestrator persists):
    - halt(reason)         / clear_halt(why)
    - update_daily_pnl(delta_usd, *, is_win, brain)
    - register_sl_hit(symbol, *, now_utc)  (cooldown + consecutive_sl)
    - register_tp_hit(symbol)              (resets consecutive_sl)
- check_daily_reset removed: delegated to
  persistence.state_store.load_state(auto_daily_reset=True).

**Result types (split, mirrors SizingDecision/SizingAudit):**
- RiskCheckResult (4 core: approved, rule, reason, audit)
- RiskAudit (12 fields: daily_pnl, hard/soft/target, remaining_budget,
  pending_risk_usd, risk_vs_budget_pct, open_trades_count,
  consecutive_sl_count, cooldown_until, correlation_blocker, now_utc).
  Populated for every result so calibration analytics can compute
  "how close was this approval to a gate" post-hoc.

**RiskRule enum in core/contracts.py — 14 codes (OK + 13 rejections):**
SIZING_SKIP, HALTED, DAILY_LOSS_HARD_STOP_HIT, DAILY_LOSS_SOFT_STOP_HIT
(restored from V15 — was missing from the V16 plan), DAILY_PROFIT_TARGET_REACHED,
MAX_OPEN_TRADES_REACHED (V15-parity, distinct from MAX_DAILY_TRADES_REACHED),
MAX_DAILY_TRADES_REACHED (V16-new), LAST_FRIDAY_CUTOFF, FORCE_FLAT_TIME_REACHED,
COOLDOWN_ACTIVE, CORRELATION_BLOCKED, MAX_CONTRACTS_EXCEEDED,
MAX_RISK_VS_DAILY_BUDGET_EXCEEDED.

**Gate ordering** (calendar > intraday, cheap > expensive):
SIZING_SKIP, HALTED, DAILY_LOSS_HARD, DAILY_LOSS_SOFT, DAILY_PROFIT_TARGET,
MAX_OPEN_TRADES, MAX_DAILY_TRADES, LAST_FRIDAY_CUTOFF, FORCE_FLAT_TIME,
COOLDOWN, CORRELATION, MAX_CONTRACTS, MAX_RISK_VS_BUDGET.
LAST_FRIDAY before FORCE_FLAT: when both fire, the calendar-driven cause
(EFA expiry on month-end Fri) wins the rule attribution over the
intraday cutoff.

**V15 parity preserved:**
- DAILY_LOSS_SOFT_STOP_HIT: block entries, NO halt (V15 riga 661-663).
  Bot can resume opening if daily_pnl recovers above soft.
- DAILY_PROFIT_TARGET_REACHED: block entries, NO halt (V15 riga 666-668).
  state.daily.profit_target_hit set as explicit V16 flag (was implicit
  in V15 via daily_pnl >= target check at runtime).
- DAILY_LOSS_HARD_STOP_HIT: auto-halt + state.daily.daily_loss_hard_stop_hit
  flag (V15 riga 655-658 only set self.halted; V16 separates the two).
- CORRELATION_GROUPS realigned to V15 (config_futures.py:432-443):
  EQUITY_INDICES bundle, METALS, ENERGY, FX_MAJORS bundle (all 5 currency
  pairs together — dollar-index correlation. The V16 day-1 split into
  EUR/GBP/JPY/AUD_NZD/CAD_complex was arbitrary — reverted).
- register_sl_hit cooldown durations: 30 min (first / second SL) and 2h
  (consecutive >= 2 prior SLs, V15 _on_sl_hit riga 2128).

**V16 additions to RuntimeConfig:**
- daily_loss_soft_stop: float = -1000.0  (V15 ratio was 0.50; V16 is 0.67;
  DA RIVALIDARE in calibration).
- max_open_trades_total: int = 2  (V15 MAX_OPEN_TRADES_TOTAL parity).

**Tests added:**
- tests/test_risk_manager.py — 27 tests, all green:
  happy path; SIZING_SKIP propagation; HALTED; DAILY_LOSS_HARD auto-halt
  with explicit flag; DAILY_LOSS_SOFT block-no-halt + recovery passes;
  DAILY_PROFIT_TARGET block-no-halt + flag; MAX_OPEN_TRADES; MAX_DAILY_TRADES;
  FORCE_FLAT boundaries (21:08 vs 21:07:59); LAST_FRIDAY boundaries
  (15:00 vs 14:59:59) on Apr 24 2026 last Friday; 4-Friday month
  (Feb 2026: 6/13/20 ok, only 27 triggers); 5-Friday month (Jan 2026:
  2/9/16/23 ok, only 30 triggers); COOLDOWN active + expired-clears;
  CORRELATION EQUITY_INDICES + disabled-passes + V15 FX_MAJORS bundle;
  MAX_CONTRACTS sanity guard; MAX_RISK_VS_BUDGET; RiskAudit completeness;
  register_sl_hit 30min + 2h paths; register_tp_hit reset; update_daily_pnl
  brain routing.

**No regressions:**
- 158 pre-existing tests + 27 new = 185 tests green.

### 2026-04-28 — BiasResolver (AI override + state-backed cache)

**Extended:**
- analysis/bias.py:
  - AI_BIAS_PROMPT (port V15 bias_computer AI_BIAS_PROMPT, italiano).
  - compute_ai_bias() async function: AI override on ambiguous algo bias.
    - Validation rules ported from V15 (bias_computer.py:254-262):
      allowed_direction whitelisted to {BUY, SELL, NONE} (BOTH coerced
      to NONE); bias=NEUTRO -> allowed forced to NONE (V14 coherence
      rule); h1_compatibility clamped to [0.3, 1.0].
    - V16 stricter than V15 on garbage bias values: V15 silently
      coerced unknown bias to NEUTRO; V16 falls back to algo entirely.
    - V15 used temperature=0.7 here — preserved (DA RIVALIDARE in
      V16 calibration). Brain trade decisions stay temp=0.0.
    - Silent fallback to algo on any error (df4 invalid, AI text=None
      for credit/overload/invalid/unknown error_kind, JSON parse fail,
      missing/garbage bias field). No raise — bias is advisory.
  - BiasResolver class: stateful facade.
    - resolve(symbol, df4) -> BiasData. Cache via state.bias_cache,
      TTL=3600s (V15 BiasComputer.CACHE_TTL_SECONDS parity). Cache
      writes for both algo-only AND AI-resolved decisions, so cold
      starts find them all.
    - V15 router parity: df4=None/<20 bars -> NEUTRO BOTH 0.5 (the
      permissive default, NOT compute_algo_bias's strict NONE).
      Cached for full TTL.
    - state.brain.bias_calls_count is incremented when an AI call is
      attempted (algo-ambiguous path), regardless of success. A
      dedicated bias_ai_failures_count is on BACKLOG.
    - resolve() does NOT save SessionState — orchestrator owns the
      save (V15-BUG-9 discipline preserved).
    - invalidate(symbol|None) drops cached BiasEntry for symbol or
      clears the entire cache.
  - Conversion helpers _bias_data_to_entry / _entry_to_bias_data:
    persist allowed_direction/h1_compatibility/h1_reason as
    direction/confidence/rationale; ambiguous and rsi_h4_override
    are process flags, intentionally NOT persisted.
  - Layering: analysis/ does NOT import from brain/. AIClient is
    injected; a small _strip_code_fences helper is inlined to avoid
    importing brain.ai_client.extract_json_from_response.

**Tests added:**
- tests/test_bias_resolver.py — 14 tests, all green:
  unambiguous skips AI; ambiguous + AI happy/parse-error/timeout/credit
  fallback paths; cache TTL hit and miss (manipulated computed_at);
  invalidate(symbol) and invalidate(all); AI garbage bias -> fallback
  algo; NEUTRO+BOTH -> NONE coercion (V14 coherence); h1 clamp
  [0.3, 1.0]; BiasData<->BiasEntry roundtrip; df4 None/short ->
  NEUTRO BOTH 0.5 cached.

**Naming note:**
- The user originally proposed "BrainBias". Adopted "BiasResolver"
  as canonical instead — it does NOT implement BrainBase (no
  evaluate_entry / manage_exit), so it is not a Brain. It is an
  algo+AI dispatcher in the analysis/ layer.

**No regressions:**
- 144 pre-existing tests + 14 new = 158 tests green.

### 2026-04-28 — State persistence v2 (nested sub-dataclasses)

**Extended:**
- persistence/state_store.py:
  - SCHEMA_VERSION 1 -> 2.
  - SessionState restructured into nested sub-dataclasses:
    - DailyCounters (date, daily_pnl, daily_loss_hard_stop_hit,
      profit_target_hit, approved/executed/rejected_count). The two
      explicit halt flags are V16-new — V15 collapsed both into a
      single `halted` switch; V16 separates them so RiskManager can
      decouple "halted (no entries)" from "profit target reached
      (allow risk-reduced trades)".
    - BrainCounters (mr/tf wins/losses + bias_calls_count). The
      bias_calls_count field is V16-new for AI-cost analytics across
      sessions.
    - BiasEntry (frozen) + BiasCache. BiasEntry docstring documents
      the "frozen snapshot at computed_at" pattern: the consumer
      (BrainBias) is responsible for freshness checks; the store
      persists the snapshot, not its validity window.
    - CooldownState (per-symbol last_close_at + cooldown_until).
  - _migrate_v1_to_v2 implements flat -> nested with explicit field
    mapping; new v2 flags default to False/0 for migrated v1 data.
  - StateLoadMode enum (RESUME, FRESH) + load_state() helper:
    - FRESH archives existing state to <file>.deleted-<UTC>.json
      before returning a fresh SessionState (forensics > destruction).
    - auto_daily_reset=True triggers DailyCounters reset and clears
      halted/halt_reason when state.daily.date != today UTC. V15
      parity: V15 reset daily counters at UTC midnight in the loop;
      V16 makes the reset declarative at load time.
  - save() hardened with f.flush() + os.fsync(fd) on the temp file
    AND a best-effort directory fsync after the atomic rename, so the
    rename survives a crash on POSIX. Windows raises on dir fsync —
    swallowed with try/except as documented.

**Updated:**
- main.py: state.daily_pnl -> state.daily.daily_pnl in the post-load
  log line (only nested-access call site).

**Tests added:**
- tests/test_state_store.py — 17 tests, all green:
  fresh defaults; DailyCounters/BrainCounters/BiasEntry/BiasCache/
  CooldownState roundtrips; full-payload SessionState roundtrip via
  disk; .prev.json backup created on overwrite; v1->v2 migration
  preserves all data and defaults new fields; loud failure on
  unknown/future schema; load_state RESUME (existing + missing) and
  FRESH (timestamped archive); auto_daily_reset zeros daily + halt
  but preserves session_pnl and active_trades; corrupt-JSON load
  raises RuntimeError.

**No regressions:**
- 127 pre-existing tests (price_action 6, indicators 29, regime/bias 14,
  tech_snapshot 6, brain_tf 18, brain_mr 23, topstepx_v16 11, sizing 20)
  + 17 new = 144 tests green.

### 2026-04-28 — Day 1 — Foundations complete

**Created (Python modules):**
- core/contracts.py: TradeEntry (frozen), TradeRuntime, BrainContext, BrainDecision, EntryDecision, enums
- core/config.py: RuntimeConfig, RunMode, AccountKind, apply_express_profile
- brain/brain_base.py: BrainBase abstract (evaluate_entry, manage_exit + helpers)
- broker/broker_base.py: BrokerBase abstract (10 methods: connect, positions, orders)
- persistence/state_store.py: StateStore with atomic save, schema versioning, .prev.json backup
- persistence/logging_setup.py: LoggerBundle (6 JSONL streams + rotating system.log)
- trading/pnl_calculator.py: compute_pnl with fail-loud on missing specs
- trading/risk_manager.py: RiskManager with 7 pre-trade gates + daily reset + correlation

**Reused AS-IS from V15 (md5-verified):**
- broker/topstepx_adapter.py
- core/config_futures.py

**Validated with smoke tests:**
- contracts.py: imports clean, dataclasses instantiable
- config.py: state_file/log_dir auto-created on init
- brain_base.py: 2 abstract methods enforced, 4 helpers usable
- broker_base.py: 10 abstract methods enforced
- state_store.py: save/load/roundtrip/backup all working
- logging_setup.py: 6 streams + JSONL valid + system log working
- pnl_calculator.py: 5 tests passed (MES, 6B, MNQ, sub-tick, error guards)
- risk_manager.py: 10 tests passed (all gates + daily reset + P&L update)

**Bugs prevented architecturally (cannot recur in V16):**
- V15-BUG-2 (key name mismatch): dataclasses enforce field names
- V15-BUG-3 (dict fallbacks): BrainContext is typed, frozen TradeEntry
- V15-BUG-4 (manage_exit signature divergence): BrainBase enforces signature
- V15-BUG-5 (rsi_start not propagated): TradeEntry carries full snapshot
- V15-BUG (silent default tick specs in P&L): pnl_calculator raises on missing specs

**Bugs additionally noted today (to NOT replicate):**
- V15-BUG-9: state file not updated after trade close (saw 6B trade
  marked "active" in state.json while broker had already closed it).
  V16 must call state_store.save() in trade_closer immediately after
  position confirmation from broker.
- V15-BUG-10: SIGTERM handler does not log "shutdown saving final state".
  V16 should add a clean shutdown log line for traceability.

**V15 archived:**
- ~/apex_backups/apex_v15_final_20260428_1137.tar.gz (266 MB)
- V15 process stopped pulito via SIGTERM at 11:38 (PID 27801)
- Final session: daily P&L +$352, 6B trade closed at TP +$275

**Pending (next sessions):**
- brain/ai_client.py (Gemini round-robin, 17 keys from .env)
- broker/topstepx_v16.py (wrapper of topstepx_adapter implementing BrokerBase)
- trading/sizing.py (budget pre-check, MIN/MAX SL ticks per asset)
- trading/trade_opener.py (single-source replacement of V15's 4 duplicates)
- trading/trade_tracker.py (track open trades, dispatch to Brain)
- trading/trade_closer.py (close + cleanup orphans)
- brain/brain_tf.py (TF logic migrated from V15 with new contracts)
- brain/brain_mr.py (MR logic migrated from V15 with new contracts)
- orchestrator.py (async main loop)
- main.py (CLI entry point)

### 2026-04-28 (continued) — afternoon

**Created:**
- trading/sizing.py: budget_pre_check + compute_contracts (8 tests passed)
- brain/ai_client.py: AIClient with async Anthropic SDK + ask_for_decision()

**Validated live:**
- ai_client.py end-to-end test: real Claude Haiku 4.5 call returned valid JSON
- ai_client.py determinism test: 3 calls with temperature=0.0, identical responses

**Architecture decision: AI provider switched to Anthropic Claude.**
- V15 had migrated from Gemini to Claude Haiku in March 2026 (already in config_futures.py)
- V16 confirms: claude-haiku-4-5-20251001 as default model
- Single API key (no round-robin needed for paid plan)
- DEFAULT_TEMPERATURE lowered from 0.7 (V15) to 0.2 for consistency
- ask_for_decision() forces temperature=0.0 for Brain trade decisions

**Total Day 1 progress:**
- 11 Python modules created (9 new + 2 V15-reused)
- 44+ smoke tests passed across all modules
- AI integration validated live (Claude Haiku responding deterministically)
- 0 architectural debt carried over from V15

### 2026-04-28 (continued) — afternoon part 2

**Created:**
- trading/trade_opener.py: SINGLE point of trade open (V15-BUG-1 resolved)
  6 tests passed: paper, live-success, duplicate, broker-fail, programmer-guard, orphan-recovery
- trading/trade_closer.py: SINGLE point of trade close (V15-BUG-9 resolved)
  5 tests passed: paper-TP, paper-SL, live-success+cleanup, live-fail-graceful, SHORT-symmetry

**Architecture decisions:**
- TradeOpener has ONE _build_trade_entry() called by all 4 paths
  (paper, live-success, duplicate, orphan-recovered). V15 had 4 separate
  dict literals that drifted. V16 cannot drift by construction.
- TradeCloser does NOT mutate state.active_trades. Orchestrator owns
  the sequencing: close -> log -> remove from state -> save_state.
  This prevents V15-BUG-9 (state-broker desync after close).

**Day 1 final stats:**
- 15 Python modules created (13 new + 2 V15-reused)
- 55+ smoke tests passed across all modules
- AI integration validated live with deterministic responses
- 4 V15 structural bugs eliminated by architecture (BUG-1, BUG-3, BUG-4, BUG-5, BUG-9)
- ~50% of V16 codebase complete

**Pending for next sessions:**
- broker/topstepx_v16.py: wrapper of topstepx_adapter implementing BrokerBase
- trading/trade_tracker.py: track open trades, dispatch to Brain
- brain/brain_tf.py: TF logic migration
- brain/brain_mr.py: MR logic migration
- orchestrator.py: async main loop
- main.py: CLI entry

**Estimate:** 3-4 more sessions to V16 paper-executable.

### 2026-04-28 (continued) — afternoon part 3 — END OF DAY 1

**Created:**
- main.py: CLI entry point with argparse, .env loading, config build, banner

**Validated live:**
- python main.py --help: works, all options visible
- python main.py --mode dry: banner + logging + clean exit
- python main.py --mode paper --asset MES,MNQ: state/log paths separated by mode

**Day 1 final inventory:**
- 16 Python modules (14 new + 2 V15-reused)
- 57+ smoke tests passed
- Live AI integration validated (Claude Haiku deterministic)
- CLI executable end-to-end (state, logs, config, banner all working)
- 5 V15 structural bugs eliminated by architecture
- ~60% of V16 codebase complete

**Remaining for V16 paper-executable (estimate 4-6 hours):**
- broker/topstepx_v16.py (~60 min)
- brain/brain_tf.py (~60-90 min)
- brain/brain_mr.py (~60-90 min)
- trading/trade_tracker.py (~45 min)
- orchestrator.py (~60 min)

**Next session: start with broker/topstepx_v16.py (most complex remaining file).**

### 2026-04-28 (continued) — Day 2 morning — broker wrapper

**Created:**
- broker/topstepx_v16.py: BrokerBase implementation wrapping V15 TopstepXAdapter
  - dict returns -> OrderResult / Position / CancelResult typed
  - bool returns -> OrderResult with .success populated
  - Exceptions caught at boundary, never leak to orchestrator
  - Defensive int() conversion on order_id (returns success=False on
    non-numeric, instead of crashing)
- tests/test_topstepx_v16.py: 11 smoke tests with FakeAdapter
  (no broker connection required)

**Modified:**
- broker/broker_base.py: added `symbol` param to cancel_order and
  modify_stop (TopstepX requires symbol for ctx routing); added
  structural-aware optional params to place_market_bracket
  (sl_absolute_price, sl_ticks, tp_ticks, sl_source) — keyword-only
  via `*,` to prevent positional misuse

**Validated:**
- 11/11 tests pass
- Existing trade_opener.py and trade_closer.py unaffected (call sites
  already used keyword arguments and only the 5 original params)

**Total V16 progress:**
- 17 Python modules (15 new + 2 V15-reused)
- 68+ smoke tests passed across all modules

**Pending for next sessions:**
- brain/brain_tf.py (~60-90 min)
- brain/brain_mr.py (~60-90 min)
- trading/trade_tracker.py (~45 min)
- orchestrator.py (~60 min)

### 2026-04-28 (continued) — Day 2 afternoon — Brain TF migrated

**Modified:**
- brain/brain_base.py: evaluate_entry / manage_exit now `async def`
  (aligns with AIClient async-first; helpers `_hold/_exit/_partial_50/
  _move_sl_to_be` stay sync). Updated docstring on manage_exit:
  "Brain MUST NOT mutate ctx.runtime — orchestrator owns runtime
  updates; signal via BrainDecision.metadata."

**Created:**
- analysis/__init__.py + analysis/price_action.py: PA Engine extracted
  for reuse by brain_tf and brain_mr.
  - PASignals (10 pattern bool flags + volume_weak + candle_strength)
  - PAScore (bullish/bearish/dominant/strength/favor/adverse)
  - extract_pa_signals(tech), score_pa(signals, direction)
  - PATTERN_WEIGHTS const (engulfing 1.5, star 1.6, hammer 1.0, etc.)
- tests/test_price_action.py: 6 tests passed
- brain/brain_tf.py: V15 BrainTF logic migrated to V16 contracts.
  - TF_ASSET_PROFILES at top with V15 origin map (MES <- US500,
    MNQ <- US100, MYM <- US30, MGC <- XAUUSD, 6E <- EURUSD,
    6B <- GBPUSD, 6J <- USDJPY, 6C <- USDCAD). MCL/6A use
    GENERIC_PROFILE + one-time warning.
  - TF_TIME_STOP matrix (asset × session) rebuilt from V15 mapping.
  - Pre-validation: RSI zone [42,58], ATR>=0.8, capitulation candle.
  - AI prompt: V15 Chain-of-Thought 3 steps, condensed.
  - Post-validation watchdog: RSI re-check, conf>=70, RR floor,
    C>=90% + H4 weak guardia.
  - Price computation: applies SL_SAFETY_MULT 1.10 + MIN/MAX_SL_TICKS
    clamp, returns absolute entry/SL/TP prices via EntryDecision.
  - manage_exit: time-stop, same-candle dedup (no state mutation —
    candle_time exposed via BrainDecision.metadata), struct-flip
    detection, deep-discount H4 paradox guard, AI exit prompt
    biased toward HOLD.
- tests/test_brain_tf.py: 15 tests passed (FakeAIClient, no network).
  - Pre-val: 3 (RSI zone, ATR floor, capitulation)
  - Post-val: 3 (conf<70, RR<min, C95%+H4 weak)
  - Entry happy: 1
  - Generic profile warning: 1
  - Exit: 7 (time-stop, dedup, struct flip, AI hold, AI exit,
    AI error, SHORT symmetry)

**Architecture decisions:**
- Candle-gating ("evaluate only on M5 just-closed") lives in the
  ORCHESTRATOR, not the Brain. Brain only handles same-candle DEDUP
  inside manage_exit (idempotency for the same evaluation tick).
- PA Engine extracted now (vs inline) so brain_mr can import the
  same module. Saves a re-write next session.
- TF_ASSET_PROFILES inherited from V15 CFD calibration with explicit
  "v15_origin" tag and a "rivalidare in V16 (5-9 mag)" comment. The
  realistic ATR scale on CME futures is different from V15 CFDs, so
  the V16 calibration round will retune the multipliers. MCL and 6A
  log a warning on first use to flag them for the calibration log.
- BrainTF NEVER mutates ctx.runtime. Orchestrator must read
  BrainDecision.metadata["evaluated_candle_time"] and update
  TradeRuntime.last_exit_eval_time before persisting.

**V15 bugs prevented architecturally (cumulative):**
- V15-BUG-1, BUG-3, BUG-4, BUG-5, BUG-9 (already noted)
- V15-BUG (brain mutates router.active_trades): in V16, Brain returns
  metadata; orchestrator owns state. Test verifies brain_tf does not
  modify ctx.runtime.

**Total V16 progress:**
- 19 Python modules (17 new + 2 V15-reused)
- 89+ smoke tests passed across all modules
  (+6 price_action, +15 brain_tf since previous count of 68)

**Pending for next sessions:**
- brain/brain_mr.py (~60-90 min, can reuse analysis/price_action)
- trading/trade_tracker.py (~45 min)
- orchestrator.py (~60 min)

### 2026-04-28 (continued) — Day 2 afternoon — Structured rejection logging

**Modified:**
- brain/brain_tf.py: structured "entry_rejected" events on every
  reject path, written to `logger.brain_log.jsonl`. Each event carries
  `rule` (stable code) + `reason` (human) + cause-specific numerics:
  - PULLBACK_ZONE: rsi, rsi_prev, rsi_low, rsi_high
  - VOLATILITA_BASSA: atr_ratio, atr_floor
  - CAPITULATION_CANDLE: candle_strength, strength_floor, price, open
  - API_ERROR: error_kind, attempts
  - API_PARSE_ERROR: response_preview
  - POST_VAL_*: rsi, rsi_h4, ai_confidence, ai_sl_atr, ai_tp_atr, rr_min
  - AI_REJECTED: ai_confidence
  - PRICE_COMPUTATION_ERROR: atr_m5_points
  - RR_BELOW_MIN_POST_CLAMP: full diagnostic set (see below)

- brain/brain_tf.py — `_compute_prices` now returns a diagnostics
  dict (entry/sl/tp + atr_m5_points, sl_distance_pre/post_clamp,
  sl_ticks_pre/post_clamp, rr_pre/post_clamp, sl_min/max_ticks,
  clamp_active) instead of a bare tuple.

- brain/brain_tf.py — NEW reject path RR_BELOW_MIN_POST_CLAMP:
  after MIN/MAX_SL_TICKS clamp, recompute RR and reject if it
  fell below profile's rr_min. Critical signal for CALIBRAZIONE
  5-9 mag — the V15 sl_range values were calibrated on CFD ATR
  scale; on CME futures with smaller raw ATR, the MIN_SL_TICKS
  floor often distorts AI-proposed RR. Histograms of this rule
  firing in early sessions will tell us whether profiles need
  retuning.

- EntryDecision.metadata gained: rr_pre_clamp, rr_post_clamp,
  clamp_active (so accepted entries also carry the calibration
  signal).

**Added tests:**
- tests/test_brain_tf.py: +3 tests (now 18 total)
  - rr_below_min_post_clamp_rejected_with_diagnostics
  - rejection_logging_pullback_zone
  - rejection_logging_post_val
  via FakeLoggerBundle (in-memory FakeJsonlLogger).

**Total V16 progress:**
- 19 Python modules (17 new + 2 V15-reused)
- 92+ smoke tests passed across all modules
  (+3 brain_tf since previous count of 89)

### 2026-04-28 (continued) — Day 2 afternoon — Brain MR migrated

**Modified:**
- core/contracts.py: `TradeAction.MOVE_SL_BE` -> `MOVE_SL` (generic;
  target encoded in `BrainDecision.metadata["sl_target"]` as
  "breakeven" | "trailing"). Renamed once trailing-SL emerged in
  brain_mr — naming was misleading otherwise.
- brain/brain_base.py: helper `_move_sl_to_be` -> `_move_sl(sl_price,
  reason, target="breakeven", extra_metadata=None)`. Always sets
  `metadata["sl_target"]` for log analytics.

**Created:**
- brain/brain_mr.py: V15 BrainMR logic migrated to V16 contracts.
  - MR_ASSET_PROFILES at top with V15 origin map (same mapping as TF:
    MES <- US500, MNQ <- US100, MYM <- US30, MGC <- XAUUSD, 6E <- EURUSD,
    6B <- GBPUSD, 6J <- USDJPY, 6C <- USDCAD). MCL/6A use
    GENERIC_PROFILE + one-time warning.
  - MR_TIME_STOP matrix (asset × session) -> TUPLE
    `(deep_loss_min, breakeven_min)` (V15 shape).
  - Pre-validation:
      * RSI_NOT_EXTREME (BUY needs <32, SELL needs >68)
      * INDICES_NOT_PRO_TREND (V15 patch #20 ported into Brain:
        index BUY requires BULLISH H1, SELL requires BEARISH)
      * INDICES_RANGING (index in RANGING regime -> skip)
      * ATR_TOO_HIGH (atr_ratio > 1.8 cap)
  - Candle pattern signal SOFT: when reversal pattern absent,
    AI confidence threshold raises 60% -> 75% (post-validation
    enforces this independent of AI's response).
  - Post-validation watchdog: RSI re-check, conf threshold by
    pattern presence (POST_VAL_CONF / POST_VAL_CONF_NO_PATTERN),
    RR floor (POST_VAL_RR), C>=90% + H4 weak (POST_VAL_C90_H4).
  - Post-clamp RR check (RR_BELOW_MIN_POST_CLAMP) — same diagnostic
    set as brain_tf for the calibration round 5-9 mag.
  - manage_exit:
      * time_stop_deep / time_stop_be_failed branches
      * grace period indices (+15min if BE & toward mean)
      * trailing emergency (progress > 30, TP large enough) ->
        MOVE_SL with target="trailing" + metadata
        (atr_used, trailing_atr_mult, progress_pct, tp_distance)
      * RSI50 cross + P&L>0 + progress>15 + !rsi50_partial_done ->
        PARTIAL_50 with metadata `{set_be_after_partial: True,
        be_price: entry_price, rsi50_partial_done: True}` so the
        orchestrator atomically applies partial+BE in same tick
        (no 60s exposure gap)
      * auto partial 65% -> PARTIAL_50
      * same-candle dedup (idempotency, no state mutation)
      * AI exit per-candle with V15 prompt (regime-flip alert,
        deep-discount H4 paradox, VWAP target check)

**Architecture decisions:**
- INDICES_NOT_PRO_TREND/INDICES_RANGING enforced ONLY in Brain
  (not duplicated in router/orchestrator). Trading rules belong in
  the Brain; orchestrator owns flow control, not setup eligibility.
- PARTIAL_50 + set_be combo emitted as a single decision with
  metadata. Orchestrator applies partial first, then SL move atomically
  -> avoids the 60s exposure gap a two-tick implementation would
  introduce.
- MOVE_SL_BE renamed to MOVE_SL with `sl_target` metadata: keeps
  the action enum honest (target can be breakeven, trailing, or
  future variants like "structural") without naming-debt.
- Helpers `_classify_session`, `_parse_ai_json`, `_reject` duplicated
  between brain_tf and brain_mr — rule of three, will extract when
  a third Brain emerges (BrainBias or similar).

**Added tests:**
- tests/test_brain_mr.py: 23 tests passed, FakeAIClient + FakeLoggerBundle
  + `_SessionPatch` ctx manager (pins `_classify_session` for
  deterministic time-stop tests).
- tests/test_brain_tf.py: existing 18 tests still pass after MOVE_SL
  rename (no call sites in brain_tf — verified).
- tests/test_topstepx_v16.py: 11 tests still pass.
- tests/test_price_action.py: 6 tests still pass.

**Total V16 progress:**
- 21 Python modules (19 new + 2 V15-reused)
- 115+ smoke tests passed across all modules
  (+23 brain_mr since previous count of 92)
- 0 regressions on existing modules after MOVE_SL_BE rename

**Pending for next sessions:**
- trading/trade_tracker.py (~45 min)
- orchestrator.py (~60 min)

### 2026-04-28 (continued) — Day 2 evening — Indicators pipeline

**Created (analysis package):**
- analysis/market_data.py: `MarketDataProvider` Protocol +
  `BrokerMarketDataProvider` (duck-typed wrapper) + `FakeMarketDataProvider`
  (canned bars for tests). Indicators do NOT couple to TopstepX.
- analysis/indicators/ sub-modules (port fedele V15 tech_calculators.py,
  modularizzato — V15 was 744 lines in one file):
    rsi.py / atr.py / macd.py / divergence.py / candle_strength.py
    candles.py (hammer, engulfing, doji, piercing/dark, star, volume_weak)
    vwap.py / structure.py (+ STRUCT_THRESHOLDS) / swing.py
- analysis/regime.py: `determine_regime(...)` extracted from V15
  monolith `_determine_regime_multitf` as pure function.
- analysis/bias.py: `compute_algo_bias(df4) -> BiasData` (typed
  dataclass) port of V15 bias_computer.compute_algo_bias.
  AI override path intentionally OMITTED — V16 will introduce
  BrainBias on its own track.
- analysis/tech_snapshot.py: `TechSnapshot` frozen dataclass
  (single source of truth, V15-BUG-3 fix at indicator boundary)
  + async `build_tech_snapshot(symbol, provider, tick_size, ...)`
  orchestrator. Returns None on insufficient bars (V15 parity).
  No cache (decision D3=α, YAGNI; γ candle-keyed cache documented
  as fallback if profiling in CALIBRAZIONE 5-9 mag requires it).

**Modified:**
- analysis/price_action.py: `extract_pa_signals(source)` now reads via
  attribute-or-key, accepts both TechSnapshot (preferred) and dicts
  (legacy test mocks). Doji split now also handles V15-style
  `dragonfly`/`gravestone` doji_type values.
- core/contracts.py: `BrainContext.tech_now: Any` (typed as
  TechSnapshot at runtime; `Any` annotation avoids circular import).
- brain/brain_base.py: `evaluate_entry(symbol, tech)` docstring now
  documents `tech: TechSnapshot` (not dict).
- brain/brain_tf.py: every `tech.get(k, default)` and `tech[k]`
  replaced with `tech.<field>` (typed attribute access). Same for
  prompt builders, _compute_prices, manage_exit.
- brain/brain_mr.py: same refactor, ~32 dict accesses converted via
  scripted regex pass.

**Added tests:**
- tests/_fixtures/bars.py: deterministic OHLCV synthetic helpers
  (uptrend, downtrend, sideways, with_spike, hammer_at_end,
  bull_engulfing_at_end, doji_at_end, with_pivot_low/high,
  hh_hl_h1, lh_ll_h1, ranging_h1).
- tests/test_indicators.py: 29 tests (RSI, ATR, MACD, divergence,
  candle_strength, candles patterns, VWAP, structure, swing).
- tests/test_regime_bias.py: 14 tests (regime tabular + bias paths
  including RSI override, EMA/struct conflict, insufficient bars).
- tests/test_tech_snapshot.py: 6 tests (insufficient bars,
  missing TF, happy-path full snapshot, frozen-dataclass guard,
  swing data lazy population, H4 bias coherence).
- tests/test_brain_tf.py: 18 tests still passing after refactor
  (added `_to_snap` helper to convert legacy test-dicts).
- tests/test_brain_mr.py: 23 tests still passing after refactor
  (same `_to_snap` helper, scripted call-site wrap).

**Architecture decisions:**
- TechSnapshot is the V15-BUG-3 fix at the indicator boundary.
  Brain code reads typed fields with default values defined in
  the dataclass — no more "key dimenticata che fa partire un
  fallback nascosto".
- Sub-modules per calculator (rsi.py, atr.py, candles.py, ...)
  rather than V15's 744-line monolith. Each file 50-150 lines.
- MarketDataProvider Protocol decouples indicators from the broker.
  BrokerMarketDataProvider is a duck-typed wrapper that will work
  as soon as BrokerBase grows `fetch_bars` (BACKLOG'd).
- AI bias override (V15) intentionally NOT migrated — bias H4
  algorithmic is the default; AI bias becomes BrainBias next.
- No cache layer (decision D3 = α, no premature optimization).
  Will revisit with profiling data from real loop runs.

**Total V16 progress:**
- 33 Python modules (31 new + 2 V15-reused) — analysis package
  added 11 files
- 196+ smoke tests passed across all modules
  (+29 indicators, +14 regime/bias, +6 tech_snapshot, +6 fixtures
  smoke = +55 since previous count of 115)
- 0 regressions on Brain tests after TechSnapshot adoption
  (proof that the typed-snapshot refactor preserved V15 behavior)

**Pending for next sessions:**
- trading/trade_tracker.py (~45 min)
- orchestrator.py (~60 min)
- Wire BrokerBase.fetch_bars (currently duck-typed in
  BrokerMarketDataProvider)

### 2026-04-28 (continued) — Day 2 evening — Position sizing extension

**Modified:**
- trading/sizing.py: extended (Day 1 primitives `budget_pre_check` and
  `compute_contracts` kept intact — used internally by the new entry
  point). Added:
    * `points_to_ticks(symbol, distance_points) -> int` — fail-loud on
      unknown symbol / non-positive distance.
    * `points_to_dollars(symbol, distance_points) -> float` — wraps
      tick_size + tick_value lookup so callers never read ASSETS_MAP
      tick mechanics inline.
    * `SizingDecision` (frozen dataclass, 5 core fields:
      contracts/skip/reason/sl_ticks/real_risk_usd + nested audit) —
      orchestrator reads core, JSONL logger reads audit.
    * `SizingAudit` (frozen dataclass, 12 fields: symbol,
      risk_multiplier, target_float, sl_distance_points, tick_size,
      tick_value, sl_usd_per_contract, risk_usd_target,
      daily_budget_cap_usd, max_contracts_used, clamp_active,
      inverted_quote) — diagnostics for calibration round 5-9 mag.
    * `size_for_entry(entry, symbol, balance, ...) -> SizingDecision` —
      public orchestrator-facing API. Consumes EntryDecision from
      Brain, reads risk_multiplier from entry.metadata, delegates
      ticks-math to compute_contracts, emits typed result.

**V15 parity verified:**
- Sizing formula `risk_usd / (sl_ticks × tick_value)` = V15
  APEX_PREDATOR_V15.py:775-826 (read and confirmed identical).
- `inverted_quote` (6J/6C) is audit-only in V16, matching V15 which
  uses it solely for an info log post-sizing (line 1665) — never in
  dollar math. The split SizingDecision/SizingAudit makes the
  audit-only nature self-documenting (the flag lives in Audit).

**Architecture decisions:**
- Layered: Day-1 primitives kept as internal math, new public API
  on top — zero risk of regressing the primitive.
- AccountInfoProvider Protocol intentionally NOT introduced now —
  orchestrator-facing decision (BACKLOG'd). Keeps size_for_entry a
  pure function unit-testable without mocking a Protocol.
- SizingDecision split (core 5 + nested audit 12) — orchestrator
  acts on `decision.contracts/skip/etc.`; logger consumes
  `decision.audit_dict()` for JSONL emission.

**Added tests:**
- tests/test_sizing.py: 20 tests passed, broken into 3 tiers:
    Primitives backfill (Day 1 code never had a test file): 6 tests
    Conversion helpers: 4 tests
    size_for_entry: 10 tests including 2 dedicated 6J cases that
    verify both math correctness on the unusual tick_size scale
    (1e-7 vs 1e-4 for 6E) and that inverted_quote is propagated
    in audit only.

**Total V16 progress:**
- 33 Python modules
- 216+ smoke tests passed across all modules (+20 sizing)
- 0 regressions on prior tests after sizing extension
