a13dbc9710aca32c39397af9916482c47a05b6de
421
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a13dbc9710 |
fix(sec): stop an unrecoverable filing gap pausing setups forever
A filing gap pauses its issuer until the filing is ingested or a later one supersedes it, which assumes the gap is temporary. It is not always: SEC's per-company Company-Facts files can go stale indefinitely — 43 large caps whose Q2 10-Qs the frames API carries but whose companyfacts files never received (Abbott's newest fact was 2026-04-29 in late August) — and because the supersede rule needs a *successfully ingested* later filing, a stale file swallows the next quarter too. The pause was open-ended, not seasonal. So the pause hands off to the alert: once filing_gap_aged has escalated a gap, it stops gating if the issuer's newest stored 10-K/10-Q is under 180 days old. An issuer with nothing that recent has no usable fundamentals at all and stays paused, which is the case the gate was built for. Applied in the gate service only. active_gaps is deliberately untouched so _retry_backlog keeps retrying and a recovered filing still resolves normally, and the bound covers both gate paths — the queue and the validation_json summary that mirrors the same filings — since bounding one leaves production behaviour unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Lo99z3jWqu9X9ueBU3Z3D |
||
|
|
c97a067e0e |
fix(risk-monitor): drop an unused import and align migration 033 with its model
ruff F401 failed the deploy: `import pytest` in test_event_study.py outlived the pytest.approx assertion it was added for. Compiling the migration for Postgres while checking that turned up a second defect worth fixing while the table is still empty. It created a unique constraint *and* a plain index on effective_date, while the model declares `unique=True, index=True` -- one unique index. Both enforce uniqueness, but the pairing left a redundant second index on the column and a permanent diff for autogenerate to keep trying to reconcile. Now renders byte-for-byte what the model declares, matching RegimeSnapshot.date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
333989eeab |
feat(risk-monitor): measure the rule that fires, and give fundamentals their own channel
The Warning study measured a fitted percentile crossing that nothing consumes. What reaches Telegram is a quadrant change: fixed 50/40 dividers, hysteresis, two-session confirmation, 3-day cooldown. Those thresholds are constants, not fits, so there is no training set to protect and all 11 detected corrections are evaluable instead of the 4 that fell in a holdout. Replaying it: 1/10 corrections, 0.9 false alarms/year. Random alarms at the same firing rate match or beat that in 65% of draws. The panel now carries ablations (does the quadrant machinery earn its place?), external baselines (does the score earn its complexity?), and that null, because a bare "2 of 4" was unreadable in either direction. Nothing in the alert path was retuned on the strength of it. Fundamentals become a third channel rather than a term in either score. v3 cut them arguing 12+8 of 100 points "could not change any published conclusion" -- true only when every technical sensor reads zero; weighted they moved the bar for the 40 divider from 40 to 25. But no fusion weight is measurable either: with ~10 events and no fundamental history, any weight is a policy preference presented as a measurement. So the read is a categorical state (supportive/neutral/adverse/ unknown) with an evidence grade, derived by fixed rules from stored facts, read by confluence. The LLM extracts and explains; it does not score. Absence stays absence throughout. `unknown` is unreachable by averaging, a stale or empty observation may display but never confirm, extraction failures map to `unknown` rather than `mixed`, and the study rows are coverage-matched and marked not-measurable until enough corrections are covered -- otherwise a fortnight of observations renders as 0/10 and reads as a failed test. Observations become a real time series (migration 033); they lived in a single overwritten settings slot, so no history existed to replay. Pre-rename snapshots are adapted rather than discarded. METHODOLOGY stays v4 -- no score changed -- so no reseed; STUDY_SCHEMA moves to 3 and discards the cached report. Post-deploy: re-run Event Study from Admin -> Jobs. The panel reads "not run yet" until then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3033ad83fd |
docs(readme): catch the README up with the last 20 commits
The README still described capacity 10, a two-tab Signals page, cascade-delete ticker retirement, and a discretionary paper book as the out-of-sample proof. All four are wrong against the current tree. Capacity: SIM_MAX_POSITIONS is 15 since the 2026-08-05 bracket. The summary and flowchart now say 15; the re-entry study and tuning rows keep 10 and are marked as measured at the then-production capacity, because rewriting numbers a study did not produce is worse than a stale one. The tuning table claimed the 10-slot cap never binds -- that read came from EV per trade and is what the bracket reversed. Gate reset was promoted at capacity 10 and capacity 15 is the one arm where immediate re-entry edged ahead, so that gap is written up as an open question rather than resolved by edit. The shadow book was missing entirely, and it contradicts what the README claimed as the OOS record: the discretionary book measures the strategy plus discretion and availability, which is the gap the shadow book exists to close. Documented as opt-in, with its near-close pipeline step, its parity invariant, and the Dashboard chart that actually renders it (not the Paper Trades tab). Also: Signals is Setups / Paper Trades / Backtest; the iron rule pointed at a Signal edge table the UI no longer renders, now redirected to the local report; delisting replaces cascade delete; SEC promotion ceiling; SEC_USER_AGENT and the DeepSeek/xAI/Dolt/backtest env vars; ~15 missing endpoints; the systemd unit filename; npm test no longer exists as a script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
044a3447f6 |
fix(backtest): rebuild the recommendation on read, not only on run
Every fix so far only applied to reports generated after deploy. The cached report is served verbatim, so it keeps the recommendation the OLD build stored — quoting the legacy policy book, naming a rejected exit as "recommended", and carrying no basis_lookback, which let the lookback selector default to 3y and put 3-year tiles beside an all-history recommendation with no divergence notice. Exactly the contradiction the last three commits set out to remove, silently present on the first page load after deploy and until the next scheduled run overwrote it. The recommendation is a pure function of the numbers already in the report — its own note says it is derived from them on every run — so it is now re-derived on read. A corrected recommendation appears immediately instead of after the next backtest. On failure it is dropped rather than falling back to the stored one, which is the stale derivation this replaces. The test drives the real shape: an old-build report with a legacy recommendation written straight to the settings row, read back through get_backtest_report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
21a5fc8a52 |
fix(backtest): flag a lookback the recommendation was not computed on
Selecting a different window or a comparison strategy silently made the tiles stop matching the recommendation below, which is baked into the report and cannot follow a dropdown. On load they now agree by construction; moving off that basis says so. Also: an absent production row produced no headline and no benchmark, but any passing gate finding still rendered a green "no warnings" chip — a success badge for missing data, directly beside "this report predates the portfolio monitor". Missing baseline now reads "baseline unavailable". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
11dffcd695 |
fix(backtest): one window, and stop calling a rejected exit "recommended"
Two ways the recommendation still disagreed with the page it sits on. It preferred the "all" monitor row while the UI defaulted its selector to "3y", so a default page load showed one set of returns in the tiles and a different set in the recommendation. The row it used is now published as basis_lookback and the page defaults to it, so the two cannot open on different windows. The test fixture gains a second monitor row with different numbers — with only an "all" row present, a lookback mix-up could not fail. Robustness picked its basis between "the recommended Nd hold" and "the S/R target exit", naming an exit the production book replaced as recommended. There is no ATR-trail ex-top-5% figure in the report, so it now always reports the gate-level grading and says that is what it is, rather than dressing a legacy number as a verdict on the production book. time_exit_sweep is no longer read here at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3a2d548610 |
feat(backtest): make the tiles answer "is that good?", and stop the layout jumping
Five UI problems, all reported from using the page. Expanding "How this is measured" shoved every control down, because the disclosure and the run controls shared one flex row. They no longer do: run status and the controls that start a new run sit together on one line, and the explainer is below them where growing it moves nothing. A long strategy name wrapped the dropdown trigger onto three lines and dragged the row out of alignment. The trigger now truncates with the full text on hover — a wrapping dropdown is broken anywhere, so the fix is in the primitive — and the twelve-character "Production: " prefix is a bullet. "Sortino 2.72" answered nothing. Each risk-adjusted metric now carries a meter: a track showing where the value sits, ticks at the band edges, and the band word. Colour never travels alone. Bands are deliberately stricter than textbook ranges because this universe is today's survivors replayed backward, which flatters every ratio — that caveat is stated next to them rather than left implied. The two tile rows were different sizes, which read as inconsistent rather than as hierarchy. Every tile is the same size now and grouping carries the ranking: top row is raw outcome and takes no meters, second row is risk-adjusted ratios and all take meters. Sharpe moved down to join them — it is one of those ratios, and leaving it above made it the only metered tile in a row of bare ones. The recommendation led with a long bold sentence that describes the configuration, not a verdict, while the actual findings were small grey text. Findings now come first, each split into label and detail on the colon the backend strings already carry, and the configuration is a footer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
28b3273150 |
fix(backtest): quote one book, not two
The recommendation's "Book vs SPY" line read from portfolio_sim — the hold/target policy book — while the tiles directly above read from portfolio_monitor, the production ATR-trail book. Same SPY figure, different portfolio return, on one screen. It now reads the same production row the tiles do. Also removed, for the same reason: the "legacy exit diagnostic" comparing hold against the S/R target. Both are exits the production book replaced, so a recommendation between them could not lead to an action. And the fallback headline, which advised the fixed-hold exit whenever a report had no production row — a report that cannot describe the production baseline now states none. portfolio_sim stays in the report payload: scripts/run_backtest_snapshot.py and reports/compare_reports.py read it, and it is no longer surfaced in the UI. The test fixture now carries a production monitor whose numbers differ from its policy sim, so re-sourcing that line from the old place fails rather than passes unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
14cfa44fc5 |
refactor(signals): drop the now-unused fmtMoney helper
It was lifted from BacktestPanel during the extraction, then lost its last caller in the same branch when avg_trade_pnl became an EV / trade tile rendered with fmtSignedMoney and disappeared from the monitor footnote. formatPrice already covers a bare unsigned amount if one is ever needed again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
13a984a84d |
refactor(signals): split the Track Record tab and cut the backtest page down
One tab stacked three things that all called themselves a track record: realized paper P&L, setup-outcome grading under the rejected take-profit model, and the backtest portfolio simulation. Split into Setups | Paper Trades | Backtest, one subject each. `track` stays the Paper Trades slug so the legacy /performance redirect keeps working. The grading diagnostic and its Evaluate / Reset controls go with Backtest, not Paper Trades — reset_track_record deletes trade_setups, not paper trades. BacktestPanel 439 -> 175 lines. Its run settings alone were 106 lines of hand-rolled sr-only radio cards for two binary choices; they are now two Dropdowns and a button on one wrapping row, with the per-option prose moved into the existing explainer. The amber warnings survive as a conditional slot, so a non-default choice still announces itself but the common path is silent. The recommendation printed eight findings at equal weight, burying the verdict in tuning detail. `topic` now splits them: production, benchmark and robustness stay inline, gate/exit/cutoff collapse behind a disclosure, and any WARNING or LAGS item is promoted out of the collapsed group regardless of topic. No topic chips — every backend string already self-prefixes, so a chip would render "GATE | Gate: ...". Portfolio metrics are now two tiers: five headline tiles for what the book returned, then a smaller labelled row for how good that return was (Sortino, Calmar (MAR), Gain/Pain, Profit Factor $, EV/trade). Reports cached before those metrics existed hide the second row rather than showing a half-populated line of dashes. Extracted EquityCurveChart, PortfolioMonitorPanel and BacktestRecommendationCard, plus a StatTile primitive and shared formatters for the duplication in the files this touched. DashboardPage and OpenTradesPanel deliberately keep their own copies — migrating them is separate scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
442dc3f04b |
feat(backtest): add Sortino, Gain-to-Pain and dollar profit factor
Three portfolio metrics computed where their inputs already live in _simulate_portfolio: Sortino off the existing daily return series, Gain-to-Pain off a monthly aggregation of the equity curve, profit factor off closed-trade dollar P&L. Gain-to-Pain follows Schwager — sum of ALL monthly returns over the absolute sum of the negative ones. The profit-factor-shaped variant, sum(positive)/|sum(negative)|, sits exactly 1.0 higher for every input since sum(all) = sum(pos) - |sum(neg)|; the test asserts against both so the wrong one cannot pass. Sortino divides by len(rets), the full-sample lower partial moment, not by the count of down days, which would shrink the denominator and inflate the ratio. No MAR field: calmar is already CAGR / max drawdown, the same number under the other name (docs/research/effective-risk-floor-ab.md). All three keys are emitted unconditionally even when None — the UI reads an absent key as "report predates these metrics", so presence is a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
247a7889b9 |
fix(tickers): honor the effective date instead of retiring on the mark
active_only tested delisted_on IS NULL, so a symbol dropped out of signals the moment a Form 25 was detected — ten days before Rule 12d2-2 makes the removal effective, while it was demonstrably still trading. A manual future-dated mark behaved the same way. It now compares against the database's own date, so a pending delisting stays live until the day it takes effect. That exposes a second problem the fix would otherwise create. Trading typically stops before the ten-day delay expires, so across that window the symbol is correctly active yet produces no bars — and confirm_delisting returned None for an already-marked row, which would have fired the staleness warning daily for ten days, the exact noise this flow exists to remove. It now reports the known effective date on every path where the delisting is established, so the caller warns only about gaps that are still unexplained. CURRENT_DATE renders identically on postgres and sqlite, and the OR is parenthesized when callers chain further where clauses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d4ed39fd2 |
fix(tickers): close the delisting review findings
Detection could retire an actively traded symbol — silently, since it then
vanishes from every signal. Three causes:
- Form 25 is filed per security class. An issuer removing its notes, preferred
or warrants files one while the common keeps trading. The filing's own
descriptionClassSecurity distinguishes them, so the primary document is now
fetched and read; anything not recognisably common equity is rejected, as is
anything unreadable (pre-2009 filings have no primary_doc.xml). Fail closed.
- Form 15 ends a reporting obligation and is no evidence trading stopped. The
whole family is dropped.
- A historical filing for a long-gone class could retire a symbol whose bars ran
years later, stamping the old date. Filings before the last bar (less a 30-day
lead for the exchange) are now ignored.
Rule 12d2-2 makes removal effective ten days after filing, so delisted_on is the
effective date rather than the filing date.
bootstrap_universe(prune_missing=True) still ran a cascading delete over
delisted rows, undoing the retention this branch exists for; it now skips them
and reports kept_delisted so the count is explicable.
clear_delisted had no route, which made "safe to automate because it is
reversible" false — reversal needed SQL. POST/DELETE /tickers/{symbol}/delisting
now mark and un-mark, giving an operator a non-destructive alternative to the
cascading DELETE that was the only option.
Shared-CIK siblings (GOOG/GOOGL) stay safe by construction: the probe is
per-symbol and gated on that symbol's own staleness, so a class that still
trades is never probed.
Not addressed: pruning a symbol merely dropped from the index still destroys its
history — the same survivorship problem in a different costume, needing a
tracked/membership state separate from delisting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d950fcf70e |
fix(tickers): let an SEC confirmation upgrade a manual delisting mark
mark_delisted returned early on any already-delisted row, so the sequence an operator actually hits — mark EA by hand today, Form 25-NSE surfaces three days later dated 2026-08-04 — left the estimated date and "manual" reason in place permanently. Form 25 carries the real effective date, so it now replaces an operator's estimate; a confirmed row is never downgraded or re-probed. Also cover _get_ohlcv_priority_tickers, the one place active_only wraps a compound select rather than a bare one — the unit suite reached none of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6501b7e9a0 |
feat(tickers): record delisting instead of deleting the symbol
Retiring a symbol meant delete_ticker or bootstrap_universe(prune_missing), both of which cascade through OHLCV, setups and scores. That destroys exactly the history four research documents already apologise for: today's tracked universe projected backward is survivorship-biased, and hard-deleting every delisted name is what causes it. Keeping the rows preserves the option to fix that — it does not fix it, which needs the replay to model a delisting as an exit event. tickers gains delisted_on / delisted_reason (migration 032). NULL means actively traded. The filter is opt-in via ticker_service.active_only rather than folded into a shared getter: the registry and admin views deliberately keep delisted rows so the delisting is visible, and a silent default would undo that. Applied to the live path only — scanner, momentum ranking, scoring, breadth, fundamentals candidates, SEC universe, earnings import, ingestion loops. run_backtest keeps them on purpose. Detection runs off OHLCV staleness, not off the SEC fundamentals import: that importer stalls for days on unrelated Company-Facts gaps and would take detection down with it. On a stale symbol the scheduler asks SEC for a Form 25/25-NSE/15 and retires it only on a hit, so a halt or a rename (SATS->ECHO) keeps the existing warning. The probe waits 3 stale days so a market-data outage cannot turn into one SEC request per symbol per run. Safe to automate because it is reversible: clear_delisted un-retires a false positive, where a delete had already taken the history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
486fb500d1 |
test(sec): cover the ceiling's promote/queue/alert path end to end
The ceiling tests asserted validate()'s verdict but nothing proved the claim the design rests on: that a forced promotion actually queues the filings it released and says that it did. Drive it through run_import with a filing that stays inside the per-filing window, so only the aggregate ceiling can release it, and assert the SecFilingGap row and the promotion_ceiling_forced event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
77570557db |
feat(sec): cap how long the fundamentals import can stay deferred
MISSING_XBRL_RETRY_DAYS bounds how long ONE filing blocks promotion. It does not bound the import as a whole, and the two come apart because a blocking filing is only queued by promote(), which a deferred run never reaches. During a rolling supply of unresolvable filings — earnings season, when SEC's Company-Facts aggregation lags furthest — each new arrival restarts the 3-day clock before the previous one clears, and nothing is written at all: not the good rows, not the gap rows that would stop those filings blocking again. Add an aggregate ceiling. Once promotions have been stale for PROMOTION_CEILING_DAYS (7), every unresolved filing is aged past the retry window in place, so promote() queues them all through the path that already exists, source_max_date advances, and _missing() keeps queued rows aged-out on later runs. The import self-heals instead of compounding. Deliberately not the alternative of queueing gap rows on a deferred run: that would drop the grace period to a single run for every filing, including the common case of a Company-Facts lag that resolves in a day, and it needs a write on a run that failed validation. The per-filing window is untouched, a never-promoted source never trips (that is initial setup, not a wedge), and affected symbols stay barred from setups either way since setup_blocked_ciks ignores the window. A forced promotion raises promotion_ceiling_forced so the safety valve is never silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fbca38e144 |
fix(backtest): roll back the portfolio-sim DB failures too
The first pass guarded the replay loop but not the portfolio-simulation block, which re-fetches price columns and loads the benchmark and the live exit policy from the same session much later. A failure in any of those swallows the exception without clearing the transaction — the identical failure mode, with the identical symptom: the report write is the first unguarded statement and takes the blame. The outer handler is the backstop for the price_columns loop, which has no handler of its own; rolling back a session an inner handler already cleared is a no-op. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ca7f13779 |
fix(backtest): roll back the session after a swallowed DB failure
Every DB call in run_backtest is best-effort so one unreadable ticker cannot abort the whole replay, but the handlers swallowed the exception without clearing the transaction. asyncpg then reports "current transaction is aborted" for every later statement, and the first unguarded one — the report write — surfaced it as the job error, long after the real cause. Add _rollback_quietly at the three swallowing sites (benchmark load, parallel fetch, sequential replay), matching the guard price_service already uses. Load plain symbols instead of Ticker instances: a rollback expires ORM objects held across it, and touching an expired attribute afterwards triggers sync lazy-loading, which raises on an AsyncSession. rr_scanner_service hit this same trap. Only .symbol was ever used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d02fd82ced |
docs(research): regenerate the artifact under the v2-mandatory harness
Supersedes the previous artifact, which predates v2_reconstruction becoming a
required variant and the P1-cap conditional reporting. Generated from a clean
tree (git_dirty false at rev
|
||
|
|
43ee619412 |
fix(research): require the v2 reproduction, and correct the P1-cap denominator
Two review findings, plus a lost-edit repair. v2_reconstruction is now a required variant. It carries every published figure the reproduction rests on (avg, p80, max, P3-pegged, W1-live), so a run without it could emit a confident, non-provisional recommendation having checked nothing against v2 at all -- while the methodology doc claims v2 and v3 are reproduced first. The default invocation is now derived from REQUIRED_VARIANTS so the two cannot drift, and a test asserts the default satisfies its own requirement. The doc and the P1_TREND_BREAK_ANCHORS comment still justified skipping the P1_SCORE_CAP with 17/408 = 4.2%, which is the all-session share and does not evaluate the rule. The rule names sessions with State >= 40: 47 of them, P1 sole argmax on 17 = 36.2%, against P2's 16 and P3's 14. Conclusion unchanged -- well under the 80% trigger -- but the published rationale now states the metric that actually decided it. Root cause of that survival: the earlier correction WAS made, but in a script that applied several substitutions and wrote the file once at the end. A later substitution raised, so the successful edits were discarded with it. The "Unlike P3 and V1 ... P3's do not" fix was lost the same way and is restored. Also adds tests for the refusal paths themselves -- missing required variant, unknown variant, custom window with no calendar anchor. They were verified by hand last round but left unpinned, which is the same shape of problem as the optional gates they exist to enforce. All return before any network call. Deliberately not done, as not load-bearing: recording the oas400 variant's missing-credit session count (the truncation conclusion rests on the distribution mismatch, which is already recorded), and generalising _pipeline_gates for arbitrary --end/--sessions windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87224a1451 |
docs(research): regenerate the calibration artifact from a clean tree
The previous artifact was produced from a dirty working tree while HEAD still pointed at the harness commit, so its recorded revision could not reproduce it. This one records git_dirty false alongside sha256 of the three source files it depends on, so the claim "checking out this revision reproduces this artifact" is now checkable rather than implied. All hard gates pass, including the first-scored-date anchor and the row-wise state_v4 <= state_v3 invariant, so it carries a non-null recommendation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec1b0acfad |
fix(research): make the calibration artifact live up to its refusal guarantees
Review of the v4 evidence path. The shipped sensors, bands, methodology bump and
categorical allowlist were found sound; these are gaps in the harness that
produced the evidence for them.
The recommendation gates were optional, so they were not gates. The calendar
anchor lived behind --expected-first-session, which defaulted to None -- so the
committed artifact had no first-date check at all, leaving only a session COUNT
that is tautological (the harness slices the tail of the price series to whatever
was asked for). And the state_v4 <= state_v3 invariant was appended only when
both variants were present, so `--methodology v3` alone could still emit a v4
recommendation having never evaluated v4. The anchor is now a published constant
asserted unconditionally, required explicitly whenever --end/--sessions are
overridden, and v3+v4 are mandatory. Both refusals exit 2.
The P1_SCORE_CAP decision was taken on the wrong population. The agreed rule was
"sole price argmax on >80% of sessions with State >= 40"; the harness reported
only all-session counts and the doc concluded from 17/408 = 4.2%. Measured on the
actual population: 47 qualifying sessions, P1 sole argmax on 17 = **36.2%** (P2
16, P3 14). Still well under 80, so the conclusion holds -- but it was reached
from a denominator that did not test the rule, and 36.2% is a materially
different number to have on the page.
Provenance did not identify the code that produced the artifact. It recorded
git_rev
|
||
|
|
3143477a62 |
feat(regime): cut the risk monitor to v4 — desaturate VIX and the trend break
Two sensors saturated in exactly the range where resolution matters, and the top State band had no headroom. Calibrated with scripts/run_regime_monitor_calibration.py over the 408 sessions ending 2026-07-24; the shipped code reproduces that run's band shares exactly (78.9 / 13.0 / 4.7 / 3.4). V1 read VIX 30, 50 and 82 as an identical 100 — the same defect v3 had just removed from P3, left in place one sensor over. In the window it flattened five distinct April-2025 prints (52.33, 46.98, 45.31, 40.72, 38.57) into one value. Now an anchor table reaching full scale at 55, not at 2020's ~82: anchoring the top at a once-in-a-generation print would make VIX 50 read only ~70. Pegged on 14 of 408 sessions before; none now. _under_200 returned a bare 0/100, so P1 printed 100 the moment SMH and QQQ were both under their average — and since the price pillar takes max(P1, P2, P3), that pinned the pillar and stopped P3's ladder resolving for the whole of a selloff. Now graded by depth below the 200-DMA, with a deliberate floor of 20 at the crossing: the break is a genuine binary event, only its depth is graded. Pegged on 46 of 408 sessions before; none now. A 2% break reads ~30, not 100. max() was KEPT — the defect was the step function feeding it, not the vote, and v3's "one capped vote for correlated reads" rationale still holds. P1 is the sole price argmax on 17 of 408 sessions (4.2%), so the P1_SCORE_CAP fallback drafted during design was measured as unnecessary and not shipped. STATE_BANDS breaking 80 -> 65, and only that threshold. Credit returns 0.0 (not None) when calm, so it holds its 20 points pinned at zero and price + breadth + volatility at literal maximum summed to exactly 80.0 — v3's threshold to the decimal, with nothing above it. The sensor is deliberately unchanged: a calm-credit selloff genuinely is less stressed. What was stale is the band, fit on v2 while credit's since-removed percentile leg still contributed. A 2022-style AI/tech drawdown with calm credit computes to 70.3 (no death cross) or 74.0 (with one); 70 would have left 0.33 points of headroom, reproducing the defect. Chosen by scenario arithmetic, and the realized breaking share then lands on 3.4% — the same as v3's, arrived at independently. "v4" added to CATEGORICAL_FUNDAMENTAL_METHODOLOGIES in this same commit, which is load-bearing: that set is checked against the STORED blob, so bumping without it discards the collected observation on first write, leaving fetched_at null and locked false — and update_regime_monitor then fires a paid LLM refresh on every run, forever. Now guarded by a test parametrised over v2 and v3 stored blobs. SENSOR_REVISION deliberately stays 2: a METHODOLOGY change already forces a full reseed via _parse_snapshot, and bumping both would imply the reseed was revision-driven. QUADRANT_STATE_DIVIDER stays 50 because only breaking moved, so alert_service, RegimeChart and the quadrant tests need no change. A new test enforces divider == band boundary on both axes, which nothing did before. Doc renamed to regime-monitor-v4.md with a tombstone at the old path (commit messages cite it), the three open questions converted to resolved with the reasoning that closed them, and indexed in docs/research/README.md for the first time. The P2 limit is stated honestly: _death_cross pegs at a -5% MA gap, so a deep selloff still reaches 100 via P2 — v4 repairs the shallow-to-moderate break, not "the price pillar no longer pegs". DEPLOY: the first run reseeds ~464 sessions. Expect one phantom quadrant alert (the dedup key carries basket_hash, not methodology) and re-run the Event Study manually — its cached report self-invalidates but does not self-regenerate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c3ae5ad949 |
feat(research): commit the regime-monitor replay harness, and reproduce v2/v3
v3 was calibrated by replaying the series offline, but that harness was never
committed -- so its published numbers could not be re-derived, and a v4 cut would
have had to choose anchors by argument rather than measurement. This is that
harness, and it reproduces the published figures.
scripts/run_regime_monitor_calibration.py replays State/Warning session by
session from the same inputs the live job uses (Alpaca for all 33 symbols, FRED
for VIX and HY OAS), with no database: breadth and divergence come from
breadth_service's pure helpers. It never reimplements an unchanged live sensor --
_compute_index, _score_pillars, P2, P4 and the Warning sensors are imported and
called. Only candidate formulas (proposed v4) and retired ones (v2, gone from the
codebase) are defined here and patched onto the module for a variant's duration.
Reproduction of the 408 sessions ending 2026-07-24, against the figures in
docs/research/regime-monitor-v3.md:
v2 State avg 22.6 -> 22.68
v2 State p80 35.1 -> 35.1 exact
v2 State max 91.2 -> 91.2 exact
v2 P3 pegged 39 -> 39 exact
v2 W1 live 108 -> 108 exact
v3 State max 87.4 -> 87.4 exact
v3 band shares 73.3/15.0/8.3/3.4 -> 73.0/15.4/8.1/3.4
Three things the harness had to get right to reach that, each of which was
initially wrong and caught by a gate rather than by inspection:
- "W1 live 108" counts NONZERO sessions, not non-null ones. v2's divergence
gate returned 0.0 during any decline (v3 tapers instead), so the retired
divergence formula had to be reconstructed too.
- v2 sliced HY_OAS_REFERENCE_YEARS = 10.0 per session, not v3's 700 days. The
percentile leg ranks against that window, so replaying it short shifted the
middle of the distribution while leaving the max exact.
- The published v2 numbers correspond to FULL OAS coverage. Replaying v2 with
the 400-calendar-day fetch it shipped with yields max 100.0 and 133
credit-less sessions -- so that truncation was not in force when the figures
were taken. Recorded rather than assumed.
The script refuses to emit a band recommendation unless every hard gate passes
(33 symbols fetched, per-symbol warm-up and final bar, full basket on every
session, calendar anchors, 100% coverage, and a row-wise state_v4 <= state_v3
invariant), and exits non-zero. It is meant to be structurally impossible to read
a calibration result out of a run whose pipeline did not validate. No v4 code
ships in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f22313deaf |
chore: remove dead frontend code and one unused service helper
Scan of every module and exported symbol, with each candidate verified by hand
rather than trusted from the scan.
Deleted outright:
frontend/src/lib/fundamentals.ts (112 lines, 12 exports) — imported by
nothing, including FundamentalsPanel, which reads backend values. It mirrors
scoring_service._compute_fundamental_score, so it is the same *kind* of
thing as lib/qualification.ts — but nothing consumes it, so it mirrored
nothing and could drift out of sync unnoticed.
Skeleton.SkeletonLine, paperTrades.getEquityCurve, regime.regimeColor
breadth_service.compute_breadth_today — self-described "thin wrapper, for
future live use"; that future did not arrive.
Kept, but unexported — used inside their own module, so the dead part was the
public surface, not the code: Button.Spinner, exitPlan.SETUP_STOP_ATR_MULTIPLIER,
client.ApiError.
Three things the scan flagged that are NOT dead, recorded so the next sweep does
not re-raise them:
RegimeChart.tsx — lazy(() => import(...)) in RegimePage, so it looks orphaned
to any importer-graph scan. Deleting it would break the risk page.
qualification.ts MIN_TARGET_PROBABILITY / liveRiskReward — that file is a live
mirror of app/services/qualification.py used in five places, and the
constant is exported to document the backend value it tracks.
ssl_bootstrap.ssl_status — called from an inline python snippet inside
scripts/run_tier1_macbook.sh, invisible to a .py-only search.
No orphaned backend modules across app/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d116fcc146 |
chore(frontend): drop the dead FundamentalsPanel dev harness
Left over from the fundamentals work. Verified orphaned before removing: not in vite.config.ts (default build input is index.html alone, and dist/ only ever contained index.html, so it never shipped), not referenced by any script, config or module, and imported by nothing. Its only mention anywhere was its own header comment — the many other "harness" hits in the repo are the factor / backtest IC harness, which is unrelated and stays. Removes frontend/src/dev entirely. The pattern it embodied — a fixture-seeded page for eyeballing one component without a backend — is still the only way to see a component render, since the frontend has no test runner. But that is worth recreating per component on the spot, not preserving as a stale file pinned to one panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94baa89423 |
fix(jobs): make last-run writes atomic and drain them properly on shutdown
Two review findings on
|
||
|
|
4b4a1084cb |
refactor(jobs): group Admin -> Jobs into sections instead of one flat list
Nineteen jobs rendered as one alphabetical list in which a pipeline, one of its
steps, a standalone cron job and a manual-only job were indistinguishable.
Four sections, ordered by the API (category rank, then trading-day order within
it) so the client does not re-derive ordering: Pipelines, Pipeline steps,
Standalone scheduled, Manual only. An unrecognised category still renders, under
"Other" -- a stray section beats a job silently vanishing from the admin page.
Sections rather than nesting steps under their parent, which is what the flat
"runs via pipeline" label invited. Membership is many-to-many -- data_collector
runs in all four pipelines, alerts and outcome_evaluator in two each -- so
nesting means duplicating those rows, and the duplicates would each carry a
Trigger button despite not being distinct actions: only plain collect_ohlcv is
registered, while the near-close and after-close variants are different
coroutines that are not individually triggerable. Instead each pipeline card
lists its step sequence and each step says which pipelines run it, which is the
same information without a button that lies.
Every job now answers "when does this next run" the same way: its own timer, its
soonest enabled parent's ("Next via Intraday Pipeline in 42m"), or "manual
only". Jobs with no recorded run say so explicitly rather than showing nothing.
The status chip and the rate-limit banner still read runtime_* only, so a
persisted failure cannot pin either to a stale state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
22bee28ac7 |
feat(jobs): persist each job's last run so it survives a restart
Job outcomes lived only in scheduler._job_runtime, an in-memory dict. Every
deploy wiped it, so Admin -> Jobs could report "Active" with no indication a job
had ever run or how it ended -- which is the main thing that page is for.
New job_run_state table (migration 031): one row per job, upserted on job_name.
Deliberately not history -- system_events already grows unbounded with no
retention job, and a second append-only operational table would repeat that
debt. Adding history later is purely additive.
Written from two hooks, NOT from _runtime_finish. That looked cheapest (one
function, ~40 call sites) but unit tests invoke job coroutines directly, so it
would fire detached DB writes at the real session factory throughout the suite,
and there is no testing flag to guard on.
- An APScheduler EVENT_JOB_EXECUTED/ERROR listener covers everything the
scheduler fires, including manual triggers. Its detached task is held in a
module-level set (a bare create_task result can be collected mid-flight) and
drained in the app lifespan before engine.dispose().
- _run_pipeline persists directly, and must: pipeline steps are plain
coroutine calls that emit no scheduler events, so the listener cannot see
them. The step persist sits AFTER the except that swallows step errors --
inside it, exactly the failed runs worth seeing would be skipped. The
orchestrator persists in the finally, and the disabled early-return persists
too, or "skipped" is silently dropped.
_persist_job_run never raises: a persistence failure must not break an otherwise
successful pipeline.
The API reports this as last_run_* and leaves runtime_* meaning strictly live
in-memory state. Reusing runtime_status would have been a regression, not a
no-op: JobControls drives the status chip from it (a job that errored eight days
ago would read "Last run error" forever instead of "Active") and picks the
rate-limit banner from it (a week-old rate limit would pin the banner
permanently). Tests pin the split.
The table starts empty; each job fills its row the next time it finishes. No
backfill from system_events, which records only warning/error outcomes under a
different status vocabulary and would invent successes that never happened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
083c9dbf7c |
refactor(jobs): derive job topology from one catalog, make next-run coherent
Groundwork for the Admin -> Jobs cleanup. Three sources of truth collapse into
app/job_catalog.py, which imports nothing from app so both the scheduler and
admin_service can import it at module level (admin_service otherwise has to
import the scheduler inside functions to dodge a cycle).
PIPELINE_MEMBERS is now DERIVED from the four pipeline step lists instead of
being a literal set in admin_service duplicating four lists in scheduler.py with
nothing asserting they agreed. A test pins that the derivation reproduces the
previous hand-maintained 9 names exactly, so this is behaviour-preserving.
Deletes the private _JOB_NAMES list, which held 16 of the 19 jobs:
benchmark_collector, outcome_evaluator and shadow_book had no runtime row, and
so no "last run" line in the panel, until their first run in a given process.
_job_runtime is now seeded from the catalog, and a test pins the invariant.
Next-run is decided by category rather than by reading a timestamp. A pipeline
step has no schedule of its own, so it reports its parent's ("next via Morning
Pipeline in 3h") instead of nothing; a manual job says manual_only rather than
rendering a date. This also fixes a real bug: triggering a paused job set
next_run_time=now, APScheduler re-armed the 520-week backstop behind it, and the
panel displayed "next run in ~87600h". Two independent guards -- the category
rule, plus _visible_next_run dropping anything past a year -- and an APScheduler
listener that re-pauses steps and manual jobs once their run finishes. The
listener is registered at module level because configure_scheduler is called
more than once and add_listener does not deduplicate.
Migrates backtest and ticker_universe_sync from interval to cron (Sun 03:00 ET
and 01:00 ET). configure_scheduler calls remove_all_jobs() on every startup, so
an interval countdown restarts each deploy -- a 168h backtest needed a week of
uninterrupted uptime to fire even once. The codebase already documented this
pitfall as the reason cron was adopted; these two were never migrated. Both are
now editable in Admin -> Schedule.
Also: list_jobs went from one settings query per job (19) to one for all of
them, and data_backfill is hidden from the listing while staying registered and
API-triggerable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7fdcac3b55 |
docs: carry the risk-monitor wording through docs, comments and logs
Follows
|
||
|
|
5ea0785be6 |
refactor(ui): name the two regime jobs for what they actually do
"Market Regime" and "Regime Monitor" sat next to each other in Admin -> Jobs
(pipeline steps 4 and 5) reading as the same job. They are unrelated, and the
names had it backwards: "Market Regime" is the SPY 50/200 guard that drives the
TopBar trend dot and the counter-trend warning on setups, so it changes what a
setup shows; "Regime Monitor" is the observational AI/Tech thermometer that
explicitly feeds no trades. The more consequential job had the vaguer name.
market_regime "Market Regime" -> "Market Trend (SPY)"
regime_monitor "Regime Monitor" -> "AI/Tech Risk Monitor"
Display strings only. The job *ids* are persisted -- they key the pipeline step
list, cron config, runtime tracking and run history -- so they are untouched,
as is the /regime route, which keeps existing links working.
The label the admin UI renders comes from JOB_LABELS in admin_service (via
routers/jobs.py), not from the scheduler's APScheduler `name=`. Both are updated;
only the former is user-visible.
Carries the vocabulary through the rest of the surface so it does not half-land:
page title, nav ("Regime" -> "Risk"), the empty-state instruction that names the
job to run, the quadrant alert toggle, the morning-pipeline hint, and the
Telegram alert headline ("Regime quadrant change" -> "AI/Tech risk quadrant
change"). No test asserts any of these strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
98b41629e7 |
ci: lint the whole repo, and pin the rule set so it stays deterministic
Widens the lint step from `ruff check app/` to `ruff check .`, since tests/ and
scripts/ had drifted to 11 findings while unchecked (fixed in
|
||
|
|
1c6ccceb12 |
chore: make the whole tree ruff-clean, not just app/
CI only lints app/, so 11 findings had accumulated in tests/ and scripts/. Mechanical and behaviour-neutral, but two were not auto-fixable and needed a judgement call rather than `ruff --fix`: - E741 in run_fip_breadth_diagnostics: `l` is the OHLCV low and is genuinely used, so this was a naming fix (`l` -> `lo`), not a deletion. - F841 in the same file: `vol_ix`/`momr_ix` are assigned from a pure local `_index()` and never read, so removing them cannot change any output. Their upstream `vol_weeks`/`momr_weeks` maps *are* used further down and stay; the comment above `_index` was corrected to say so. The rest are unused imports and f-strings without placeholders (literal markdown table headers, so identical output). Verified beyond the linter, since py_compile does not catch a removed-but-used import: every removed symbol has zero remaining references, all scripts compile, and the full unit suite passes (852 passed, 1 skipped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3483797e75 |
fix(regime): reseed stored history on a sensor change, and stop faking an observation
Two review findings on
|
||
|
|
7dc804be2b |
test(dolt): anchor the real-clone smoke test to the clone, not the wall clock
The test built the importer with today=date.today() while running against a fixed local clone with do_pull=False, so its forward horizon shrank by a day per real day. It has now decayed past the initial-load gate -- 19d against the 21d MIN_FORWARD_HORIZON_DAYS floor -- and would have kept failing, worse each day. Anchors today to the clone's own calendar (max reporting date across the seeded dot-free symbols, minus 35 days, mirroring the ~35d horizon the importer's own comment cites) and uses that date in the forward-calendar assertion. Also surfaces run.error_details on failure, which is how the cause was found. Test-only. MIN_FORWARD_HORIZON_DAYS and the importer are untouched: production pulls fresh data on every run and was never affected by this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
46ace501a2 |
refactor(regime): collapse the monitor page, fix the OAS rebuild window
The page had twelve stacked blocks, several of them different views of the same numbers. The quadrant plot and the score-history chart drew the same two series from the same query key, which read as two datasets; they are now one card with a Time | Path toggle. The two pillar disclosures become one grouped table, and three prose blocks (data quality, basket, coverage) become one provenance chip strip. Page text is now limited to what changes how the reader interprets today's number; the rest moved to the methodology doc. Removes three stale-threshold bugs of one class. The quadrant fell back to v2's 60/60 dividers when quadrant_config was absent -- the real values are 50/40 and they feed alert_service, so the chart could disagree with what actually fires. The gauge fell back to v2's 30/60/80 band ticks, and drew a divider line that always landed on its own "elevated" tick. The time series' reference lines were at 30/60/80, which correspond to nothing in v3; they are now per-axis dashed lines read from the same quadrant_config. Rendering also surfaced a live clipping bug inherited from the old chart: margin.left -18 against YAxis width 28 left ~10px for a 3-digit label, so every Y tick was cut off. HY_OAS_WINDOW_DAYS was 400 *calendar* days while a rebuild replays REBUILD_SESSIONS = 400 *trading* sessions (~579 calendar days), so the oldest ~180 days of any rebuild got no OAS at all and both credit sensors returned None. State then lands at 80% coverage and Warning at exactly MIN_COVERAGE, so both still publish bands -- a series that looks homogeneous while its oldest rows were scored without credit. Widened to 700. This needs no methodology bump: C1 reads [-1] and W3 reads [-21], both from the end, so widening only prepends and every live score is bit-identical. Sequenced deliberately, since acting on the open findings below bumps METHODOLOGY and fires the rebuild. A just-collected fundamental observation was hidden until its effective date -- one day, three over a weekend -- because the live reading called the point-in-time function, so refreshing appeared to do nothing. That was the opposite of what the doc claimed. fundamental_overlay stays the gated record (it runs for every replayed date during a rebuild); current_observation is the live reading and reports the effective date instead of blanking the content. Nothing in the overlay is scored, so showing it early cannot reach a published number. Documents four calculation findings. Three are not implemented, since each changes a published score and so requires a v4 cut: State's top band is a credit-event band (credit returns 0.0 rather than None below the 3.5 anchor, so it is pinned at zero at weight 20 -- with everything else pegged State computes to exactly 80.0, the breaking threshold); V1 saturates at VIX 30; and the deliberate max(P1,P2,P3) defeats P3's anchoring because P1 is binary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23de8c9540 |
docs(dolt-plan): drop workstream B and record why
B would have replaced Alpaca historical OHLCV with the DoltHub stocks repo. It was scoped inside a plan whose goal was killing the quota-limited free-tier APIs — which A6 achieved without it, since Alpaca was never one of them. Its only concrete benefit was `corporate_actions` for the KLAC-class post-filing split (TTM EPS pre-split against a post-split price). That needs split events, not a 4.7 GB clone, and the Alpaca SDK already in the venv exposes them. Against that: fundamentals are 20% of the composite and P/E one of three inputs, so the wart is small and self-correcting at the next filing; and B would have made symbol history mutable as a routine event, which the backtest/prod parity guard exists to catch. The design is kept as a record, struck through rather than deleted, with the reasoning next to it so this isn't re-derived. Also corrects the doc's status header, the never-written migration 027 (that number went to weighted_avg_diluted_shares), and the note that the stocks repo's license was never reviewed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c2a3b56aaa |
chore: drop the A6 rollback tombstones
A6 deployed cleanly and the provider keys are gone from the production `.env`, which makes the legacy collector inert regardless of any settings row. The two tombstones migration 029 pinned have no remaining job, and nothing in the codebase reads either key. Migration 030 deletes them and drops the Admin filter that hid them. Unlike 029's, its downgrade is meaningful — it restores both rows at their safe values, since going back past this revision means going back toward code that reads them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e1607ddbff |
fix: don't double-report a failed SEC run, and correct the rollback doc
Three follow-ups from review of the A6 commits. The cache-summary re-finalize raised a second durable system event on a failed run: _runtime_finish emits for `error`/`rate_limited`, and the dedup key includes the message, so "SEC unavailable" and "SEC unavailable · cache 511 · 2 score inputs changed" landed as two unacknowledged Admin events. Adds `emit_event` so a re-finalize that only rewords an outcome stays silent, with a regression test asserting exactly one event. The rollback section still claimed disabling the SEC job freezes the cache — the opposite of what the same page says two lines earlier, and of what the code now does. Rewritten: there is no Admin cache-off switch, restoring `fundamental_data` alone is temporary because the next run rebuilds it from the same snapshots and code, and a real freeze means stopping the service. Remaining "shadow" wording: the two import jobs have never been shadow since activation, so `_run_shadow_import` -> `_run_source_import`, its section heading, the deployment doc's job label, and the plan doc's "production switch remains" handoff paragraph are all brought up to date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
13f3636b6a |
fix: surface the fundamentals cache result on every SEC job outcome
The runtime message only appended the cache summary when the import itself completed. On a deferred, failed or source-locked run Admin → Jobs showed just the import outcome, so an operator had no signal that `fundamental_data` had advanced — contradicting the claim now made in the docstring, the schedule hint and the deployment doc. The import status still varies and stays the headline; the cache summary is appended to all of them. Adds a source-locked regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3e83d63b05 |
chore: decommission FMP, Finnhub and Alpha Vantage (A6)
The A5 cutover has been on and observed in production, so SEC Company Facts + DoltHub earnings are already the live source for `fundamental_data`. This removes everything the legacy path still occupied. Gone: the three providers and their config/env keys; the weekly `fundamental_collector` job; the cutover toggle (SEC + Dolt is now the unconditional path, so `off` can no longer silently freeze scoring inputs); the A5 parity report, whose deltas became structurally zero once the candidate builder started writing the table it compared against; and the FMP tier of universe bootstrap. Two behavioral notes: - Disabling **SEC Fundamentals Import** now stops the SEC network fetch only. The local cache refresh moved outside the job-enable check, because candidates also derive from daily closes and earnings events — freezing those on an ingestion pause would stale scoring with no fallback left to recover from. - `/ingestion/fetch?sources=fundamentals` still accepts the key and reports `skipped`; there is no per-ticker fetch any more. Migration 029 does not blanket-delete the leftover settings rows. Migrations run before the service restart, and pre-A6 code reads an absent `job_*_enabled` row as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe values (hidden in Admin) and only the inert three are deleted. Removing the provider keys from the production `.env` is the matching rollout step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f5d4b516ab |
docs: land capacity-study evidence and share the rank-map helper
Brings the durable artifacts of research/portfolio-capacity-rebalancing onto main so the rationale for raising the count cap lives with the code that cites it. The matrix runner, the research simulator hooks and the study's unit tests are deliberately left behind; they remain at tag research/portfolio-capacity-final. Corrects conclusions that were reached on EV per trade and are now superseded: the findings doc's decisions 1 (keep cap 10) and 4 (run the risk-floor A/B) are struck through and answered in a new correction section, and the research README and phase-A matrix entries are updated to match. The frozen specification itself is untouched -- its recorded SHA-256 f1e37783 still verifies. effective-risk-floor-ab.md is retained but marked CLOSED/NEGATIVE: the study it proposes is already answered by cap15 vs cash_unbounded (-0.753pp CAGR while EV/trade rises), and its EV-based pass rule would have shipped it. scripts/research_rankings.py replaces a fourth copy of the historical rank-map helper; run_research_matrix, run_execution_recovery_matrix and run_daily_reentry_matrix now share it. The shared version adds a duplicate observation guard and a deterministic symbol tie-break the copies lacked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ff0fd9f1c |
feat: stop the position count cap from binding (10 -> 15)
The capacity bracket study (reports/portfolio-construction-prod505-capacity- bracket-daily-v1) showed a book whose count cap never binds earns +1.1pp CAGR over the old 10 -- 51 of 175 paired cohorts better, 2 worse -- at unchanged drawdown (+0.007pp) and better Calmar in 51 of the 52 cohorts that moved. The headline EV-per-trade delta is ~0 (+0.001), which is the trap: capacity does not change trade quality, it changes trade COUNT. Flat EV/trade means the blocked entries were just as good as the taken ones, so refusing them cost their whole contribution to return. Judge capacity on CAGR, never on EV per trade. 15 is headroom, not a target. cap15 peaked at 12 positions with zero full-book skips, so cash plus SIM_NOTIONAL_CAP is the real ceiling and 15/20/None are the same experiment. SIM_MAX_POSITIONS and the shadow book's DEFAULT_CAPACITY move together to keep backtest and production in parity. Historical research arms pass max_positions explicitly, so their labels and past results are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07d864cf64 |
fix: draw the trade chart for positions older than the window
TradeChart shows a fixed 21-bar window. Once a trade is older than that, its entry bar precedes the window and entryIdx goes negative, so the price and trail paths index past the start of series/stopPath and emit NaN coordinates -- the browser then drops both paths entirely, leaving only the horizontal level lines. Clamp the index to the left edge and drop the entry marker when the entry bar is outside the window; the full-width entry line already carries it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2435abacaf | refactor: simplify open position details | ||
|
|
d29d603158 |
test: drop redundant scanner primary-target suites
test_rr_scanner_bug_exploration.py and test_rr_scanner_fix_check.py both assert one invariant: the headline target is the probability-based near level, not the far max-R:R lottery. That is already covered directly by test_recommendation_service.py's _select_primary_target tests, which also reach cases these never did (empty list, probability floor, activation vs scanner floor), and end to end by test_rr_scanner_integration.py's full-flow test -- a strict superset of their deterministic cases: three resistance and three support levels, both directions, plus persistence and rr_ratio consistency. The two files also duplicated each other, and their docstrings had gone stale: test_deterministic_long_three_levels documented a hand-computed _compute_quality_score winner even though the assertion is about the probability primary that supersedes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
70157ccfc2 |
perf: reuse the test schema instead of rebuilding it per test
The autouse _setup_db fixture ran create_all + drop_all for every test in the suite, including the many that never open a session. That cycle costs ~49ms against these 22 tables; truncating them instead costs ~6ms for the same guarantee of an empty database per test. Build the schema once, then delete every row before each subsequent test. No model sets sqlite_autoincrement, so SQLite reuses rowids after a full delete and generated ids still restart at 1. Measured over 874 tests, deterministic order: 138.6s -> 74.5s (~46%). Verified green under pytest-randomly's default random ordering as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |