Commit Graph

431 Commits

Author SHA1 Message Date
0ed2cfdff3 fix(exec): UCITS live-run 478/10349 + false-EXECUTED (Mon 2026-07-20)
The first live UCITS paper run on bizworx reported EXECUTED/exit-0 but was
degraded: the IEF->CBU0 leg got 0 fills and DBMF hung Submitted.

- Error 478 (contract conflict): the ISIN-qualified UCITS contract kept a
  localSymbol/secId conflicting with the pinned conId (requested CBU0 vs
  canonical CSBGU0). Strip secId/secIdType/localSymbol after resolve and route
  by conId + SMART.
- Error 10349 (TIF preset): the bare MarketOrder had no TIF so the account
  preset forced DAY + cancel/reconfirm. Set explicit tif="DAY".
- False-success (the dangerous one): plan.executed counted Cancelled/Submitted
  legs as placed, and exec-record booked on any non-blocked run. Now executed
  requires EVERY leg filled; a degraded run prints DEGRADED, exits 1, and
  skips exec-track booking so the reconciliation gate stays WAIT (never books a
  phantom rebalance) — real money errs false-WAIT.

Tests: new test_ucits_exec_fill_status.py + degraded cases in test_execution.py
and test_execute_multistrat_b2a.py (TDD failing-first). 523 exec/forward/gate
tests green, mypy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:20:47 +00:00
04305a1c72 feat(orchestration): fold bybit precompute into a Dagster asset (K8sRunLauncher-ready)
Move _persist_bybit_book + _BYBIT_BOOK_SIDS to application/bybit_book_persist.py
(single source of truth; breaks the cli<->assets coupling). Add a new nightly
asset bybit_book_precompute (deps=[bybit_warehouse_refresh]) that wraps the
same helper backtest-refs/bybit-persist-sleeve-ret run — writing bybit_sleeve_ret
+ the report-kind gate refs in one compute-once pass. Tagged
dagster-k8s/config 6Gi req / 8Gi limit so K8sRunLauncher runs it in its own
right-sized Job (never the 4Gi daemon). Wired into combined_book_job + defs;
tests updated (13->14 assets + presence + wiring). CLI commands left intact
(deleted at the cron layer later). 60 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 09:36:05 +00:00
jgrusewski
38b8ff26d2 feat(exec): multistrat self-cleans its OWN superseded lines (fixes lingering IDTM)
The IEF->CBU0 UCITS remap (515117d, 2026-07-16 12:28) left the paper account
holding IDTM (the old thin Dist line, bought on the 07-15/16 09:00 runs under the
prior IEF->IDTM config). The rebalancer only ever traded its TARGET symbols
(_plan iterates targets.items()), so it never sold the orphaned IDTM — it lingered
in the account NLV and (correctly) tripped the stray-holdings warning.

Fix — scoped, safe self-cleaning:
- superseded_holdings(): the symbols multistrat ITSELF previously traded (per its
  own exec_fills) that are still held but no longer a target — priced at the last
  fill. Scoped to multistrat's own fills, so a position ANOTHER strategy holds on
  the SHARED IBKR account is never touched.
- plan_and_record: adds them at target weight 0 (+ LSEETF/USD spec so a UCITS line
  resolves by symbol to SELL) before the rebalance. Best-effort: a scan failure
  never blocks a real rebalance.
- _plan: a full EXIT (weight 0 on a held line) ALWAYS trades — hysteresis damps
  churn, it must not strand a position we're deliberately closing.

Next UCITS rebalance (09:00) closes IDTM and buys CBU0; the warning clears. Also
prevents future mapping-change legacy from silently accreting in the NLV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:30:00 +02:00
jgrusewski
713424a89d feat(fund): multistrat_levered — observe-only levered shadow of the ETF book + financing sensitivity band
The multistrat ETF book is an UNDER-levered risk-parity book: it runs at ~5.7%
vol vs its own 10% target because leverage is capped at 1x (MAXLEV=1.0), leaving
return on the table. Add a return-defined observe-only shadow (cf.
bybit_4edge_levered) that levers the SAME series to the 10% vol target minus the
HONEST financing drag on the borrowed leg.

- LeveredShadowStrategy (paper_strategies.py): L = min(max_lev, target_vol/vol),
  floored at 1x; levered_ret = L·r − (L−1)·financing/252. Never executed.
- multistrat_levered_nav asset (deps=multistrat_nav) publishes its forward track
  + basis-matched backtest ref like the base; registered in the nightly job.
- multistrat_levered registry entry + SIM_BOOKS (Backtest switcher).
- financing_bps=530 GROUNDED in IBKR's real USD schedule (verified 2026-07-18:
  Fed funds 3.63% + Pro tiered spread → 5.83% <$100k / ~5.3% $100k-1M).
- leverage_financing_sensitivity() + a cockpit BAND on the levered book's
  Backtest view: levered return/vol/Sharpe across 1..6.83% + the break-even
  (= the book's 7.7% return), so the verdict is never hostage to one rate.

Honest finding it surfaces: at any realistic IBKR retail rate (~5-7%) levering
this modest-return book LOWERS risk-adjusted return (Sharpe 1.35 -> ~0.9); it
only pays at institutional financing (~1-3%). The financing drag is what makes
the shadow honest (without it, free leverage would look like 13.5%/Sharpe 1.35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:15:55 +02:00
jgrusewski
f6f1c9082b chore(cockpit): strip jargon text blocks + hard-coded values from the pages
Following the /paper blurb removal, sweep the remaining pages:
- Backtest (sim.html): drop the paragraph-long verdict banner; keep a one-line
  plain subtitle ("a what-if on capital & period at real trading cost").
- Measured-cost caption (_sim_result.html): visible text now plain
  ("real trading cost, per coin — fee + spread · avg drag X%"); the technical
  detail (L1 quoted spread / current-snapshot caveat / "not a slider") moves to
  the hover tooltip. IBKR book caption likewise plain.
- Hard-coded "vol-targeted @ 20%" label (Overview, paper_crypto, strategy) ->
  "each shown on its own" (drops the hard-coded 20% + the jargon).

Tests updated to the new plain copy; technical strings that stay in tooltips
(real L1 quoted spread, the snapshot caveat) are still asserted there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:46:51 +02:00
jgrusewski
ebd31d7c07 chore(cockpit): drop the orientation blurb on /paper — the two cards say it
The Paper-books landing led with a paragraph-long verdict banner ("Two paper
accounts, one job..."). The two book cards below already convey account value,
holdings and status, so the blurb was redundant text. Keep just the h1 + a
one-line subtitle. Removes the two tests that asserted the banner copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:36:06 +02:00
jgrusewski
9e3ff64e1d feat(cockpit): warn when the IBKR account holds a symbol outside the book
Verifying the IBKR side surfaced an IBIT (bitcoin ETF) position in the DU paper
account on 2026-07-14 — a legacy leftover from the pre-UCITS US book (exec_fills
has zero IBIT fills; the current multistrat only ever traded the 5 UCITS lines).
It was liquidated in the 07-15 UCITS transition, but until then it sat silently
in the account NLV, unattributed to any book.

Add a stray-holdings guard: `_expected_book_symbols()` derives the allowed set
from the SAME two SSOTs the executor uses (multistrat.FUND_INSTRUMENTS + the
UCITS map) — never a hardcoded duplicate. Each holding gets an `in_book` flag;
StrategyDetail carries `stray_holdings` (held, nonzero-qty symbols outside the
set). The holdings view now renders a red "Position outside the book: <syms>"
banner + a ⚠ on the offending rows, so legacy/stray ballast can't inflate the
NLV unnoticed. [] = clean (no warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:29:38 +02:00
jgrusewski
fd2316b1e8 fix(cockpit): Backtest state panel said "active sleeves" for a coin count
Verifying the Backtest page: the scrub state panel labelled n_active "active
sleeves", but n_active counts the instruments HELD that day — the Bybit book's
~64-coin liquid universe (compare_measured_cost sets n_active=[n_coins]*len),
the ETF book's ~5 ETFs — NOT the book's sleeves (its 4 edges). So a 4-edge book
read as having "64 sleeves". Relabelled "active positions", accurate for both
the coin book and the ETF book.

Regression test pins "active positions" (and asserts "active sleeves" absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:56:06 +02:00
jgrusewski
b1ceee39a5 fix(cockpit): Overview real-trades return was a naked % under the "allocated" column
Verifying the Overview: the IBKR "↳ real trades" sub-row put the real account's
cumulative return (e.g. -0.01%) bare in the borrowed "allocated" column — on
desktop it read as an allocation, and on mobile the card reflow labelled it
"allocated: -0.01%" (actively wrong; it's a return, not capital allocated).

The data was correct and reconciled (real -0.01% vs plan +0.56% => "0.6% behind
plan"); only the labelling was off. Mirrored the exec-twin row's self-labelling:
the return now renders "real -0.01%" inline, and every sub-row cell carries an
accurate data-label (real return / trades / vs plan / cost) so the mobile card
reflow shows the true meaning, not the borrowed column name.

Test pins the labelled form ("real +3.10%"), so the label can't silently drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:46:03 +02:00
jgrusewski
8bd87e8ab1 fix(cockpit): honest FAIL verdict + plain UCITS holdings on the strategy detail
Two inconsistencies found verifying the IBKR /strategy/multistrat page:

1. A FAILED backtest with a high Sharpe (multistrat: Sharpe 1.7, gate FAIL)
   rendered a GREEN "Validated edge ... backtest passed" banner directly above
   the FAIL badge — the Sharpe>=1.0 fallback overrode the explicit FAIL. The
   verdict now respects the badge: an explicit FAIL is an amber "Backtest
   didn't clear the gate ... treat it as unproven", never a green "passed".
   The backtest Sharpe figure also goes neutral-grey (not alarming red) when
   unvalidated — the number itself (1.7) isn't bad, it just didn't validate.

2. The UCITS lines the EU DU account actually holds (CSPX/CBU0/IDTM/IGLN/ICOM)
   weren't in ASSET_DESCRIPTIONS, so the holdings "what it is" column read
   "other". Mapped each to its plain description (US stocks / US Treasuries /
   Gold / Commodities), same as its US cousin.

Regression tests: a passed=False + Sharpe 1.7 detail renders amber
"didn't clear the gate" and never "backtest passed"/"Validated edge"; UCITS
tickers resolve to plain descriptions, never "other". Verified desktop+mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:31:27 +02:00
jgrusewski
ce464f82f4 fix(cockpit): IBKR paper card was falsely "not connected" — it is live via UCITS
The IBKR paper account (DU9600528) IS connected and trading: the armed
fxhnt-ucits-rebalancer (0 9 * * 1-5) books the multi-strat ETF leg through
UCITS lines (EU KID/PRIIPs blocks the US-ETF leg, whose rebalancer is
correctly suspended). Prod already rendered it ($1,029,621, 5 holdings).

Two self-inflicted "not connected" artifacts fixed:
- scripts/dev_cockpit_local.py did NOT wire ibkr_account_repo, so the LOCAL
  dev cockpit always showed the IBKR card empty regardless of real data —
  a misleading local-verification artifact. Now wires every repo prod wires.
- paper.html's new orientation banner hardcoded "not connected yet" (written
  from that stale local view). Made it data-aware (keyed on ibkr_view) so it
  states the live NLV + holdings when trading, "not connected yet" only when
  genuinely empty — it can't drift from the card beneath it.

Adds a regression assertion: connected account => banner says "live now,
trading through UCITS" and never "not connected yet".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:11:41 +02:00
jgrusewski
bd5f820697 fix(cockpit): restore guarded exec-twin card + align its two tests to the restored behavior
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:46:06 +02:00
jgrusewski
d5f871af0a fix(cockpit): DB-name fallback for non-registry sids + update strategy-detail tests to the redesign
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:42:30 +02:00
jgrusewski
a71737eeca refactor(cockpit): ONE name resolver — fold display-name overrides into the registry SSOT
Root cause of the 'half' inconsistency: two name paths disagreed — the fleet
(dashboard_service) read the raw DB strategy_registry.display_name (seeded from
the registry), while other views used display_name()'s _OVERRIDES map (checked
first). So multistrat showed 'Adaptive multi-strat book (SPY/IEF/GLD/PDBC/DBMF)'
in the fleet and 'Multi-asset ETF portfolio' elsewhere. Fix: fold the overrides
INTO the registry display_name (multistrat->'ETF Portfolio', vrp->'Options income
(put spreads)'), delete _OVERRIDES, and route the fleet/detail through the ONE
display_name() resolver (registry SSOT, always fresh — no seed-lag). Every
surface now shows the same name. Locally verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:05:09 +02:00
jgrusewski
048e5ca26d fix(cockpit): name the deploy book 'Bybit 4-edge book' consistently on every surface
Trim the registry display_name (drops the (naive eq-wt, forward) parenthetical;
exec/levered keep their qualifiers), rename the /paper/crypto h2 (was 'Bybit
paper book') and the sim pill (was 'Bybit 4-edge'). Overview/paper/sim/strategy
all render 'Bybit 4-edge book' now (locally verified); old names gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:53:03 +02:00
jgrusewski
0c4ffc11fc fix(equity): reseed UCITS volumes in equity integration test + fail loud on unknown regime
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:53:58 +02:00
jgrusewski
797462bdf7 feat(equity): equity_allocation_inputs provider merged into record_allocation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:48:27 +02:00
jgrusewski
8b887af153 test(alloc): repoint funding-filter test to positioning (bybit_4edge now research-tier)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:52:29 +02:00
jgrusewski
6abf2a8289 fix(alloc): killswitch on forward record (not backtest), ISO-normalize honest keys, unviable-capacity=0 sentinel 2026-07-16 00:07:48 +02:00
jgrusewski
efc304e3f9 feat(alloc): capacity policy knobs (min_sharpe, equity_default_ceiling) + ceiling shown next to the curve (spec 6/8) 2026-07-15 23:45:42 +02:00
jgrusewski
242277db30 feat(alloc): record_allocation sizes from honest-reference + caps at capacity; graceful store injection (spec 4b/4c) 2026-07-15 23:41:10 +02:00
jgrusewski
b6e3f24ed2 fix(honest-ref): impact calibration (400bps + turnover-basis), edge-4 sign, book union-fill, wire G4, leverage-ceiling guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:35:57 +02:00
jgrusewski
dd55f331c3 feat(honest-ref): research report + CLI; consolidate spread reader to PaperRepo SSOT (spec 9) 2026-07-15 22:13:30 +02:00
jgrusewski
5019e9ffbb feat(honest-ref): sqrt-impact/spread-floor lock + real-spread snapshot reader (spec 0c) 2026-07-15 21:42:51 +02:00
jgrusewski
edf7e41527 feat(honest-ref): AUM capacity curve + missing-ADV=zero-capacity fix (spec 0b) 2026-07-15 21:38:23 +02:00
jgrusewski
4dbaf90609 feat(honest-ref): coin-level honest return core + fidelity invariant (spec 0a/0d) 2026-07-15 21:30:00 +02:00
jgrusewski
1958315344 refactor(cockpit): drop the "REAL PAPER" chip — a single PAPER badge everywhere
The IBKR-backed sections carried a second exec chip ("REAL PAPER", blue) on top of
the plain PAPER badge, doubling up on the fleet exec row and the IBKR account cards.
It's redundant (the section titles already say "IBKR paper account") and crowds narrow
mobile rows. Per request, collapse to just PAPER:

- fleet exec row (cockpit.html): drop the extra REAL PAPER badge — the row already
  renders exec_badge = PAPER right before it.
- IBKR account cards (paper.html) + "Following the plan" block (strategy.html): swap
  real_paper_badge() -> exec_badge('paper'), matching the crypto book's PAPER chip.
- remove the now-unused real_paper_badge macro (_macros.html) and .badge-exec.real CSS
  (base.html); refresh the stale REAL PAPER prose in paper.html / app.py docstrings.
- tests updated to assert the PAPER badge instead of REAL PAPER (the real-trades sub-row
  and behind/ahead-of-plan pill are unchanged).

Verified locally in a real browser at mobile widths: both /paper cards now read
"… PAPER", no REAL PAPER anywhere, all routes still 200. 224 web/forward tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:54:20 +02:00
jgrusewski
595dc5da0e feat(forward): pin re-homed sleeves' recovered OOS inception in git (reproducible on DB rebuild)
The Phase-0b venue re-home reset the crypto sleeves' forward OOS clock to 2026-07-14
(definition-hash change → auto-re-inception). Since these were faithful re-homes (same
edge + PIT data, record_mode=recompute), the pre-re-home days are still valid OOS; on
2026-07-15 they were recovered by backdating the active anchors' t0 in the DB (unlock
06-20 → recon-gate PASS +4.57%; xsfunding/positioning/bybit_4edge/levered → 8d).

That backdate lived ONLY in the DB — a full operational-Postgres rebuild would let the
anchors re-incept to `today` and silently lose the recovered window. This makes it
reproducible from git (the SSOT):

- forward_definition.inception_override(sid): returns a definition's declared
  `inception_t0.date`, VERSION-PINNED (honored only when its `version` == the
  definition's current version — a genuine re-tune bumps `version`, auto-ignoring a
  stale pin and correctly getting a fresh today-clock). Lives OUTSIDE `params` so it is
  NOT hashed — declaring it never itself re-inceptions (verified: the five sleeves'
  definition_hash is byte-identical to their live DB anchors).
- run_track: at inception, t0 = inception_override(sid) if present AND <= today, else
  today (never incept in the future — clock-skew/not-yet-reached pins fall back to today).
- registry: xsfunding/unlock/positioning/bybit_4edge/bybit_4edge_levered carry their
  pinned pre-re-home t0 (07-07/06-20/07-06/07-07/07-07, version 2).

Tests: inception_override version match/stale/absent + not-hashed + a regression pin on
the recovered dates; run_track honors the pin, ignores a version-stale pin, and falls
back to today for a future pin. 262 forward/registry tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:31:16 +02:00
jgrusewski
97b530de10 fix(vrp): hide archived VRP from EVERY cockpit render path (not just Overview)
The first pass only filtered the Overview fleet(); the Backtest tab still showed
vrp as "Options income (put spreads)" + a "Live forward — Equity VRP" section,
and /strategy/vrp still rendered. Fix at the SOURCE so every registry-driven path
inherits the hide, then patch the pages that bypass the source.

- forward_nav.ForwardNavRepo.registry(): SINGLE authoritative filter — exclude
  rows whose STRATEGY_REGISTRY entry is `archived: True`. Every caller (fleet,
  overview gate specs, detail()'s reg lookup) now drops vrp/vrp_exec automatically.
  DB rows stay seeded (code + OPRA data kept) — only hidden from the UI.
- dashboard_service.detail(): explicit archived guard -> returns None so
  /strategy/vrp 404s (belt-and-suspenders over the registry() filter).
- dashboard_service: _active_ibkr_account_sids() helper excludes archived sids;
  the IBKR account per-strategy breakdown + recent-fills roll-up iterate it, so
  vrp never renders a row/label on the account page.
- app.py: _SIM_BOOKS excludes archived books -> no vrp Backtest pill, and a
  ?book=vrp request falls back to the default book (folded "Live forward" section
  can't surface vrp either). _SIM_BOOK_LABELS derives from the filtered list.
- fleet() keeps its archived-check as defense-in-depth (registry() is now the
  authoritative filter).

Render paths fixed: Overview, Backtest (/paper/sim + /paper/sim/run pills,
selector, folded Live-forward), /strategy/<sid> detail, IBKR account per-strategy
breakdown. Verified end-to-end: no "vrp"/"Equity VRP"/"Options income" on any page,
detail("vrp"/"vrp_exec")=None, /strategy/vrp=404, raw DB rows still seeded.

Tests: registry()/detail()/sim-book-list/IBKR-breakdown all assert vrp ABSENT;
test_cockpit_vrp_render flipped to assert 404; test_ibkr_sim_web asserts vrp
excluded from pills + falls back; account-view/detail-breakdown tests use a
synthetic non-archived `newstrat` for the dimmed/scale rows.

uv run pytest -q: 2006 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:02:36 +02:00
jgrusewski
cd6e554534 chore(vrp): de-list VRP sleeve from cockpit + stop nightly (Option 1 — code+data kept)
VRP (equity-vol XSP put-credit-spreads) is FALSIFIED/shelved (co-crashes,
fails the marginal gate). Hide it from the cockpit and stop its nightly Dagster
asset, while KEEPING all VRP code and the 13yr OPRA data archived.

- registry: mark both `vrp` and `vrp_exec` entries `archived: True` (entries KEPT).
- dashboard_service.fleet(): single hide point — skip any sleeve whose registry
  meta is archived, so it never renders (deploy/research grouping or exec-twin).
- definitions.py: de-wire `vrp_nav` from the import, combined_book_job selection,
  and Definitions assets list — nightly no longer materializes it.
- assets.py: drop `vrp_nav` from cockpit_forward deps; KEEP the vrp_nav function.
- tests: flip test_orchestration_definitions to assert vrp_nav is de-wired
  (12 assets, not a cockpit_forward dep); switch the stale-badge render test off
  the now-hidden vrp; add test_fleet_omits_archived_sleeves.

uv run pytest -q: 2006 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:31:43 +02:00
jgrusewski
bcf58e703d fix(exec): entry-floor threshold on the sizing envelope, not the raw NLV
The IGLN gold-sleeve 'priced but no order' bug. scaled_weights sizes orders to
the paper envelope (envelope*within_weight), but _plan's entry-floor/hysteresis
threshold used entry_floor*nlv (the full $1M account NLV) — so the effective
floor was ~entry_floor*(nlv/envelope) of the envelope (~5%), silently dropping
every sleeve below it. A 3% gold sleeve at a $100k envelope on a $1M account
skipped. FIX: rebalance_weights takes sizing_capital (the envelope); the floor
is entry_floor*min(sizing_capital,nlv). Sizing + recording unchanged. Regression
test: a 3% sleeve survives WITH the envelope basis, drops without it. 2005 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:14:06 +02:00
jgrusewski
29b6c19167 fix(vrp): robust freeze step-back on degenerate session + document ref-vs-live asymmetry
Re-review Minors.

MINOR-1 (document intentional asymmetry): on a degenerate slice where a
rung's own put legs price (real _mark_spread actual mark) but no ATM
call/put pair prices anywhere (model_spread_value -> None), the pure-model
backtest advance DROPS the rung (its only mark IS the model value) while
the live exec plan_vrp_ladder HOLDS it (take_profit is False when the
model signal is None, so it manages on the real fill mark until DTE<=1 or
the model recovers). Correct — the twin has strictly more information.
Making them consistent would regress either the model-only backtest (a
raw-leg fallback re-introduces the mark noise this fix removed) or the
model-signal profit-take (discarding a real fill mark). Added precise
cross-referencing comments at both divergence points. No thresholds
changed.

MINOR-2 (robust not fragile): _freeze_latest_session only stepped back on
DatabentoRangeUnavailable; a PUBLISHED-but-degenerate session makes
_xsp_forward_estimate/_forward_from_marks raise RuntimeError ("cannot
estimate forward" / "no matched call/put strike" / "no ATM call/put
pair"), which was uncaught -> plan_and_record_vrp and nightly vrp_nav
hard-crashed. Now the bounded step-back loop also treats a
forward-estimation RuntimeError as a step-back trigger, matched narrowly
via _is_degenerate_forward_error so an unrelated RuntimeError still
propagates; stays bounded and still raises DatabentoRangeUnavailable
loudly when exhausted. Both live-exec and nightly paths get more robust.

Tests: degenerate-then-usable step-back; all-degenerate -> loud raise;
unrelated RuntimeError propagates (not swallowed); predicate matches only
the forward messages. uv run pytest -q -> 2002 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:07:22 +02:00
jgrusewski
edf7a659f6 fix(ucits): resolve UCITS contracts by ISIN + size from real IBKR price
execute-multistrat --venue ucits was unrunnable and mis-sized:
- place_order hardcoded Stock(symbol, SMART, USD) so LSE-listed UCITS ETFs
  (CSPX/IBTM/SGLN/ICOM/DBMF) failed IBKR resolution (Error 200);
- apply_ucits_map reused the US ticker's Yahoo price for the UCITS ticker, so
  shares = weight*envelope/price was wrong;
- any single unmapped sleeve refused the whole (PRIIPs-legal) rebalance.

Fix:
- Order carries optional exchange/currency/sec_id_type/sec_id (US path keeps
  SMART/USD defaults, byte-unchanged). IbkrBroker resolves UCITS orders via
  Contract(secIdType=ISIN, secId, currency=USD) + qualifyContracts; an
  unresolved contract logs loudly and is SKIPPED (no crash, no wrong order).
- IbkrBroker.market_price fetches the REAL UCITS USD price via delayed market
  data (reqMarketDataType(3) + reqMktData snapshot); the UCITS route sizes from
  it, not the US Yahoo price. Modeled US weights unchanged.
- route_ucits routes sleeves that map+resolve+price and loudly logs+skips the
  rest; survivors keep their own weight (book NOT rescaled), skipped $ reported.
- UcitsSettings.map now carries verified ISINs (CSPX IE00B5BMR087, IBTM
  IE00B1FZS798, SGLN IE00B4ND3602, ICOM IE00BDFL4P12) and DBMF is mapped to the
  real iMGP DBi Managed Futures UCITS ETF (LU2951555585, USD line) with a
  liquidity caveat comment.

TDD mocks the IB client (no live broker). Full suite: 1986 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:56:06 +02:00
jgrusewski
c3c627ebfd fix(cockpit): dedupe strategy-detail exec cards, drop raw venue slug + bps leak
Whole-branch-review fixes for the IBKR paper cockpit parity feature:
- strategy.html: suppress the pre-existing "Real trades vs the plan" exec-twin
  card when the new "Following the plan" card (from d.recon) already covers
  the same IBKR book — was rendering the same "vs plan" % twice under two
  labels with two different cost-to-trade numbers. Kept for non-IBKR exec
  twins (e.g. Bybit) that have no d.recon.
- cockpit.html: the new D3 "real trades" sub-row no longer renders the raw
  registry venue slug (e.g. "ibkr-equity") in its exec-tag; replaced with the
  plain "IBKR paper account" label.
- _sim_result.html: reworded the Bybit measured-cost caption's "avg drag X bp"
  to "avg drag X%", removing the last visible `bp` jargon on that surface.

Extends the existing plain-language web tests to cover each surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:12:36 +02:00
jgrusewski
fe9509c189 feat(D4): consolidated /paper Paper-books page (both books) + per-strategy breakdown (scales by row)
Consolidates /paper into ONE "Paper books" landing showing both the crypto 4-edge book
(simulated, PAPER) and the IBKR paper account (REAL PAPER) as clickable cards, each linking
to its own holdings detail. The crypto book's former /paper content (headline/live book/
trades/edges) moves verbatim to /paper/crypto — data/behavior unchanged, only its URL.

DashboardService gains ibkr_account_view() (account-wide NAV/positions + a per-strategy
breakdown of StratExecRow, one row per strategy trading on the shared IBKR account — a
dimmed "not started yet" row for a registered-but-not-executing strategy, proven to scale by
row via a synthetic second executing strategy) and paper_books() (wires Task 4's
IbkrAccountRollup into the landing card instead of leaving it dead code). The IBKR account
detail page (Task 3's strategy.html) now renders this per-strategy breakdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:55:31 +02:00
jgrusewski
55c8644b51 test(D3): pin exact rendered forms in the overview real-trades web test
Tighten test_web_overview_shows_real_paper_badge_and_real_trades_subrow from loose substrings
to pinned values: "+3.10%" executed return, "0.7% behind plan" (+ an "ahead of plan" sign guard),
"cost to trade 0.0%", and "3 trades · 2 holdings" (+ a swapped-count guard). The fixture now seeds
3 fills over 2 held symbols (SPY traded twice) so a trade/holdings count swap, a stray sign, or a
wrong divisor fails the test rather than passing on a loose match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:38:08 +02:00
jgrusewski
524c43eebd feat(D3): overview real-trades sub-line + behind-plan pill (plain language, REAL PAPER badge)
DashboardService.fleet() now marks a strategy row exec_status="EXEC" + attaches the shared
exec_vs_sim recon signal (Task 3) whenever a REAL IBKR paper-account book (multistrat/vrp) has
captured exec data; overview() rolls the executing rows up into an IbkrAccountRollup (reusing
each row's already-computed recon, no duplicate math) — carried on the model, not rendered as its
own card (Task 5's consolidated Paper-books page is the account home).

cockpit.html's "All forward tracks" table gets a REAL PAPER badge and a nested "real trades"
sub-row (reusing the existing .exec-tag/tr.exrow exec-twin style) for a genuinely-executing row:
real return, a "N trades · M holdings →" click-through to its holdings detail, the plan_pill
("X% behind/ahead of plan"), and honest cost-to-trade — plain language only, mutually exclusive
with the crypto book's generic exec-twin row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:30:50 +02:00
jgrusewski
29f60a8584 fix(D2.2/2.3): plain-language exec card copy, gross %-of-book, stale-value caveat, generic asset fallback
Review fixes (2 Major + 2 Minor):
- MAJOR: reword the pre-existing "Executed reality" card (same detail page) off the forbidden
  "divergence"/"vs sim"/"decay"/"bp" jargon -> "Real trades vs the plan", "N% behind/ahead of plan",
  "cost to trade %". Extend the web forbidden-strings test to assert (on VISIBLE text, stripping
  CSS/markup) no divergence/decay/vs-sim/bare-sim/bps anywhere on the rendered page.
- MAJOR: %-of-book uses GROSS exposure (sum of abs) as the denominator, so a mixed-sign defined-risk
  book (vrp put-credit-spread: short put + long wing) no longer renders >100%/negative/non-summing
  shares. Each row keeps its signed value. New mixed-sign holdings test.
- MINOR: holdings value is marked at each holding's last trade price (no live mark) — surface a per-row
  "as of <date>" and a column caveat so a stale mark isn't over-trusted.
- MINOR: an unmapped asset symbol (future vrp option contracts) falls back to a plain generic "other"
  instead of echoing a raw contract string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:12:55 +02:00
jgrusewski
d25ff9659a feat(D2.2/2.3): IBKR paper-account section on the strategy detail + shared exec-vs-sim divergence signal
- display_names.py: the SINGLE internal-id -> human-name map every view uses (absorbs app.py's
  interim _SIM_BOOK_LABELS local map + a per-asset "what it is" description for the holdings table).
- exec_reconcile.py: exec_vs_sim() — THE shared "how closely real trades follow the plan" signal
  (executed vs modeled cumulative forward return + median slippage + fees), reused by later D3/D4 work.
- dashboard_service.detail() attaches holdings/recent_trades/account_nav/recon for the IBKR real-account
  books (multistrat/vrp), sourced from IbkrAccountRepo (Task 2) + ForwardNavRepo's new cum_return().
- strategy.html: "Following the plan" card + plan_pill, a real-vs-plan value-over-time curve (new
  charts.overlay_curve), a "what it's holding right now" table, and a "recent trades" table with honest
  cost-to-trade as a % (never a fabricated non-zero) — all plain-language, no raw ids/bps/"vs sim".
- Fixed a pre-existing raw-id leak (exec_twin_sid rendered as literal link text) via a new
  `display_name` Jinja global, used project-wide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:02:25 +02:00
jgrusewski
4a6b5b8f2f fix(D2.1): market-order UNSET_DOUBLE lmtPrice fabricated ~-10000bps slippage; guard capture migration
CRITICAL: ib_async Order.lmtPrice defaults to the IBAPI sentinel UNSET_DOUBLE
(1.79e308, truthy), which every MarketOrder carries — capture_fills treated it
as a real intended price and fabricated ~-10000 bps slippage on EVERY real market
fill. Now treat None-or-UNSET_DOUBLE as "no intended price" -> honest 0.0.
Minor 1: wrap the capture migration in its own try/except so a broken capture
migration can never block a real rebalance (falls back to no capture).
Minor 2: tests exercising capture_fills against a real MarketOrder sentinel and a
raising account_repo/broker driven all the way through plan_and_record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:41:51 +02:00
jgrusewski
ff7a81ceeb feat(D2.1): capture real IBKR fills->exec_fills (strategy-tagged) + ibkr_account_nav snapshot (best-effort, paper-only)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:32:37 +02:00
jgrusewski
784cd27949 fix(D1): gate sim_curve_ret write with backtest_summary (same-basis invariant)
Move the recompute-replay sim_curve_ret persist to after the `if sm is None: return`
guard so it is gated on the SAME condition as the reconciliation-gate backtest_summary
write. A too-short replay (< 2 rows, no gate ref) now writes NEITHER, never a degenerate
sim curve with no matching gate ref. Adds test_insufficient_replay_writes_neither_ref_nor_sim_curve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:20:48 +02:00
jgrusewski
3532671892 feat(D1): /paper/sim sources any book's curve by backtest.kind (adds IBKR multistrat/vrp)
Generalizes the Backtest page beyond the Bybit-only venue: bybit_4edge/bybit_4edge_levered
keep reading the precomputed bybit_sleeve_ret table; the IBKR paper strategies (multistrat,
vrp) now read their own precomputed recompute-replay curve from a new sim_curve_ret table,
written nightly by _persist_track_backtest_ref from the SAME rows the reconciliation-gate
ref is derived from. New sim_curves.sim_returns_for dispatches on registry_backtest_kind;
app.py wires an _ibkr_measured_context render path (reusing the existing pure
simulate_naive_eqwt engine) alongside the unchanged Bybit measured-cost path, with
plain-language pill labels for the two IBKR books.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:13:27 +02:00
jgrusewski
fd5d97bc94 fix(cockpit): give the bybit_4edge sleeve breakdown its own display names
crypto_tstrend's standalone STRATEGY_REGISTRY entry was retired in Phase 0b
Task 4, but the still-live crypto_tstrend book SLEEVE in the bybit_4edge
per-edge breakdown (Overview + /paper) shared the same string id, so its
display-name lookup blanked to the raw id and linked to a dead
/strategy/crypto_tstrend (404).

Add _SLEEVE_DISPLAY_NAMES next to _DEFAULT_BYBIT_SLEEVES in
bybit_book_eval.py — the sleeve breakdown's own display-name source,
independent of STRATEGY_REGISTRY, so a future forward-track retirement can
never blank a live sleeve again. _deploy_individual_edges() now sources
display_name from that map and adds a `linkable` field (true only when the
sleeve id is also a real registry entry); deploy_constituent_row renders the
/strategy link only when linkable, plain text otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:11:31 +02:00
jgrusewski
70708ca8a7 fix(vrp): freeze the LAST COMPLETE OPRA session via bounded step-back
The vrp health axis flagged vrp=STALE because the OPRA freeze
(xsp_option_bars) was stuck: vrp_nav froze as_of=today, but today's
session is only partially available intraday, so the freeze failed with
`422 data_schema_not_fully_available` (distinct from the C1a clamp's
`data_end_after_available_end`). That made the freeze timing-dependent —
it could only succeed in a narrow post-settlement window and otherwise
silently degraded to recompute-off-stale-frozen.

Make it robust and timing-independent:
- databento: normalize the `data_schema_not_fully_available` 422 into the
  single catchable DatabentoRangeUnavailable (surgical — only that 422;
  any other client error propagates raw). Expose
  last_available_option_date() as the step-back's starting candidate.
- assets: new _freeze_latest_session entry point walks back one TRADING
  day (skip Sat/Sun) from OPRA's available-end until a COMPLETE session
  freezes, bounded to 5 steps; returns/logs the frozen session date.
  Never freezes a partial session's marks.
- No silent degradation: if no complete session is found in the bound it
  raises DatabentoRangeUnavailable; the existing C1b wrapper in vrp_nav
  catches it → recompute off frozen, and the health axis keeps vrp STALE
  (loud), never a quiet no-op.

TDD: step-back-past-partial, weekend-skip, bound-exhausted-raises, 422
normalization (both chain + bars paths), unrelated-error-propagates,
last_available_option_date caching. Full suite green (one pre-existing,
unrelated seed-dependent gauntlet flake).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:45:48 +02:00
jgrusewski
01e437816b fix(forward-health): close no-anchor silent-HEALTHY hole; fix BROKEN docstring scope
The "no forward data yet" carve-out in compute_health let a reconciliation-gated
recompute track that never produced data since inception (empty freeze table / no
forward_summary, old anchor t0 or no active anchor at all) read HEALTHY the same
way a genuinely brand-new track does. Thread the active forward_anchor.t0 through
evaluate_health so compute_health can tell "just inceptioned" (anchor within the
stale threshold -> HEALTHY) apart from "broken from inception" or "never anchored"
(-> STALE), closing the silent-degradation hole without false-alarming new tracks.

Also corrects the module docstring: BROKEN only covers per-track compute failures,
not the migrate/all_summaries/all_backtest_summaries/xsp_freeze_max_date reads
evaluate_health hoists outside the per-track try (those fail the whole
cockpit_forward Dagster asset instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:13:13 +02:00
jgrusewski
d070ec9127 feat(B): runtime staleness/health axis — kill silent rot (red badge + ERROR log)
vrp rotted for 2 weeks because a data failure degraded QUIETLY (recompute off a
frozen-but-stale table, only a log line) and the gate can't tell a stale track
from a healthy WAIT. Add a HEALTH axis SEPARATE from the gate so ANY live/gated
track that stops updating surfaces LOUDLY on day 1.

forward_health.py (new): compute_health (pure state machine) + evaluate_health,
called by cockpit_forward AFTER ingest. States {HEALTHY, STALE(age), NO_REF,
BROKEN}, precedence BROKEN>STALE>NO_REF>HEALTHY:
- STALE: a `recompute` track whose source as_of OR its freeze table's max date
  (vrp → xsp_option_bars) is > Settings.stale_after_business_days (default 3)
  business days behind. The freeze axis catches the exact vrp mode: forward
  "updates" yet the frozen input is stale.
- NO_REF: a reconciliation-gated recompute track with no resolvable backtest
  reference (the gate can never reconcile) — the M6 static invariant made a real
  runtime check.
- record_mode carve-out: `observed` twins (vrp_exec/…) are externally-driven and
  legitimately DORMANT when execution is suspended — never staleness/no-ref
  alarmed (won't false-alarm the intentionally-dormant reference_only tracks).
- BROKEN: a per-track health-compute error is surfaced, never swallowed.

Storage: new strategy_health table (create_all — idempotent, Postgres-safe, no
ALTER), replace-all each run so a recovered track clears its stale row.
Rendering: FleetRow/StrategyDetail gain health/health_stale_days/health_reason;
a LOUD red health_badge (STALE 14d / NO REF / BROKEN) on Overview, the all-tracks
table, and the detail header — visually impossible to confuse with a WAIT.
cockpit_forward + evaluate_health log at ERROR with the unhealthy list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:00:34 +02:00
jgrusewski
3302ca5d04 fix(C2): make every *_nav failure-isolating so the gate is never skipped
cockpit_forward reads purely from the DB (forward_summary) and its deps are
ordering-only, so a skipped upstream stalls the whole gate/promotion/allocation
pipeline. Close the two pre-tracker offenders so no *_nav asset raises on a
data-provider failure:

- multistrat_nav: the snapshot_yahoo_closes loop runs OUTSIDE _run_paper_tracker;
  a Yahoo outage now logs ERROR and degrades to a no-op {forward_days:0,...}
  return (record unchanged) instead of raising and skipping the gate.
- _run_paper_tracker: broaden the caught set to also isolate the databento
  provider error base (BentoError/BentoClientError, imported lazily since
  databento is an optional extra). Bare Exception is NOT caught — genuine
  strategy-math bugs (KeyError/ValueError) still propagate loudly.

With every expected data-provider failure mode isolated, cockpit_forward always
runs (deps kept for ordering). See report for the decouple-approach rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:36:48 +02:00
jgrusewski
afe488e6aa fix(C1): clamp OPRA query end to dataset range + vrp_nav freeze resilience
vrp_nav hard-failed every night: _freeze_day runs OUTSIDE _run_paper_tracker
and databento 422'd (data_end_after_available_end) because end=as_of+1day
exceeded OPRA's ~22:30-UTC available end at the 23:30 freeze. The raised
BentoClientError killed the asset before run_track/set_anchor, zeroing vrp's
forward anchor AND skipping the downstream cockpit_forward gate.

C1a: DatabentoDataProvider now fetches the OPRA available end via
metadata.get_dataset_range (cached once per instance) and clamps the exclusive
query `end` down to it in fetch_option_chain/fetch_option_chain_range/
fetch_option_bars, so no query requests past what OPRA has published. An empty
window (start >= clamped end) raises catchable DatabentoRangeUnavailable, never
a raw 422.

C1b: vrp_nav wraps _freeze_day so any freeze failure logs a WARNING and
continues to _run_paper_tracker, which recomputes VrpStrategy off the FROZEN
xsp_option_bars table — guarantees a forward row/anchor/ref even on a day the
freeze fails, and never skips the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:34:57 +02:00
jgrusewski
375cd108bc feat(0b): remove the vestigial dual-venue PaperRepo abstraction (Task 7f)
Task 7e only renamed venue="binance" -> "legacy" to pass the substring guard;
the dual-venue repo machinery was left in place even though bybit is the ONLY
live venue. PaperRepo now hardcodes every discriminated read/write to a
_BYBIT_VENUE = "bybit" module constant instead of a constructor venue param
(removed) -- reads/writes ONLY the bybit rows, so the orphaned legacy/binance
rows from the retired combined-crypto book (Task 7d) are never touched.
nav_summary's venue arg is dropped the same way (every caller always passed
"bybit"). cockpit_models keeps the venue column (dropping it from the PK is a
separate prod schema migration, documented in the report for the merge
runbook) with reworded comments reflecting the single-venue reality.

Test fallout: deleted test_paper_repo_venue.py (tested the now-removed
dual-venue isolation) and 3 "does-not-touch-binance" tests that constructed
PaperRepo(venue="binance"); mechanically dropped the venue kwarg everywhere
else. Added a guard assertion that PaperRepo.__init__ has no venue param.
Full suite green (1853 passed); binance substring guard still 0 hits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:20:57 +02:00