Compare commits

...
6 Commits
Author SHA1 Message Date
dennisthiessenandClaude Opus 4.8 ea11efe3d1 Sweep the R:R floor; fix a holdout metric artifact
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m9s
Deploy / deploy (push) Successful in 37s
min_rr = 2.0 was hand-set in Admin (2026-06-24) and never swept — the gate
ablation only tested the floor on-vs-off, never its level. It was the last
un-swept knob in the live gate.

Swept against portfolio Sharpe under the real exit, with a parity self-check
(reproduces_production_gate: the row at the live floor must rebuild production's
exact 1,089-setup qualified set — it does).

  min_rr   qualified   in-sample Sh/CAGR   OOS Sh/CAGR (entries >= 2024-07)
  0.0        6636      1.98 / 58.5%        2.02 / 66.2%
  1.2        3897      1.34 / 33.9%        1.12 / 28.8%
  1.5        3127      1.20 / 29.6%        1.12 / 28.8%
  1.75       1974      1.64 / 44.5%        1.15 / 27.4%
  2.0 (live) 1089      2.04 / 50.4%        2.78 / 73.3%
  2.25        577      1.64 / 31.8%        1.71 / 31.9%
  2.5         286      1.67 / 29.0%        0.68 /  8.7%

KEEP 2.0. It is the optimum in both windows, and a peak that reproduces in data
it was never fitted to is real evidence. But treat it as fragile: unlike the ATR
trail (a plateau), this is a spike with a trough beside it — +/-0.25 costs ~0.4
Sharpe in-sample and ~1.6 out-of-sample — and the curve is bimodal (floor-off is
good, 1.2-1.75 is bad, 2.0 is good). The hand-set value landed on the peak by
luck, not by tuning. Do not nudge it.

Worth knowing: turning the floor OFF entirely is the second-best row in both
windows, with substantially higher CAGR (58.5% / 66.2%) and more trades. If CAGR
ever outranks Sharpe here, "no R:R floor" is a live option — and it would sever
the gate's last dependency on the weak S/R detector.

Also fixes a metric artifact in the holdout harness. The train book's equity curve
ran to the end of the data while its entries stopped at the split, so it sat in
flat cash for two years and deflated its own CAGR/Sharpe (reported 0.95 / 14.6%;
actually 1.31 / 29.6%). _simulate_portfolio now truncates the calendar to
hold_days after the last entry when end_date is set — it only triggers on the
holdout train window, so no other number moves. The clear-air OOS verdict is
unaffected: it rests on the test row, whose entries and curve both start at the
split and were always clean. Both holdout reports regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:45:59 +02:00
dennisthiessenandClaude Opus 4.8 906d1db7d1 Docs: state the actual strategy, add an end-to-end diagram, add a research log
The README opened with "find the path of least resistance, key S/R zones, and
asymmetric R:R setups" — a description of a strategy we do not run. What we run
is a long-only cross-sectional momentum book with a trailing exit. The S/R
engine, the composite score, sentiment and fundamentals are screening and
display; none has a measured edge.

- Rewrites the intro/philosophy around the real strategy, and says plainly what
  is NOT the edge.
- Adds a mermaid decision graph, universe -> qualified -> ranked -> opened ->
  closed, with the real exit distribution on the terminal nodes: initial stop 45%,
  trailing stop 31%, max hold 24%, S/R target 0%. Validated against the mermaid
  parser, not eyeballed.
- Documents that the R:R and touch-probability are GATE INPUTS, not forecasts of
  the trade — the single easiest way to misread this app.
- Adds win rate, best/worst R and the exit-reason split to the production
  baseline table.
- New docs/research/README.md: every strategy tested, the result, the decision,
  and why we stay with the current one. 12 rejected ideas (take-profit exits,
  clear-air gate relaxation, EV gate, regime overlay, inverse-vol sizing, shorts,
  standalone vol, FIP, ...), the confirmed tuning knobs, the open leads, and the
  method rules we learned the hard way (nested lookbacks are not out-of-sample; a
  rising win rate is a warning, not a win).
- Documents the research flags and the holdout harness, and warns that the
  portfolio_monitor lookbacks are nested windows, NOT a holdout.
- Notes the snapshot must copy paper_% settings or it silently diverges from prod.

All baseline numbers re-verified against reports/backtest-20260711-prod-baseline.json
(506 tickers, 1,089 qualified, CAGR 50.4%, +413.8% vs SPY +95.7%, DD -21.4%,
Sharpe 2.04, 320 trades, 15.3d avg hold, and all five promotion contenders). No
corrections were needed — the numbers were right, the framing was not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:40:34 +02:00
dennisthiessenandClaude Opus 4.8 9789e3d762 UI: frame the setup as what it is — a momentum signal with a trailing exit
The UI told a swing-trade story (entry -> target -> stop) while the engine runs
a momentum portfolio (buy strength, trail out, re-rank). The selection was
honest; everything around it was borrowed from a strategy we don't run.

The target is never an exit under `atr_trailing`: `_atr_trailing_close()` does
not even take it as a parameter. It exists only to compute the R:R and touch
odds that admit a setup through the activation gate. Backtested exit reasons for
the production strategy: 144 initial stop, 98 trailing stop, 78 max hold —
target 0. See docs/research/sr-levels-and-exits.md.

What changed:

- New ExitPlanPanel on every setup card states the rules that actually close the
  trade: initial stop (1R), the price at which the 3x ATR trail takes over from
  it, the trail width in R, and the max hold. Derived in lib/exitPlan.ts from the
  live exit policy, so it follows Admin rather than hardcoding the default.
- New BaseRatesPanel replaces per-target "probability" as the answer to "what
  usually happens": win rate, average hold, best/worst R, and how trades actually
  ended — measured under the real exit, from the backtest report.
- "Target"/"target probability" relabelled to "level"/"touch odds" and grouped as
  gate metrics, with the R:R. On the dashboard focus card, residual momentum
  (the actual signal) takes the headline stat those two used to occupy.
- The take-trade dialog no longer offers a target dropdown whose value the exit
  ignores; it states the trailing plan instead. The picker returns only when the
  live policy is mode='target', where the choice is real. The stored target is
  now the setup's own, not whichever row was last clicked while exploring.
- "Played out" is gone. A setup was declared dead once price reached the target —
  backwards under a trailing exit, where reaching a level is the good case and
  the trade keeps running. Only the stop invalidates a setup now; running past
  the entry is an "extended" warning, measured in R (you'd be chasing).

The levels ladder, the price rail and the chart overlay all stay fully
explorable — clicking a level still drives them. It is framed as overhead
structure, which is what it is, rather than a menu of exits.

Adds a parity guard: the UI recovers ATR as |entry - stop| / 1.5, so the test
fails if the scanner's stop width ever moves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:29:21 +02:00
dennisthiessenandClaude Opus 4.8 85b3ef618f Research: S/R levels, the target exit, and the entry gate
Investigated whether our support/resistance detection follows best practice
and whether we actually use it that way. Three findings, all backed by runs
against the prod snapshot and written up in docs/research/sr-levels-and-exits.md:

- The S/R target must NOT become an exit. Honoring it as a take-profit on top
  of the 3x ATR trail drops Sharpe 2.04 -> 1.47 and halves CAGR. Win rate rises
  (37.5% -> 40.0%), which is the tell: it truncates the right tail where
  momentum's edge lives.
- The clear-air fallback (synthesize a 3xATR target where no resistance exists,
  so 52-week-high breakouts stop being vetoed) looked strictly better in-sample
  (Sharpe 2.04 -> 2.07, CAGR 50.4% -> 62.3%, DD 21.4% -> 20.1%) but FAILED a
  real out-of-sample holdout: on entries after 2024-07-01 it is worse on Sharpe
  (2.78 -> 2.45) and Calmar, better only on raw CAGR. Not shipped.
- The detector itself is weak vs best practice (POC/VAH/VAL computed then
  discarded, HVN = any above-mean bin, 1.48x volume double-counting, "touch"
  counts pass-throughs, no round numbers), but its only causal path to P&L is
  the entry gate. Fix it for the displayed levels, not for returns.

Method note: nested lookback windows are NOT out-of-sample. The in-sample result
was clean, large, and consistent across five windows, and still did not survive
a proper entry-date split.

All research paths are off by default and the default report is unchanged:
  BACKTEST_RESEARCH_EXITS=1        take-profit exit rows
  BACKTEST_ATR_TARGET_FALLBACK=k   synthetic k*ATR target when S/R offers none
  BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1  restrict that to genuinely clear air
  BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD   train/test split by entry date

Also fixes two reproducibility holes found while reconciling our local baseline
against the live report:

- create_backtest_snapshot.py now copies paper_% settings. The production
  monitor row replays the runtime exit policy via get_exit_policy(); without
  those keys a snapshot silently falls back to code defaults, so a live-tuned
  exit would never be reflected.
- Migration 020 drops activation_min_expected_value and
  activation_min_target_probability. Both are orphans of the June EV-gate
  redesign, read by no code path, but prod carries min_target_probability = 50.0
  which implies a probability floor that is not enforced (the real floor is the
  20% constant in qualification.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:05:34 +02:00
dennisthiessenandClaude Opus 4.8 fa26ec3ec4 Track backtest reports in git; add a report comparison tool
The reports are the evidence behind the production baseline, so they belong
next to the README that quotes them rather than living only on one machine.
Un-ignores reports/*.json (~2.6 MB compressed for all 11); the snapshot DBs
they run against stay ignored.

Renames the reports to a single dated scheme so they sort chronologically and
say what they measured. Each name is derived from the report's own contents
(the atr_trail_sweep / regime_overlay / blue_sky_projected / sizing_test
sections, and the qualified counts that identify the A/B arms), not from the
ad-hoc slugs they carried before. The run the README quotes is now
backtest-20260711-prod-baseline.json.

reports/compare_reports.py loads every report into one sortable table
(portfolio monitor, entry variants, exit policies, portfolio sim), filters by
report and lookback, and highlights the best row for a chosen metric — max
drawdown correctly ranking lowest-as-best. Stdlib tkinter, no dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:04:13 +02:00
dennisthiessenandClaude Opus 4.8 88527f39b6 Refresh production baseline numbers in the README
The baseline table and promotion evidence still carried pre-primary-target-floor
figures. Re-derived every number from the 2026-07-11 run, the first baseline
measured after the 20% probability floor pruned lottery targets (1,428 -> 1,089
qualified).

The promotion evidence table also claimed the promoted book beat legacy on
"CAGR, Sharpe, and drawdown". That no longer holds: legacy residual 80 + hold
now has the shallowest drawdown (-15.8% vs -21.4%). Production still wins on
Sharpe, so the promotion stands, but the text now says so honestly rather than
implying a clean sweep.

Also documents the primary-target reach-probability floor in the gate
description, which shipped in c7a198b/8f41143 but never reached the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:03:47 +02:00
33 changed files with 1923492 additions and 132 deletions
+2 -1
View File
@@ -36,5 +36,6 @@ alembic/versions/__pycache__/
combined-ca-bundle.pem
# Local research artifacts
# Backtest reports in reports/ are tracked: they are the evidence behind the
# production baseline in the README. The snapshot DBs they run against are not.
backtest_snapshots/
reports/backtest-*.json
+123 -24
View File
@@ -1,8 +1,54 @@
# Signal Dashboard
Investing-signal platform for NASDAQ stocks. Surfaces the best trading opportunities through weighted multi-dimensional scoring — technical indicators, support/resistance quality, sentiment, fundamentals, and momentum — with asymmetric risk:reward scanning.
Investing-signal platform for US equities. It runs one strategy, and it is a boring one:
**Philosophy:** Don't predict price. Find the path of least resistance, key S/R zones, and asymmetric R:R setups.
> **A long-only cross-sectional momentum book.** Buy the top quintile by beta-adjusted 12-1 month momentum, tilt toward higher volatility, hold at most 10 names, cut at 1.5× ATR, then trail at 3× ATR for up to 30 trading days.
**Philosophy:** don't predict price — rank it. The edge is *relative* strength across the universe, and the discipline is in the exit: cut losers fast, let winners run until the trail catches them.
**What is NOT the edge — read this before trusting a number on screen.** The composite score, the 5 dimensions, sentiment, fundamentals, and the support/resistance engine are **display and screening context**. None has a measured edge. In particular:
- **The S/R "target" is not an exit.** It exists only to compute the R:R and touch-odds that admit a setup through the activation gate. The live exit never reads it. Across 320 backtested production trades the exit reasons were **144 initial stop, 98 trailing stop, 78 max hold — and 0 targets.** Honoring the target as a take-profit was tested and *halves CAGR* ([research](docs/research/sr-levels-and-exits.md)).
- **The composite score does not select trades.** Residual momentum does.
Full experiment log — everything tested, kept, and rejected: **[docs/research/](docs/research/README.md)**.
## The strategy, end to end
```mermaid
flowchart TD
U["Universe — ~500 tickers<br/>daily OHLCV"] --> M["Residual 12-1 momentum<br/><i>12-month return, skip last month,<br/>beta-adjusted vs SPY</i>"]
M --> R["Rank cross-sectionally<br/>into percentiles"]
R --> G1{"Top 20%?<br/>percentile ≥ 80"}
G1 -->|no| SKIP["Not traded<br/><i>(still scored — the control group)</i>"]
G1 -->|yes| S["Build the setup<br/>entry = last close<br/><b>stop = entry 1.5 × ATR</b><br/>level = nearest S/R above"]
S --> G2{"Activation gate"}
G2 --> G2a["R:R ≥ 2.0 <i>(to the S/R level)</i>"]
G2 --> G2b["touch odds ≥ 20%"]
G2 --> G2c["action not NEUTRAL<br/>and matches direction"]
G2a & G2b & G2c --> Q{"qualified?"}
Q -->|no| SKIP
Q -->|yes| RANK["Rank by production score<br/>80% momentum %ile<br/>+ 20% volatility %ile"]
RANK --> BOOK{"Room in the book?<br/>max 10 positions"}
BOOK -->|no| WAIT["Wait for a slot"]
BOOK -->|yes| OPEN["OPEN — size at 1% account risk"]
OPEN --> EXIT{"Exit — whichever comes first"}
EXIT --> E1["Initial stop hit<br/>entry 1.5 × ATR → 1R<br/><b>45% of trades</b>"]
EXIT --> E2["Trailing stop hit<br/>highest close 3 × ATR<br/><i>only binds once price is ~1R up</i><br/><b>31% of trades</b>"]
EXIT --> E3["Max hold reached<br/>30 trading days<br/><b>24% of trades</b>"]
EXIT -.->|"NEVER"| E4["S/R target<br/><b>0% of trades</b>"]
style M fill:#1e3a5f,color:#fff
style OPEN fill:#1e4d2b,color:#fff
style E4 fill:#2a2a2a,color:#888
style E1 fill:#4a1f1f,color:#fff
style E2 fill:#1e4d2b,color:#fff
```
**How to read the exit box.** The initial stop is tight (1.5× ATR) and the trail is wide (3× ATR), so the trail sits *below* the initial stop at entry and only takes over once price has advanced roughly 1R. Cut fast when wrong; give room once right. That asymmetry is what produces the right-tailed return profile the strategy depends on — most trades lose a little (win rate ~37.5%), a few win big (best trade +12.9R), and *that is why there is no take-profit*.
## How It Works
@@ -31,53 +77,67 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
### From score to "top pick"
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0100) combine into a weighted composite (weights configurable; missing dimensions re-normalize).
2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability.
3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top residual-momentum percentile of the universe (the validated edge is long-only; the confidence floor was ablated to zero effect and defaults off).
1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0100) combine into a weighted composite (weights configurable; missing dimensions re-normalize). **Display and ranking only — it does not select trades.**
2. **Setups** — the scanner builds long/short setups with a 1.5× ATR stop and picks the nearest S/R level as a nominal target, then adds a confidence score, conflict flags and a per-level touch-probability.
3. **Activation gate** — a setup *qualifies* only if it ranks in the top residual-momentum percentile of the universe (**the actual selection**, long-only), clears the R:R floor, **and** its primary level carries at least a 20% touch-probability. The confidence floor was ablated to zero effect and defaults off.
4. **Top pick** — qualified setups are ordered by the production rank: 80% residual momentum percentile + 20% 6-month realized-volatility percentile. The #1 is highlighted on the Dashboard and labelled on the ticker page.
**What the R:R and touch-probability in step 3 actually are.** They are *gate inputs*, computed from an S/R level the trade will never exit at — they exist to filter setups, not to forecast the trade you're about to take. A setup with "R:R 2.4:1, 34% touch odds" is not a claim that you'll make 2.4R with 34% probability; it's a claim that this setup cleared the screen. What actually happens to a trade is in the exit box of the diagram above, and on the "what usually happens" panel in the UI. Conflating the two is the single easiest way to misread this app.
## Strategy Status — What's Validated and What Isn't
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (June 2026, ~5 years of OHLCV), not from opinion.
**Read this before touching scoring, gating, or setup logic.** The platform measures itself — a weekly-replay backtest plus a factor rank-IC harness (`app/services/backtest_service.py`) — and the verdicts below come from those reports (latest run July 2026, ~5 years of OHLCV), not from opinion.
> **The full experiment log lives in [docs/research/](docs/research/README.md)** — every strategy we've tested, the result, and the decision. Check it before proposing an idea; most of the obvious ones have already been run and rejected.
| Component | Verdict | Evidence |
|---|---|---|
| **Residual 12-1 cross-sectional momentum** (the activation gate, long-only) | **Production gate — in-sample edge** | Promoted July 2026 after the portfolio variant beat raw 80 on CAGR, Sharpe and drawdown. Raw 12-1 remains a fallback only when benchmark data is unavailable |
| S/R setup engine (ATR stops, S/R targets, reach-probability) | **Filter/execution context, not the exit** | R:R/room-to-run still earns its keep as a filter, but S/R targets underperform the time exit. The probability model is display-only |
| **3× ATR trailing exit** (+ 1.5× ATR initial stop, 30-day max hold) | **Production exit — best Sharpe of every exit tested** | Beat hold / SMA50 / 20-day-low / technical-40 and both take-profit variants (July 2026) |
| S/R setup engine (ATR stops, S/R levels, touch-probability) | **Gate input only — NOT an exit, and not an edge** | The exit never reads the target (0 of 320 trades). Honoring it as a take-profit drops Sharpe 2.04 → 1.47. The R:R floor does real work *as a filter*; the detector itself is methodologically weak. [Full write-up](docs/research/sr-levels-and-exits.md) |
| Composite score + 5 dimensions | **Display/ranking only** | Sub-scores are hand-built heuristics; none has a measured IC. Note: the "momentum" *dimension* is 5/20-day ROC — NOT the validated 12-1 factor (that lives in `momentum_service`) |
| LLM sentiment | Display + a bounded composite adjustment (± weight × 100 pts around neutral 50) | Deliberately kept out of the setup engine; no point-in-time history to validate against yet |
| Fundamentals | Feeds composite + confidence only | Latest values only, no history — same limitation |
| Short setups | **Excluded while the momentum gate is active** | Backtest showed shorts fight the trend and drag expectancy |
| Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups |
| Expected-value gate (removed June 2026) | Degenerate — do not resurrect | Structurally favored distant lottery targets; selected *worse*-than-random setups. Orphaned settings dropped in migration 020 |
| S/R target as a take-profit (tested July 2026) | **Rejected** | Sharpe 2.04 → 1.47, CAGR halved. Win rate *rose* — it truncates the right tail where the edge lives |
| "Clear-air" gate relaxation (tested July 2026) | **Rejected — failed out-of-sample** | Strictly better in-sample (Sharpe 2.07 / CAGR 62.3% / DD 20.1%), then lost on a real train/test split (Sharpe 2.78 → 2.45). A cautionary tale: nested lookbacks are not OOS |
Caveats on the momentum result: in-sample, roughly one market regime, costs/slippage approximated at 0.1% per side, and residual momentum still needs SPY benchmark history to compute. The **out-of-sample proof is the forward paper-trade record**: Signals → Track Record compares live qualified expectancy against the backtest.
### Current production baseline
Use this as a regression guardrail for future strategy changes, not as a return promise. Backtest run: local production SQLite snapshot, 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-01, 0.1% per-side costs, price-only SPY benchmark.
Use this as a regression guardrail for future strategy changes, not as a return promise. Backtest run: local production SQLite snapshot, 506 tickers, weekly cadence, 30-trading-day horizon, 2022-06-28 → 2026-07-02, 0.1% per-side costs, price-only SPY benchmark. Numbers below are the 2026-07-11 run (`reports/backtest-20260711-prod-baseline.json`) — measured *after* the primary-target probability floor shipped, which pruned lottery-target setups (1,428 → 1,089 qualified) and lifted Sharpe on all three promotion contenders.
| Item | Current baseline |
|---|---|
| Strategy version | `residual_highvol_80_20_atr_trail3_v1` |
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, R:R floor on, NEUTRAL excluded, confidence floor effectively off |
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, R:R >= 2.0 (live `activation_min_rr`; the code default is 1.2), primary-level touch-probability >= 20%, NEUTRAL excluded, confidence floor off (0) |
| Production rank | 80% residual momentum percentile + 20% 6-month realized-volatility percentile |
| Exit | Initial ATR stop plus 3x ATR trailing stop, max 30 trading days |
| Portfolio CAGR | +44.4% |
| Portfolio total return | +336.6% vs SPY +95.7% |
| Max drawdown | -23.8% |
| Sharpe | 1.72 daily, annualized |
| Trades | 376 |
| Average hold | 14.7 trading days |
| Portfolio CAGR | +50.4% |
| Portfolio total return | +413.8% vs SPY +95.7% |
| Max drawdown | -21.4% |
| Sharpe | 2.04 daily, annualized |
| Trades | 320 |
| Win rate | 37.5% |
| Average hold | 15.3 trading days |
| Best / worst trade | +12.9R / -3.3R |
| **How trades actually ended** | **initial stop 144 (45%) · trailing stop 98 (31%) · max hold 78 (24%) · target 0 (0%)** |
That last row is the strategy in one line: a 37.5% win rate is *fine* because the +12.9R tail pays for every 1R stop. It is also why no take-profit exists — and why the S/R "target" shown in the UI is a screening artifact, not a plan.
Promotion evidence from the same snapshot:
| Candidate | CAGR | Max DD | Sharpe | Trades | Read |
|---|---:|---:|---:|---:|---|
| Legacy residual 80 + 30d hold | +34.8% | -24.4% | 1.51 | 339 | Previous production baseline |
| Residual/high-vol 80/20 + 30d hold | +39.2% | -23.9% | 1.55 | 345 | Better entry rank, slightly lower drawdown |
| Residual/high-vol 80/20 + 3x ATR trail | +44.4% | -23.8% | 1.72 | 376 | Promoted: better CAGR, Sharpe, and drawdown |
| Pure high-vol 80 + 30d hold | +37.7% | -37.6% | 1.22 | 491 | Rejected: standalone volatility was too volatile |
| Low-vol 80 + 30d hold | +0.4% | -23.1% | 0.09 | 257 | Rejected: no useful edge |
| Legacy residual 80 + 30d hold | +49.6% | -15.8% | 2.02 | 300 | Previous production baseline. Still the shallowest drawdown of the three |
| Residual/high-vol 80/20 + 30d hold | +51.9% | -22.2% | 2.00 | 303 | The vol tilt buys CAGR and pays for it in drawdown |
| Residual/high-vol 80/20 + 3x ATR trail | +50.4% | -21.4% | 2.04 | 320 | Promoted: best Sharpe. The ATR trail recovers part of the drawdown the vol tilt costs |
| Pure high-vol 80 + 30d hold | +31.6% | -34.8% | 1.12 | 476 | Rejected: standalone volatility was too volatile |
| Low-vol 80 + 30d hold | +2.7% | -19.5% | 0.29 | 240 | Rejected: no useful edge |
Read the top three honestly: the production book wins on Sharpe, not on every axis. The 80/20 vol tilt buys ~2pp of CAGR over the legacy residual-only book but costs ~6pp of drawdown, and the ATR trail hands part of that drawdown back. If drawdown ever matters more than risk-adjusted return here, legacy residual 80 + hold is the row to revisit.
The conclusion is not "trade high volatility alone." Keep residual momentum as the entry gate, use realized volatility only as a small ranking tilt, and add the ATR trail as defensive exit discipline.
@@ -115,6 +175,8 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
### Highest-value next experiments (in order)
> Check **[docs/research/](docs/research/README.md)** first — 12 strategy ideas have already been tested and rejected, including the obvious ones (take-profit exits, regime overlays, inverse-vol sizing, shorts).
1. **Forward monitor the promoted strategy** — the production UI now behaves like a portfolio monitor for the current strategy, with selectable lookbacks and SPY comparison. Forward paper-trade months are the only evidence the snapshot cannot provide; the July 2026 tuning pass closed every in-sample lead. (Trailing-stop sensitivity and the max-15 capacity check are done — see the tuning table above.)
2. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
3. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. Now doubly motivated: it is also where the strong `fip_id` signal (see tuning findings) could become tradeable. (Deeper history was considered and declined.)
@@ -150,8 +212,8 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
- Sentiment analysis with time-decay weighted scoring
- Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap)
- 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights
- Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1)
- Activation gate — qualifies setups on a residual-momentum percentile floor plus an R:R floor (validated long-only edge)
- Risk:Reward scanner — long and short setups, 1.5x ATR stops, S/R-based nominal targets, configurable scan R:R threshold (default 1.5:1 — distinct from the activation floor below)
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), an R:R floor (prod: 2.0) and a 20% primary-level touch-probability floor (validated long-only edge)
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
- Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study
@@ -282,7 +344,13 @@ npm run build
For research loops, run the production backtest locally from a SQLite snapshot
instead of deploying and clicking the Admin job. The snapshot contains only the
tables needed by `run_backtest`: tickers, OHLCV bars, SPY benchmark closes, and
activation/recommendation settings. Secrets and cached reports are not copied.
the activation / recommendation / paper-exit settings. Secrets and cached reports
are not copied.
> The `paper_%` settings **must** be copied: the portfolio monitor's Production row
> replays the *runtime* exit policy via `get_exit_policy()`. Without them a snapshot
> silently falls back to the code defaults, so a live-tuned exit would not be
> reflected and the local run would disagree with prod for no visible reason.
1. Open an SSH tunnel to the production Postgres instance:
@@ -342,8 +410,30 @@ matching decision. Every change still goes through the factor harness first (see
| `time_exit_sweep` | Net avg R / net R-per-day by hold length | Whether a fixed time exit beats the promoted ATR trail |
| `portfolio_monitor`, `portfolio_sim`, `strategy_variants` | CAGR, Sharpe, max drawdown, per-year returns | Promote a strategy only if it beats the current baseline on CAGR/Sharpe/DD |
| `signal_eval` | Mean IC, t-stat, IC>0 %, `reliable` | Iron rule: wire a new factor in only if \|IC\| ≳ 0.03 with a consistent sign and `reliable: true` |
| `holdout` (opt-in) | Train vs test books, split by entry date | **The only honest OOS read.** Set `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` |
| `recommendation`, `research_recommendation` | The report's own headline read | A starting point, not a substitute for the sections above |
**Out-of-sample validation.** The `portfolio_monitor` lookbacks (6m / 1y / 3y / 5y / all) are
**nested windows that all end today** — every one of them overlaps the data an idea was found
on, so none of them is a holdout. A rule that looks good across all five can still be an
in-sample artifact (this exact trap ate the clear-air experiment; see the research log). For a
real train/test split by entry date:
```bash
BACKTEST_HOLDOUT_SPLIT=2024-07-01 python scripts/run_backtest_snapshot.py \
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
```
Research-only flags, all off by default (the default report is byte-identical to the shipped baseline):
| Flag | What it does |
|---|---|
| `BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD` | Adds a `holdout` section: train (entries before) vs test (entries on/after), as disjoint books |
| `BACKTEST_MIN_RR_SWEEP=1` | Sweeps the activation R:R floor against portfolio Sharpe. Combine with `BACKTEST_HOLDOUT_SPLIT` to sweep out-of-sample |
| `BACKTEST_RESEARCH_EXITS=1` | Adds the rejected take-profit exit rows to the exit comparison |
| `BACKTEST_ATR_TARGET_FALLBACK=k` | Synthesizes a k×ATR target where S/R offers none |
| `BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` | Restricts that fallback to setups with genuinely no structure ahead |
`recommendation` is the one section surfaced on the deployed page ("What this
backtest recommends"); everything else in this table is intentionally local-only.
@@ -501,6 +591,13 @@ frontend/
├── stores/ # Zustand auth store
└── styles/ # Global CSS with glassmorphism classes
docs/
└── research/ # Experiment log: what was tested, the result, the decision
├── README.md # Overview — start here before proposing a strategy change
└── sr-levels-and-exits.md
reports/ # Committed backtest reports (JSON) + compare_reports.py
deploy/
├── nginx.conf # Reverse proxy + static file serving
├── setup_db.sh # Idempotent DB setup script
@@ -521,6 +618,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
- **`app/services/qualification.py` is mirrored in `frontend/src/lib/qualification.ts`.** Any gate change must land in both, or the UI's "qualified" flags silently disagree with the server.
- **Live scan and backtest share the same pure functions.** The backtest replays production logic through DB-free functions (`compute_technical_from_arrays`, `compute_momentum_from_closes`, `detect_sr_levels`, the recommendation helpers). New strategy logic must stay in pure functions consumed by both paths, or the backtest stops measuring what production actually does.
- **One S/R model app-wide:** `sr_service.detect_sr_levels` + `cluster_sr_zones` (2% tolerance) feed the chart, alerts, and target generation identically.
- **The S/R target is a gate input, never an exit.** `_atr_trailing_close()` does not take it as a parameter, and it must stay that way — take-profit exits were tested and halve CAGR. Any UI or alert that implies the trade exits at the target is a bug ([research](docs/research/sr-levels-and-exits.md)).
- **The outcome evaluator evaluates ALL setups**, not just qualified ones — unqualified setups are the control group that makes the Track Record meaningful.
- **`SystemSetting` access goes through `app/services/settings_store.py`** — don't query the model directly.
- **Time-series data gets a real table** (see `benchmark_prices`, `regime_snapshots`); `SystemSetting` JSON is only for config and cached reports.
@@ -542,6 +640,7 @@ Context for whoever — human or AI — continues this work. The owner pushes st
| Paper trades + time/trailing/target auto-exit | `app/services/paper_trade_service.py` |
| Point-in-time setup context snapshots | `app/models/signal_context_snapshot.py` + `app/services/rr_scanner_service.py` |
| S/R detection & zone clustering | `app/services/sr_service.py` |
| **Research log — what's been tested and rejected** | **`docs/research/`** |
| SPY benchmark for residual momentum + paper-trade alpha | `app/services/benchmark_service.py` |
| Pipelines & job registration | `app/scheduler.py` |
@@ -0,0 +1,63 @@
"""Drop the orphaned EV-gate activation settings.
``activation_min_expected_value`` and ``activation_min_target_probability`` are
leftovers from the June 2026 EV-gate redesign (migration 009). That gate was
superseded by the residual-momentum gate, and the current code reads neither key:
``admin_service._ACTIVATION_FLOAT_KEYS`` exposes only ``min_momentum_percentile``,
``min_rr`` and ``min_confidence``, and ``qualification.setup_qualifies`` gates on
those plus the hardcoded ``MIN_TARGET_PROBABILITY`` floor.
The rows are therefore inert but actively misleading: prod carries
``activation_min_target_probability = 50.0``, so anyone reading the DB (or an
Admin screen rendering it) would reasonably believe a 50% probability floor is
enforced. It is not — the real floor is the 20% constant in ``qualification.py``.
Reads never recreate them (``settings_store.get_value`` returns a default without
persisting), and the current Admin write path no longer emits these keys, so the
delete is permanent. Follows the precedent of migrations 009, 015 and 018.
Revision ID: 020
Revises: 019
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "020"
down_revision = "019"
branch_labels = None
depends_on = None
ORPHANED_KEYS = (
"activation_min_expected_value",
"activation_min_target_probability",
)
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM system_settings WHERE key IN "
"('activation_min_expected_value', 'activation_min_target_probability')"
)
)
def downgrade() -> None:
# Restore the values prod carried before the delete. They are inert either
# way — no code path reads them — but this keeps the downgrade faithful.
# ``updated_at`` is NOT NULL with only a Python-side default, so raw SQL must
# supply it explicitly.
op.execute(
sa.text(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('activation_min_expected_value', '0.01', CURRENT_TIMESTAMP), "
"('activation_min_target_probability', '50.0', CURRENT_TIMESTAMP) "
"ON CONFLICT (key) DO NOTHING"
)
)
+366 -7
View File
@@ -23,6 +23,7 @@ held neutral here — this calibrates the price/S-R machinery only.
from __future__ import annotations
import asyncio
import bisect
import json
import logging
import math
@@ -58,6 +59,7 @@ from app.services.outcome_service import (
from app.services.price_service import query_ohlcv
from app.services.qualification import (
HIGH_CONVICTION_ACTIONS,
MIN_TARGET_PROBABILITY,
_action_direction,
best_target_probability,
setup_qualifies,
@@ -123,6 +125,70 @@ def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
]
def _atr_target_fallback_k() -> float | None:
"""Research ablation: k for a synthetic k*ATR target when a direction has no
S/R level to aim at. Off (None) by default, which is production behavior —
no resistance above means no long setup at all. That veto lands hardest on
names at 52-week highs (clear air above), i.e. exactly what the momentum gate
selects, so this flag exists to measure what the veto costs. Set
BACKTEST_ATR_TARGET_FALLBACK=3 to enable. See docs/research/sr-levels-and-exits.md."""
raw = os.getenv("BACKTEST_ATR_TARGET_FALLBACK", "").strip()
if not raw:
return None
try:
k = float(raw)
except ValueError:
return None
return k if k > 0 else None
def _fallback_clear_air_only() -> bool:
"""Restrict the fallback to setups with genuinely NO structure ahead.
Without this, the fallback also fires when levels DO exist ahead but
``TargetGenerator``'s distance filters rejected them (nearer than 1 ATR, or
past ``max_atr_multiple``). Measured on the snapshot, that's 65% of what the
fallback admits — a different population from the clear-air breakouts, which
confounds the famine test. Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1."""
return os.getenv("BACKTEST_FALLBACK_CLEAR_AIR_ONLY", "").strip().lower() in {
"1", "true", "yes", "on",
}
def _has_structure_ahead(direction: str, entry: float, sr_levels: list[Any]) -> bool:
"""Is there any S/R level in the direction of the trade? (Resistance above for
a long, support below for a short.) False == clear air."""
if direction == "long":
return any(
lv.type == "resistance" and float(lv.price_level) > entry for lv in sr_levels
)
return any(
lv.type == "support" and float(lv.price_level) < entry for lv in sr_levels
)
def _atr_fallback_target(
direction: str, entry: float, stop: float, atr: float, k: float
) -> dict:
"""A synthetic target k*ATR from entry, shaped like a TargetGenerator row.
``sr_strength`` is 50 (neutral) so the probability model's strength magnet
contributes nothing — the target stands on distance alone.
"""
price = entry + k * atr if direction == "long" else entry - k * atr
distance = abs(price - entry)
risk = abs(entry - stop)
return {
"price": float(price),
"distance_from_entry": float(distance),
"distance_atr_multiple": float(k),
"rr_ratio": float(distance / risk) if risk > 0 else 0.0,
"classification": "Moderate",
"sr_level_id": -1, # synthetic: no S/R level behind it
"sr_strength": 50.0,
}
def _window_setups(
window_records: list,
config: dict,
@@ -174,7 +240,12 @@ def _window_setups(
zone_levels = _zone_representative_levels(sr_levels, entry)
targets = target_generator.generate_targets(direction, entry, stop, zone_levels, atr)
if not targets:
continue
fallback_k = _atr_target_fallback_k()
if fallback_k is None:
continue
if _fallback_clear_air_only() and _has_structure_ahead(direction, entry, sr_levels):
continue # structure exists ahead; the distance filters rejected it, not the famine
targets = [_atr_fallback_target(direction, entry, stop, atr, fallback_k)]
for t in targets:
t["probability"] = probability_estimator.estimate_probability(
t, dim_scores, None, direction, config
@@ -1099,6 +1170,7 @@ def _simulate_portfolio(
risk_per_trade: float = SIM_RISK_PER_TRADE,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
start_date: date | None = None,
end_date: date | None = None,
include_curve: bool = False,
) -> dict | None:
"""Replay the qualified setups as ONE capital-constrained book and report
@@ -1109,9 +1181,11 @@ def _simulate_portfolio(
``exit_policy``: "target" races the S/R target against the stop with a
timeout at ``hold_days``; "hold" keeps only the initial stop and exits at
the ``hold_days``-th close. Research exits add price-derived early exits:
"sma50", "low20", "technical40", and "atr_trail3". Stops fill at the
worse of stop or open (gaps modeled); positions still open at the end are
closed at their last mark. Returns None when there is nothing to trade.
"sma50", "low20", "technical40", and "atr_trail3". "atr_trail3_target"
runs the ATR trail *and* the S/R take-profit together — the trade ends at
whichever comes first. Stops fill at the worse of stop or open (gaps
modeled); positions still open at the end are closed at their last mark.
Returns None when there is nothing to trade.
"""
if qualified_fn is None:
def _default_qualified(c: dict) -> bool:
@@ -1121,12 +1195,15 @@ def _simulate_portfolio(
entries_by_ord: dict[int, list[dict]] = defaultdict(list)
start_ord = start_date.toordinal() if start_date is not None else None
end_ord = end_date.toordinal() if end_date is not None else None
for c in candidates:
if not qualified_fn(c) or c.get("direction") != "long":
continue
entry_ord = date.fromisoformat(c["date"]).toordinal()
if start_ord is not None and entry_ord < start_ord:
continue
if end_ord is not None and entry_ord >= end_ord:
continue # holdout: entries strictly before the split
if not c.get("entry") or not c.get("stop"):
continue
entries_by_ord[entry_ord].append(c)
@@ -1143,6 +1220,18 @@ def _simulate_portfolio(
if not calendar:
return None
if end_ord is not None:
# Holdout train book: entries stop at the split, but the calendar would
# otherwise still run to the last bar in the data — leaving the book in
# flat cash for the whole test period and deflating CAGR/Sharpe into
# something that looks like a result and isn't. Every open position
# resolves within `hold_days` bars of the last entry, so cut there.
last_entry_ord = max(entries_by_ord)
cut = bisect.bisect_left(calendar, last_entry_ord) + hold_days + 1
calendar = calendar[:cut]
if not calendar:
return None
cash = SIM_STARTING_CAPITAL
positions: dict[str, dict] = {}
curve: list[tuple[int, float]] = []
@@ -1248,7 +1337,7 @@ def _simulate_portfolio(
)
_close_trade(sym, min(pos["stop"], bar.open), reason)
continue
if exit_policy == "target" and pos["target"] and bar.high >= pos["target"]:
if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]:
_close_trade(sym, pos["target"], "target")
continue
if exit_policy == "sma50":
@@ -1269,7 +1358,7 @@ def _simulate_portfolio(
if pos["bars_held"] >= hold_days:
_close_trade(sym, bar.close, "time")
continue
if exit_policy == "atr_trail3":
if exit_policy in ("atr_trail3", "atr_trail3_target"):
pos["highest_close"] = max(pos["highest_close"], bar.close)
atr = _atr(sym, bar.idx)
if atr is not None:
@@ -1752,6 +1841,32 @@ EXIT_POLICY_VARIANTS: tuple[dict, ...] = (
},
)
# Take-profit exits, tested 2026-07-12 and REJECTED — see
# docs/research/sr-levels-and-exits.md. Honoring the S/R target is the worst
# exit of the seven: it lifts the win rate but truncates the right tail where
# momentum's edge lives. Kept so the result stays reproducible, off by default.
# Set BACKTEST_RESEARCH_EXITS=1 to include them in the exit comparison.
RESEARCH_EXIT_POLICY_VARIANTS: tuple[dict, ...] = (
{
"exit_policy": "target",
"label": "80/20 entry + S/R target take-profit (no trail)",
"description": "Take profit at the S/R target; initial stop only, no trailing.",
},
{
"exit_policy": "atr_trail3_target",
"label": "80/20 entry + 3x ATR trail AND S/R target take-profit",
"description": "Production trail plus a take-profit at the S/R target, first one wins.",
},
)
def _exit_policy_variants() -> tuple[dict, ...]:
"""The exit book. Research take-profit rows only when explicitly opted in, so
the default report stays identical to the shipped baseline."""
if os.getenv("BACKTEST_RESEARCH_EXITS", "").strip().lower() in {"1", "true", "yes", "on"}:
return EXIT_POLICY_VARIANTS + RESEARCH_EXIT_POLICY_VARIANTS
return EXIT_POLICY_VARIANTS
PORTFOLIO_MONITOR_LOOKBACKS: tuple[dict, ...] = (
{"lookback": "6m", "label": "6 months", "days": 183},
@@ -1812,7 +1927,7 @@ def _exit_policy_sims(
rows: list[dict] = []
ranking_key = str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"])
for cfg in EXIT_POLICY_VARIANTS:
for cfg in _exit_policy_variants():
sim = _simulate_portfolio(
candidates,
prices,
@@ -1843,6 +1958,235 @@ def _lookback_start(max_ord: int | None, days: int | None) -> date | None:
return date.fromordinal(max_ord - days)
# The R:R floor is the last un-swept knob in the live gate: `gate_ablation` shows
# removing it halves expectancy, but the *level* (prod: 2.0) was hand-set in Admin
# and never tuned. Sweep it against portfolio Sharpe, not per-setup expectancy.
MIN_RR_SWEEP_VALUES: tuple[float, ...] = (0.0, 1.2, 1.5, 1.75, 2.0, 2.25, 2.5, 3.0, 4.0)
def _min_rr_sweep_enabled() -> bool:
return os.getenv("BACKTEST_MIN_RR_SWEEP", "").strip().lower() in {"1", "true", "yes", "on"}
def _meets_core_at(cand: dict, activation: dict, min_rr: float) -> bool:
"""Recompute the live gate's ``meets_core`` for a candidate at a different
R:R floor, from stored fields, mirroring ``qualification.setup_qualifies``.
The live-R:R freshness check is skipped (it needs a current price, which
historical candidates don't carry) and the momentum percentile is applied
separately — exactly as ``core_config`` does when meets_core is first built.
"""
if cand["rr"] < min_rr:
return False
primary_prob = cand.get("primary_prob")
if primary_prob is None or float(primary_prob) < MIN_TARGET_PROBABILITY:
return False
if (cand["confidence"] or 0.0) < float(activation.get("min_confidence", 0.0)):
return False
if activation.get("exclude_neutral"):
action_direction = _action_direction(cand.get("action"))
if action_direction == "neutral" or action_direction != cand["direction"]:
return False
if activation.get("require_high_conviction") and (
(cand.get("action") or "") not in HIGH_CONVICTION_ACTIONS
):
return False
if activation.get("exclude_conflicts") and (cand.get("risk_level") or "") != "Low":
return False
return True
def _min_rr_sweep(
candidates: list[dict],
prices: dict[str, tuple],
_spy_closes: dict[date, float] | None,
activation: dict,
threshold: float,
hold_days: int,
live_exit_policy: dict | None = None,
) -> dict:
"""Portfolio economics of the production book at each R:R floor.
Graded on Sharpe/CAGR/DD under the real exit — not per-setup expectancy —
because that is the metric every other promotion decision used.
"""
strategy = next((s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")), None)
if strategy is None:
return {}
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
if entry_cfg is None:
return {}
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
)
row_hold_days = int(live_exit_policy.get("hold_days", hold_days))
trail_multiplier = float(live_exit_policy.get("atr_multiplier", ATR_TRAIL_MULTIPLIER))
live_min_rr = float(activation.get("min_rr", 0.0))
live_qualified = sum(1 for c in candidates if c.get("qualified"))
# When a holdout split is set, sweep on the TEST window only. A threshold
# picked off a full-history curve is fitted to that curve; the only way to
# know whether the peak is real is to look for it in data the choice never saw.
sweep_start = _holdout_split()
rows: list[dict] = []
for min_rr in MIN_RR_SWEEP_VALUES:
def qualified_fn(c: dict, min_rr: float = min_rr) -> bool:
if not _meets_core_at(c, activation, min_rr):
return False
if threshold <= 0:
return True
if c["direction"] == "short":
return False
mp = c.get(PRODUCTION_PERCENTILE_KEY)
return mp is not None and mp >= threshold
n_qualified = sum(1 for c in candidates if qualified_fn(c))
sim = _simulate_portfolio(
candidates,
prices,
_spy_closes,
exit_policy,
row_hold_days,
qualified_fn=qualified_fn,
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
start_date=sweep_start,
)
if sim is None:
continue
sim.pop("equity_curve", None)
sim.pop("benchmark_curve", None)
rows.append({
"min_rr": min_rr,
"is_live": abs(min_rr - live_min_rr) < 1e-9,
"qualified_setups": n_qualified,
**sim,
})
# Parity self-check: at the live floor the reconstructed gate must reproduce
# the production qualified set exactly. If it doesn't, the sweep is measuring
# some other gate and every row below is worthless.
live_row = next((r for r in rows if r["is_live"]), None)
reproduces = live_row is not None and live_row["qualified_setups"] == live_qualified
return {
"live_min_rr": live_min_rr,
"live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces,
"exit_policy": exit_policy,
"entries_from": sweep_start.isoformat() if sweep_start else None,
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
"rows": rows,
"note": (
"Portfolio economics of the production book at each activation R:R floor "
"(all other gate floors held at their live values). `reproduces_production_gate` "
"must be true: the row at the live floor has to rebuild the exact qualified set, "
"otherwise the sweep is grading a gate we don't run. Set BACKTEST_HOLDOUT_SPLIT "
"to sweep on the held-out window instead — a threshold read off the full-history "
"curve is fitted to it."
),
}
def _holdout_split() -> date | None:
"""Train/test split date for out-of-sample validation, e.g.
BACKTEST_HOLDOUT_SPLIT=2024-07-01. Off by default."""
raw = os.getenv("BACKTEST_HOLDOUT_SPLIT", "").strip()
if not raw:
return None
try:
return date.fromisoformat(raw)
except ValueError:
return None
def _holdout_evaluation(
candidates: list[dict],
prices: dict[str, tuple],
_spy_closes: dict[date, float] | None,
hold_days: int,
split: date,
live_exit_policy: dict | None = None,
) -> dict:
"""The production strategy simulated on entries BEFORE the split (train) and
on entries ON/AFTER it (test), as separate books.
The lookback rows in ``portfolio_monitor`` are NOT a holdout — they are nested
windows that all end today, so every one of them overlaps the data a rule was
chosen on. This does the real thing: the test window is disjoint from the
train window, so a rule settled on train has never seen it.
"""
strategy = next(
(s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production")),
None,
)
if strategy is None:
return {}
entry_cfg = _entry_variant_config(str(strategy["entry_variant"]))
if entry_cfg is None:
return {}
exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER
if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
)
row_hold_days = int(live_exit_policy.get("hold_days", hold_days))
trail_multiplier = float(
live_exit_policy.get("atr_multiplier", ATR_TRAIL_MULTIPLIER)
)
qualified_fn = (
None if strategy.get("use_live_config")
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
)
rows: list[dict] = []
for window, start, end in (
("train", None, split),
("test", split, None),
):
sim = _simulate_portfolio(
candidates,
prices,
_spy_closes,
exit_policy,
row_hold_days,
qualified_fn=qualified_fn,
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier,
start_date=start,
end_date=end,
include_curve=True,
)
if sim is None:
continue
rows.append({"window": window, "exit_policy": exit_policy, **sim})
return {
"split_date": split.isoformat(),
"strategy": strategy["strategy"],
"rows": rows,
"note": (
"Train = entries before the split; test = entries on/after it. The two "
"books are disjoint in entry date. Compare the TEST row across arms: a "
"rule chosen by looking at full history has already seen train."
),
}
def _portfolio_monitor(
candidates: list[dict],
prices: dict[str, tuple],
@@ -2433,6 +2777,8 @@ async def run_backtest(
strategy_variant_rows: list[dict] = []
exit_policy_rows: list[dict] = []
portfolio_monitor_report: dict | None = None
holdout_report: dict | None = None
min_rr_sweep_report: dict | None = None
try:
qual_symbols = sorted({
c["symbol"]
@@ -2481,6 +2827,17 @@ async def run_backtest(
candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy,
)
split = _holdout_split()
if split is not None:
holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy,
)
if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct,
hold_horizon, live_exit_policy=live_exit_policy,
)
except Exception:
logger.exception("Portfolio simulation failed")
@@ -2555,6 +2912,8 @@ async def run_backtest(
),
},
"portfolio_monitor": portfolio_monitor_report,
"holdout": holdout_report,
"min_rr_sweep": min_rr_sweep_report,
"signal_eval": _signal_evaluation(collected),
"signal_eval_note": (
"Cross-sectional rank-IC of price-only signals vs the forward "
+143
View File
@@ -0,0 +1,143 @@
# Research log — what we tested, what happened, what we kept
Every strategy question we've put to the backtest, in one table. The point is to
stop re-litigating settled questions: **if a row says "rejected", the experiment
was run and the data said no.** Detail lives in the linked docs and in
`reports/*.json` (all committed).
**The one-line summary of the whole platform:** it is a **long-only
cross-sectional momentum book** — buy the top quintile by beta-adjusted 12-1
momentum, tilt toward higher volatility, hold ≤ 10 names, cut at 1.5× ATR, then
trail at 3× ATR for up to 30 trading days. Everything else in the app (composite
score, S/R levels, sentiment, fundamentals) is **display or screening**, not edge.
---
## 1. What survived — the production strategy
| Component | Status | Why it's there |
|---|---|---|
| **Residual 12-1 momentum, top 20%, long-only** | **The edge.** Everything else is scaffolding | Only component with a measured cross-sectional IC. Promoted July 2026 |
| 80/20 residual-momentum / 6m-volatility rank | Ranking tilt | Buys ~2pp CAGR over momentum-only; costs ~6pp drawdown |
| 1.5× ATR initial stop | Real exit | Cuts losers fast |
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice |
---
## 2. Rejected — do not resurrect without new data
| # | Experiment | Result | Decision | Evidence |
|---|---|---|---|---|
| 1 | **S/R target as a take-profit** (exit at the target, with or without the trail) | Sharpe **2.04 → 1.47**, CAGR halved (50.4% → 28.9%). Win rate *rose* (37.5% → 40.0%) — the tell: it truncates the right tail | **Rejected.** The target must never become an exit | [sr-levels-and-exits.md](sr-levels-and-exits.md) · `backtest-20260712-sr-target-exit.json` |
| 2 | **Clear-air fallback** — synthesize a 3× ATR target so 52-week-high breakouts stop being vetoed by "no resistance above" | Looked *strictly better* in-sample (Sharpe 2.07, CAGR 62.3%, DD 20.1%) but **failed a real out-of-sample holdout**: Sharpe 2.78 → 2.45, higher drawdown | **Rejected.** Gate stays as-is | [sr-levels-and-exits.md](sr-levels-and-exits.md) · `backtest-20260712-holdout-*.json` |
| 3 | **Blanket S/R fallback** (any missing target, not just clear air) | Sharpe 1.82, per-setup expectancy 0.583 → 0.280 R | **Rejected.** 65% of what it admitted were ATR/R:R filter misses, which are actively bad | [sr-levels-and-exits.md](sr-levels-and-exits.md) |
| 4 | **Expected-value gate** (`min_expected_value` replacing the R:R + probability pair) | Structurally favoured distant lottery targets; selected *worse*-than-random setups | **Removed June 2026.** Settings dropped in migration 020 | migration 009, 020 |
| 5 | **Blue-sky projected targets** (invent a target above when none exists) | Dilutive under the ATR-trail exit | **Reverted July 2026.** Same root cause as #2 — better targets can't help when the exit ignores them | — |
| 6 | **SPY 200d-MA regime overlay** (block entries / go flat) | Halves return (315% → 138%), zero drawdown benefit | **Rejected.** The ATR trail already manages downside; the filter blocks the recovery entries that make the money | `backtest-20260708-regime-overlay.json` |
| 7 | **Short setups** | Fight the trend, drag expectancy | **Excluded** while the momentum gate is active | — |
| 8 | **Standalone volatility ranking** (high-vol 80, no momentum) | CAGR 31.6%, DD 34.8%, Sharpe 1.12 | **Rejected.** Vol is a *tilt*, not a signal | prod-baseline |
| 9 | **Low-volatility ranking** | CAGR 2.7%, Sharpe 0.29 | **Rejected.** No edge | prod-baseline |
| 10 | **Inverse-vol position sizing** | The apparent "win" was **mis-attributed**: the 20% notional cap bound on 95% of entries, so it measured concentration, not vol-sizing. Genuine inverse-vol cuts DD to 18.2% but costs ~58pp return at flat Sharpe | **Rejected** as edge; it's a risk-preference trade | `backtest-20260709-position-sizing*.json` |
| 11 | **FIP path-smoothness** as tie-breaker/filter | Non-monotonic within the qualified set; thinning the entry stream costs more compounding than the tilt returns | **Rejected as a filter** — but see §4, it's the strongest raw signal we've measured | — |
| 12 | **Fixed take-profit sweep** (R-multiples) | No interior optimum ever found — the best TP is "no TP" | **Rejected.** Momentum's edge lives in the right tail | `backtest_service.py:450` |
---
## 3. Tuned and confirmed — don't retest on this snapshot
A systematic single-variable sweep (July 2026) confirmed **every** production
setting. Re-running these against the same ~4-year snapshot is wasted compute and
invites overfitting.
| Knob | Verdict |
|---|---|
| ATR trail multiple {1.54.0} | **Keep 3.0** — ≤2.0 whipsaws out the right tail; ≥2.5 is a plateau |
| Momentum lookback (6-1, 3-1, 12-7 Novy-Marx, composites) | **Keep residual 12-1** — the others have IC ≈ 0 or weaker t-stats |
| Selection cutoff {70…90} × book size {10, 15, 20} | **Keep 80 × 10** — monotonically worse in both directions |
| Position sizing (equal-weight, inverse-vol, risk-% sweep) | **Keep 1% fixed-fractional** |
| Primary-target probability floor | **Keep 20%** — pruned lottery targets, 1,428 → 1,089 qualified, lifted Sharpe |
| Exit policy (hold / SMA50 / 20-day low / technical-40 / ATR trail) | **Keep 3× ATR trail** — best Sharpe (2.04) |
| **Activation R:R floor `min_rr`** (swept 2026-07-12) | **Keep 2.0** — best in-sample *and* out-of-sample. But it is a **spike, not a plateau** — see below |
### The `min_rr` sweep (2026-07-12)
`min_rr = 2.0` had been hand-set in Admin and **never swept** — the gate ablation only
tested the floor *on vs off*, never its level. Swept against portfolio Sharpe under the
real exit, with a parity self-check (`reproduces_production_gate: true` — the row at 2.0
rebuilds production's exact 1,089-setup qualified set).
Reports: `backtest-20260712-min-rr-sweep.json` (in-sample), `-oos.json` (test window only).
| min_rr | qualified | In-sample Sharpe / CAGR | **OOS** Sharpe / CAGR (entries ≥ 2024-07) |
|---|---|---|---|
| 0.0 (floor off) | 6636 | 1.98 / 58.5% | 2.02 / 66.2% |
| 1.2 (code default) | 3897 | 1.34 / 33.9% | 1.12 / 28.8% |
| 1.5 | 3127 | 1.20 / 29.6% | 1.12 / 28.8% |
| 1.75 | 1974 | 1.64 / 44.5% | 1.15 / 27.4% |
| **2.0 (live)** | 1089 | **2.04 / 50.4%** | **2.78 / 73.3%** |
| 2.25 | 577 | 1.64 / 31.8% | 1.71 / 31.9% |
| 2.5 | 286 | 1.67 / 29.0% | 0.68 / 8.7% |
| 3.0 | 89 | 1.09 / 9.1% | 0.87 / 5.0% |
**Verdict: keep 2.0.** It is the optimum in **both** windows, and the peak reproducing in
data it was never fitted to is real evidence — the one thing the clear-air experiment
couldn't show.
**But treat it as fragile, and do not nudge it.** Unlike the ATR trail (a plateau above
2.5), this is a **spike with a trough beside it**: ±0.25 costs ~0.4 Sharpe in-sample and
~1.6 Sharpe out-of-sample. A knob that sharp is not a robustly identified parameter, and
the curve is *bimodal* (floor-off is good, 1.21.75 is bad, 2.0 is good) — which is not
how a well-behaved threshold behaves. We got lucky: the hand-set value landed on the peak.
**Also worth knowing:** turning the floor **off entirely** is the second-best row in both
windows — nearly the same Sharpe with **substantially higher CAGR** (58.5% / 66.2%) and
more trades. If CAGR ever matters more than Sharpe here, "no R:R floor" is a live option,
and it would also sever the last dependency the *gate* has on the weak S/R detector.
---
## 4. Open leads
| Lead | Why it's interesting | Blocker |
|---|---|---|
| **`fip_id`** (information discreteness over the 12-1 window) | **Strongest cross-sectional signal measured on this universe** — IC 0.045, t = 2.91, correct sign | Doesn't improve *this* book (the momentum gate already captures it in-sample). Revisit when the universe broadens |
| **Broader universe** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Also where `fip_id` could become tradeable |
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time |
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
| **S/R detector quality** | POC/VAH/VAL computed then discarded; HVN = "any above-mean bin"; volume double-counted 1.48×; "touch" counts pass-throughs; no round numbers | Worth fixing for the levels users *see* — but it does **not** reach P&L, so don't justify it on returns |
---
## 5. Method rules learned the hard way
1. **Nested lookback windows are NOT out-of-sample.** The clear-air result (#2) was
clean, large, and consistent across five nested windows — and still died on a
proper train/test split by entry date. Use `BACKTEST_HOLDOUT_SPLIT`.
2. **Check what population an ablation actually admits.** The blanket fallback (#3)
looked like it tested the "resistance famine" hypothesis. It didn't — 65% of the
setups it let in were a different population entirely, and they drove the result.
3. **A rising win rate is a warning, not a win.** Both #1 and #12 raised the hit
rate while destroying returns. In a right-tailed strategy, "winning more often"
usually means you clipped the winners.
4. **The iron rule:** a signal earns its way into selection *only* through the
factor harness — |mean IC| ≳ 0.03, consistent sign, `reliable: true` (≥ 12
non-overlapping windows). Never let an unvalidated score gate setups.
---
## 6. Why we stay with the current strategy
Everything we've tried to add has either failed the backtest, failed
out-of-sample, or turned out to be measuring something other than what it claimed.
What's left is a boring, well-documented result: **cross-sectional momentum works;
the machinery around it mostly doesn't.**
The S/R engine, the composite score, the sentiment and fundamentals dimensions are
all still in the product — they make the app legible and are useful context for a
human — but none of them has a measured edge, and the platform is honest about
that in the UI (see the exit plan and base-rate panels on every setup card). The
one component that *does* have an edge is the momentum gate, and every knob on it
has been swept and confirmed.
The next real evidence is **forward**, not backward: the live paper-trade record.
+393
View File
@@ -0,0 +1,393 @@
# S/R levels: detection quality, the target exit, and the entry gate
**Date:** 2026-07-12
**Question that started it:** are our support/resistance levels built the way best
practice says they should be, and do we actually use them that way?
Short answer: the detector is weak against best practice, but its reach into P&L
runs entirely through the **entry gate** — not the exit. Honoring the target as a
take-profit was tested and is decisively worse. Whether the S/R-derived gate is
net-positive is the open question, tracked below.
---
## 1. How the levels are built today
`app/services/sr_service.py::detect_sr_levels`, over **all stored history**
(`query_ohlcv` with no date range — 5 years / ~1260 daily bars per ticker):
1. Candidates = volume-profile **HVN and LVN** bins + **pivot** swing highs/lows.
2. Strength = share of bars that "touched" the level, scaled so ~20% of bars → 100.
3. Nearby levels merged within 0.5%; tagged `support` if below spot, else `resistance`.
### Where that departs from best practice
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
| Gap | Evidence |
|---|---|
| **POC / VAH / VAL are computed then discarded.** `sr_service` reads only `hvn`/`lvn`. The canonical volume-profile levels never become S/R. | POC $148.32, VAH $230.45 — unused |
| **HVN = "any bin above the mean"**, so nearly every bin is a candidate. A real HVN is a *local peak* in the histogram. | 8 of 20 bins HVN, other 12 LVN → 73 levels, median spacing $2.44 (0.79% of spot) — a price grid, not detected structure |
| **HVN and LVN are scored and used identically**, though they encode opposite dynamics (acceptance vs. rejection). | both appended as plain candidates |
| **Volume is double-counted**: a bar's full volume is added to *every* bin it spans rather than distributed. | binned total = 1.48× true volume |
| **"Touch" = level fell inside the bar's range** — a pass-through counts the same as a rejection. Strength therefore measures *how central a price is in the 5-year range*, not how often price reversed there. | 21 of 73 levels pin at exactly 100 → the `sr_strength` magnet in the probability model is near-constant |
| **No recency decay, unbounded lookback.** | 35 AAPL "support" levels sit >35% below spot |
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
lacks is a **prominence filter** — AAPL yields 338 pivots over 1261 bars, one every
~3.7 bars.
### The structural problem: resistance famine
Levels are tagged relative to spot, so a stock near its highs has almost nothing
above it. Across all 504 tickers in the snapshot, grouped by proximity to the
52-week high:
| Population | Median resistance levels | % with <3 |
|---|---|---|
| **≥98% of 52w high — what the momentum gate buys** | **1** | **64%** |
| 9098% | 7 | 12% |
| <90% | 22 | 0% |
AAPL at $308: **71 support levels, 2 resistance levels.** 26 tickers have *zero*.
This matters because `_build_setups_at` does `if not targets: continue` — **no
resistance above ⇒ no long setup at all**, and `<3 targets` adds a
`target-availability` conflict. The detector is structurally most blind exactly
where the momentum edge is strongest. `recommendation_service.py:521` already says
so out loud: *"price is extended near highs (no resistance target above), so no
high-conviction long setup is available."*
---
## 2. Does the target even act as an exit? No.
Production runs `paper_exit_mode = "atr_trailing"` (`DEFAULT_EXIT_MODE`), and
`_atr_trailing_close(direction, entry, init_stop, atr_multiplier, hold_days, ...)`
**does not take `target` as a parameter**. Only the non-default `mode == "target"`
branch consults it.
So the S/R target's real causal role is:
1. the **entry gate**`rr ≥ 1.5`, primary-target `prob ≥ 20%`, `≥3 targets`, and
no-resistance-above ⇒ no setup;
2. the **displayed** target table.
It decides *whether you enter* and plays no part in *how you exit*.
---
## 3. Experiment: should the target be honored as a take-profit? — REJECTED
Dennis's proposal: keep the 3× ATR trail, but also take profit when the S/R target
is hit. This was not representable in the simulator (`exit_policy` is one string;
the existing `"target"` policy *replaces* the trail). Added `atr_trail3_target`,
which runs both — trade ends at whichever comes first.
Identical 80/20 momentum entry and qualification for every row — the exit is the only
policy that changes. (Trade counts still vary 303427, because exits free portfolio
slots at different times; the conclusion is robust to that.)
Report: `reports/backtest-20260712-sr-target-exit.json`
| exit policy | Sharpe | CAGR | MaxDD | trades | win% |
|---|---|---|---|---|---|
| `low20` | 2.04 | 53.2% | 21.9% | 305 | 37.7 |
| **`atr_trail3` (production)** | **2.04** | **50.4%** | **21.4%** | 320 | 37.5 |
| `hold` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
| `technical40` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
| `sma50` | 1.76 | 40.8% | 24.4% | 385 | 33.8 |
| `target` (take-profit, no trail) | 1.59 | 32.4% | 25.0% | 415 | 41.9 |
| **`atr_trail3_target` (trail + take-profit)** | **1.47** | **28.9%** | 23.5% | 427 | 40.0 |
**Decision: do not ship. The target must not become an exit.**
- Adding the take-profit to the trail: Sharpe **2.04 → 1.47**, CAGR **halved**
(50.4% → 28.9%), and drawdown got *worse* (21.4% → 23.5%). No risk compensation.
- **Win rate rose** (37.5% → 40.0%) — the tell. You win more often and earn far
less: the take-profit converts the few 5R/8R/15R runners into 1.5R wins while
every loser still costs a full 1R. Momentum's edge is that right tail.
- The combination (1.47) is worse than the take-profit alone (1.59): once upside is
capped at the target, the trail's benefit (riding a winner far past any target)
is gone but its cost (shakeouts on pullbacks) remains. Worst of both.
This confirms and extends the note at `backtest_service.py:450` — swept *fixed*
take-profits never found an interior optimum; the S/R target is no better.
Caveat: single in-sample run over full history. The effect is large (CAGR halved),
not marginal.
---
## 4. Open: is the S/R entry gate net-positive?
The target is now proven useless as an exit, so the gate is its **only**
justification — and the gate is what starves the momentum names.
Evidence *for* keeping it (`gate_ablation`, prod baseline): dropping the R:R floor
**halves per-setup expectancy**, 0.583 → 0.301 net avg R (`momentum_only` = 0.345).
Inside the tradeable pool the S/R-derived R:R floor is doing real selection — it
favors names whose nearest resistance is far away, i.e. clear air above.
But that ablation **cannot see the famine**: it re-qualifies candidates that already
exist, and starved names never enter the candidate set (`if not targets: continue`).
So "remove the floors" is the wrong test — it just reproduces the rows above.
**The right test changes target *generation***: when a direction has no S/R target,
synthesize one at k×ATR so the name becomes a candidate. One variable moves; the
previously-vetoed names now trade. Implemented behind
`BACKTEST_ATR_TARGET_FALLBACK=<k>` (k=3 matches the trail; rr = 3/1.5 = 2.0, which
clears the 1.5 floor, and an aligned momentum name lands ~34% probability, clearing
the 20% floor).
Read the result against the production baseline (`atr_trail3`, Sharpe **2.04**):
- **> 2.04** → the veto costs money; let the breakouts in.
- **≈ 2.04** → famine is a wash; a detector rewrite is cosmetic.
- **< 2.04** → the veto earns its keep by keeping us out of over-extended names, and
S/R gating is vindicated.
### Result: the veto EARNS ITS KEEP. Keep S/R in the gate.
Treatment `reports/backtest-20260712-sr-gate-ablation-treatment.json` vs control
`reports/backtest-20260711-prod-baseline.json`. Admitting the vetoed names is a big
change: **qualified setups go 1089 → 4230 (~4×)**.
Production exit (`atr_trail3`), full history:
| | Sharpe | CAGR | MaxDD | Calmar | trades |
|---|---|---|---|---|---|
| control (veto ON) | **2.04** | 50.4% | 21.4% | 2.36 | 320 |
| treatment (veto OFF) | **1.82** | **58.6%** | 21.0% | **2.79** | 404 |
Full history alone looks like a genuine trade-off — more return, more volatility,
Sharpe down but Calmar up. **The lookback split is what settles it:**
| window | control (veto ON) | treatment (veto OFF) |
|---|---|---|
| **6m** | **Sharpe 2.87, CAGR 76.6%, DD 8.1%** | Sharpe 1.50, CAGR 53.6%, **DD 15.8%** |
| **1y** | **Sharpe 2.47, CAGR 66.8%, DD 8.8%** | Sharpe 1.69, CAGR 66.0%, **DD 15.8%** |
| 3y | Sharpe 2.12, CAGR 52.3%, DD 17.7% | Sharpe 2.11, CAGR **75.9%**, DD 21.0% |
| 5y | Sharpe 1.83, CAGR 38.8%, DD 21.4% | Sharpe 1.62, CAGR 44.9%, DD 21.0% |
| all | Sharpe 2.04, CAGR 50.4%, DD 21.4% | Sharpe 1.82, CAGR 58.6%, DD 21.0% |
Per-setup expectancy: `all_floors` net avg R **0.583 → 0.280**.
**Decision: do not ship the fallback. Keep the gate as it is.** The flat 3×ATR
fallback is worse on Sharpe and on per-setup expectancy, and production needs no
further defense than that.
### But be careful what this run does and does not prove
**It does not isolate the famine hypothesis.** The fallback fires on *any* empty
`generate_targets` result — and that includes the ATR/R:R distance filters in
`TargetGenerator` (target closer than 1 ATR, or beyond `max_atr_multiple`), not just
"no resistance above." Measured at the last bar across 502 tickers, of the long
setups the fallback admits:
- **26 (35%)** have genuinely *no resistance above* — the clear-air famine case
- **49 (65%)** *do* have resistance above; the ATR/R:R filters rejected it — **a
different population entirely**
That matches the report's own tell: qualified setups exploded **1089 → 4230 (~4×)**
while candidates rose only ~16%. So the degradation may be driven mostly by that
65%, and the clear-air breakouts this investigation was *about* are a minority of
what was admitted.
**What the run actually supports:** *"a flat 3×ATR fallback for all S/R-starved
setups degrades performance."* It does **not** support the stronger claim that a
stock in clear air is a worse risk-adjusted buy, or that the veto is functioning as
an over-extension filter. That mechanism is unproven.
**The window split is also less clean than it first looks.** The verdict rests on the
two *smallest* samples — 6m (n=30) and 1y (n=72) — where Sharpe 2.87 is
noise-dominated. The statistically sturdier 3y window (n=230 → 303) shows
**equal Sharpe (2.12 vs 2.11) with substantially higher treatment CAGR (52.3% →
75.9%)**. Full-history Calmar also favors the treatment (2.79 vs 2.36). So the result
is metric- and window-dependent; only the flat-fallback rejection is solid.
**Second contamination (by design):** the fallback gives every admitted name the same
`rr = 3/1.5 = 2.0`, so there is no R:R discrimination *within* the admitted set.
**To actually test the famine**, the fallback must fire *only* when there is no
resistance above (not on ATR/R:R filter misses). That is the clear-air run below.
---
## 4b. The clean test: fire the fallback ONLY in clear air — **the veto DOES cost money**
`BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` restricts the fallback to setups with no S/R
level ahead at all, excluding the 65% that were merely ATR/R:R distance-filter
misses. Same whole-portfolio simulation, same 10-slot book, same momentum ranking.
Report: `reports/backtest-20260712-sr-gate-ablation-clearair.json`
**Production exit (`atr_trail3`), full history:**
| arm | Sharpe | CAGR | MaxDD | Calmar | trades |
|---|---|---|---|---|---|
| control — veto ON (production) | 2.04 | 50.4% | 21.4% | 2.36 | 320 |
| blanket fallback (contaminated) | 1.82 | 58.6% | 21.0% | 2.79 | 404 |
| **clear-air-only fallback** | **2.07** | **62.3%** | **20.1%** | **3.10** | 363 |
**Strictly better than production on all three headline metrics at once** — higher
Sharpe, ~12 points more CAGR, *and* lower drawdown. Qualified setups 1089 → 2072
(vs. 4230 for the blanket version).
**By lookback** (production strategy):
| window | control (veto ON) | clear-air fallback |
|---|---|---|
| 6m (n=30→38) | Sharpe 2.87, CAGR 76.6%, DD **8.1%** | Sharpe 2.21, CAGR 78.8%, DD 13.0% |
| 1y (n=72→86) | Sharpe 2.47, CAGR 66.8%, DD **8.8%** | Sharpe 2.28, CAGR **87.6%**, DD 12.9% |
| **3y** (n=230→270) | Sharpe 2.12, CAGR 52.3%, DD 17.7% | **Sharpe 2.18, CAGR 68.6%, DD 14.3%** |
| **5y** (n=320→363) | Sharpe 1.83, CAGR 38.8%, DD 21.4% | **Sharpe 1.85, CAGR 47.6%, DD 20.1%** |
| **all** | Sharpe 2.04, CAGR 50.4%, DD 21.4% | **Sharpe 2.07, CAGR 62.3%, DD 20.1%** |
In every statistically sturdy window (3y, 5y, all) the clear-air fallback wins on
Sharpe, CAGR **and** drawdown. The short windows (6m n=30, 1y n=72 — noise-dominated)
favor control on Sharpe/DD while the treatment still earns more (1y CAGR 66.8% →
87.6%).
**This reverses §4 and confirms the hypothesis that opened the investigation.** The
S/R veto on clear-air names *was* costing money; the blanket run masked it because
the 65% loophole population (ATR/R:R filter misses) is genuinely bad and dominated
the result. Isolate the two, and they pull in opposite directions:
- clear-air names (no resistance above): **portfolio-accretive**
- ATR/R:R distance-filter misses: **portfolio-destructive**
Per-setup expectancy is consistent with this: net avg R `all_floors` — control 0.583,
clear-air 0.402, blanket 0.280. The admitted clear-air setups are individually a bit
weaker, but they carry the highest momentum ranks, so they win book slots and deliver
outsized portfolio returns.
**Caveats before shipping:** in-sample, single snapshot; the fallback still assigns a
constant `rr = 2.0` to every admitted name (no discrimination within the set). Needs
an out-of-sample run before production — see §4c, which is where it comes undone.
---
## 4c. Out-of-sample holdout — **the §4b result does NOT survive**
Everything in §4b is in-sample: the rule was chosen by looking at the same 5 years it
was then graded on. The `portfolio_monitor` lookbacks (6m/1y/3y/5y) are **not** a
holdout — they are nested windows all ending today, so each one overlaps the data the
idea came from.
Real split (`BACKTEST_HOLDOUT_SPLIT=2024-07-01`, production strategy, disjoint books):
- **train** = entries before 2024-07-01 (~3y)
- **test** = entries on/after 2024-07-01 (~2y, never informed the rule)
Reports: `reports/backtest-20260712-holdout-control.json`,
`reports/backtest-20260712-holdout-clearair.json`
| window | arm | Sharpe | CAGR | MaxDD | trades |
|---|---|---|---|---|---|
| train (2022-06 → 2024-08) | control | 1.31 | 29.6% | 21.4% | 174 |
| train | **clear-air** | **1.63** | **42.6%** | **20.1%** | 191 |
| **test** (2024-07 → 2026-07) | **control** | **2.78** | 73.3% | **11.7%** | 150 |
| **test** | clear-air | 2.45 | **83.0%** | 14.3% | 176 |
> **Harness bug, found and fixed 2026-07-12.** The train row first reported Sharpe 0.95 /
> CAGR 14.6% — wrong. Its equity curve ran to the *end of the data* while its entries
> stopped at the split, so the book sat in flat cash for two years and deflated its own
> metrics. `_simulate_portfolio` now truncates the calendar to `hold_days` after the last
> entry whenever `end_date` is set. **The verdict is unaffected** — it rests on the test
> row, whose entries and curve both start at the split and were always clean. But the
> broken numbers *looked* like a result, and nearly produced a false conclusion ("the
> first half of the sample was mediocre"). Corrected numbers above.
**In train the clear-air rule wins on every metric. Out of sample it does not.** On
the held-out two years it delivers **more raw return (+9.7pp CAGR)** but at
**lower Sharpe (2.78 → 2.45)** and **higher drawdown (11.7% → 14.3%)**.
So the §4b headline — *"strictly better on all three metrics"* — was **an in-sample
artifact.** Out of sample the rule is not a free win; it is a **risk/return trade**:
it buys extra return by taking more risk, and on a risk-adjusted basis it is slightly
*worse* than production.
**Decision: do NOT ship the clear-air fallback.** This project's decision metric is
Sharpe throughout (every ranking in the report, and the 2026-07-10 primary-target A/B
was accepted on Sharpe 1.51 → 2.00). By that standard the honest read of the only
uncontaminated evidence is *no improvement*.
Notes for anyone revisiting:
- Both arms show a large regime shift (train Sharpe ~1.31.6, test Sharpe ~2.52.8) —
the test window was simply a much better market. That is why *relative* comparison
within a window is the only valid read.
- n = 150/176 in test is decent but not large; the Sharpe gap (0.33) is not
overwhelming. This is "not confirmed," not "definitively refuted."
- The famine hypothesis is therefore **real but not exploitable as tried**: the
clear-air names do add return (consistently, in both train and test), but the flat
3×ATR target admits them at a risk cost that eats the risk-adjusted benefit. A
better target model for those names (§ next runs) is the remaining avenue.
A wholesale "ATR target for everyone" variant was deliberately *not* run as the
headline: with a fixed k×ATR target and a 1.5×ATR stop, `rr = k/1.5` is constant
across every name, which erases the very selection the 0.301 credits. A loss there
would be uninterpretable.
---
## 5. Reproducing
Both research paths are **off by default** — the default report is byte-identical to
the shipped baseline (5 exit rows, no fallback), and the full unit suite including
the backtest↔prod parity guard passes.
```bash
# Exit book incl. the rejected take-profit rows
BACKTEST_RESEARCH_EXITS=1 python scripts/run_backtest_snapshot.py \
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
# S/R gate ablation: synthesize a 3xATR target where S/R offers none
BACKTEST_ATR_TARGET_FALLBACK=3 python scripts/run_backtest_snapshot.py \
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
```
Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
`forkserver` there and silently falls back to a single thread without it.
---
## 6. Standing decisions
**Measured:**
1. **The target must not be an exit.** Tested, rejected, decisively — Sharpe
2.04 → 1.47, CAGR halved. Momentum's edge is the right tail; a take-profit
truncates it. (§3)
2. **Do NOT ship the clear-air fallback — it failed out-of-sample.** In-sample it
looked strictly better (Sharpe 2.04 → 2.07, CAGR 50.4% → 62.3%, DD 21.4% → 20.1%),
but on a genuine holdout (entries after 2024-07-01, never seen by the rule) it is
**worse on Sharpe (2.78 → 2.45) and Calmar, better only on raw CAGR (+9.7pp)**. The
in-sample "free win" was an artifact. **Production gate stays as-is.** (§4b, §4c)
3. **The famine is real, but not exploitable as tried.** Clear-air names *do* add
return consistently (train and test) — the veto genuinely leaves money on the
table. But a flat 3×ATR target admits them at a risk cost that cancels the
risk-adjusted benefit. (§4c)
4. **Do NOT relax the veto indiscriminately.** The ATR/R:R distance-filter misses
(65% of a blanket fallback) are portfolio-destructive and swamp everything —
Sharpe 1.82, net avg R 0.280. The two populations pull in opposite directions and
must be separated. (§4)
**Reasoned, not measured — treat as hypotheses:**
5. **The detector's flaws probably don't reach P&L directly.** *No run ever varied
detection quality* — "good S/R vs bad S/R → P&L" has never been measured. Fix the
§1 gaps for the *displayed* levels and the UX; do not promise a return improvement.
**Method note (the expensive lesson):** the in-sample result in §4b was clean,
large, consistent across five nested windows — and still didn't survive a holdout.
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
**Next runs, if picked back up:**
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
is the one avenue left: the return is demonstrably there (§4c), it's the flat target
that makes it too expensive in risk. Grade on the §4c holdout, not full history.
- Sweep **k** (fallback distance); only k=3 was tried. Grade on the holdout.
- A **volatility-aware** admission rule for clear-air names — the OOS failure is a
drawdown/vol story (11.7% → 14.3%), so sizing them down may recover the Sharpe.
@@ -0,0 +1,73 @@
import type { BaseRates } from '../../lib/baseRates';
/**
* What actually happens to trades like this one, measured under the real exit
* policy in the backtest. This is the honest replacement for the per-target
* "probability", which estimates the odds of touching a level the trade never
* exits at. Note `target` is absent from the exit mix — by construction.
*/
export function BaseRatesPanel({ rates }: { rates: BaseRates }) {
return (
<details className="rounded-xl border border-white/[0.07] bg-white/[0.02] px-3 py-2">
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
What usually happens · {rates.trades} backtested trades ({rates.lookbackLabel})
</summary>
<div className="mt-2.5 flex flex-wrap gap-x-5 gap-y-1.5 text-[11.5px]">
<span className="text-gray-500">
Win rate <span className="num text-gray-200">{rates.winRate.toFixed(0)}%</span>
</span>
{rates.avgHoldDays != null && (
<span className="text-gray-500">
Avg hold <span className="num text-gray-200">{rates.avgHoldDays.toFixed(0)}d</span>
</span>
)}
{rates.bestR != null && (
<span className="text-gray-500">
Best <span className="num text-emerald-300">+{rates.bestR.toFixed(1)}R</span>
</span>
)}
{rates.worstR != null && (
<span className="text-gray-500">
Worst <span className="num text-red-300">{rates.worstR.toFixed(1)}R</span>
</span>
)}
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-500">
Most trades lose a little; a few win big. That asymmetry <em>is</em> the edge which is why
there is no take-profit.
</p>
{rates.exits.length > 0 && (
<div className="mt-2.5 border-t border-white/[0.05] pt-2">
<p className="num mb-1.5 text-[10px] uppercase tracking-[0.16em] text-gray-500">how they ended</p>
<div className="flex h-1.5 overflow-hidden rounded-full bg-white/[0.05]">
{rates.exits.map((e) => (
<div
key={e.reason}
style={{ width: `${e.share * 100}%` }}
className={
e.reason === 'stop'
? 'bg-red-400/60'
: e.reason === 'trailing_stop'
? 'bg-emerald-400/60'
: 'bg-gray-500/60'
}
title={`${e.label}: ${e.count} trades`}
/>
))}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-gray-500">
{rates.exits.map((e) => (
<span key={e.reason}>
{e.label} <span className="num text-gray-300">{(e.share * 100).toFixed(0)}%</span>
</span>
))}
<span className="text-gray-600">target 0%</span>
</div>
</div>
)}
</details>
);
}
@@ -0,0 +1,62 @@
import type { ExitPlan } from '../../lib/exitPlan';
import { formatPrice } from '../../lib/format';
/**
* The exit rules that will actually close this trade.
*
* Deliberately sits *above* the levels ladder: the levels are context, this is
* the plan. Before this existed the card showed a "Target" with the same visual
* weight as the entry, implying a take-profit that the live exit never fires.
*/
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
const isLong = direction === 'long';
return (
<div className="mt-3 rounded-xl border border-white/[0.07] bg-white/[0.02] p-3">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
</div>
<dl className="mt-2.5 grid gap-x-4 gap-y-1.5 text-[11.5px] sm:grid-cols-2">
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Initial stop</dt>
<dd className="num text-gray-200">
{formatPrice(plan.initialStop)}{' '}
<span className="text-gray-600">(1R = {formatPrice(plan.riskPerShare)}/sh)</span>
</dd>
</div>
{plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && (
<>
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Trail takes over</dt>
<dd className="num text-gray-200">
{isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
<dt className="text-gray-500">Then it trails</dt>
<dd className="num text-gray-200">
{formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close
</dd>
</div>
</>
)}
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Max hold</dt>
<dd className="num text-gray-200">{plan.maxHoldDays} trading days</dd>
</div>
</dl>
{!plan.honorsTarget && (
<p className="mt-2.5 border-t border-white/[0.05] pt-2 text-[11px] leading-relaxed text-gray-500">
There is <span className="text-gray-400">no take-profit</span>. Winners are ridden until the
trailing stop is hit that&rsquo;s where the strategy&rsquo;s edge comes from, so hitting a level
below is not a reason to sell. The levels shown below are screening context, not exits.
</p>
)}
</div>
);
}
@@ -10,7 +10,13 @@ import { useMarketRegime } from '../../hooks/useMarketRegime';
import { isCounterTrend } from '../../lib/regime';
import { primaryTargetProbability } from '../../lib/qualification';
import { PriceRail } from '../charts/horizon';
import type { MarketRegime } from '../../lib/types';
import type { ExitPolicy, MarketRegime } from '../../lib/types';
import { deriveExitPlan, driftInR } from '../../lib/exitPlan';
import { productionBaseRates } from '../../lib/baseRates';
import { useExitPolicy } from '../../hooks/usePaperTrades';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { ExitPlanPanel } from './ExitPlanPanel';
import { BaseRatesPanel } from './BaseRatesPanel';
interface RecommendationPanelProps {
symbol: string;
@@ -32,44 +38,42 @@ function daysUntil(iso: string): number | null {
return Math.ceil((t - Date.now()) / 86_400_000);
}
/** Earnings within the ~30-day target horizon can gap price through stop/target. */
/** Earnings within the ~30-day hold horizon can gap price through the stop. */
const EARNINGS_HORIZON_DAYS = 30;
/**
* How far current price has drifted from the setup's entry. A setup whose
* entry is far from the live price (price already ran toward target, or fell
* through the stop) is stale — entering now changes the risk/reward.
* How far price has drifted from the scan entry, measured in R (the initial risk
* distance). R is the right unit: the stop sits 1R away and the trailing exit is
* denominated in R too.
*
* This used to judge staleness by progress toward the *target*, and declared a
* setup "played out" once price reached it. Under the live trailing exit that is
* backwards — reaching a level is the good case and the trade keeps running. The
* only thing that invalidates a setup is price through the stop; running past the
* entry just means you'd be chasing (a wider effective stop), which is a warning,
* not a death sentence.
*/
function entryDrift(setup: TradeSetup, currentPrice?: number) {
if (currentPrice == null || !setup.entry_price) return null;
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price;
// Judge staleness by how much of the entry→target distance is already gone,
// not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one.
const span = Math.abs(setup.target - setup.entry_price);
const moved = Math.abs(currentPrice - setup.entry_price);
const progressPct = span > 0 ? (moved / span) * 100 : 0;
const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
let status: 'fresh' | 'stale' | 'invalidated' = 'fresh';
const r = driftInR(setup, currentPrice);
const beyondStop =
setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
let status: 'fresh' | 'extended' | 'invalidated' = 'fresh';
if (beyondStop) status = 'invalidated';
else if (towardTarget && progressPct > 33) status = 'stale';
else if (!towardTarget && progressPct > 33) status = 'stale';
return { pct, progressPct, towardTarget, status };
else if (r != null && r >= 1) status = 'extended';
else if (r != null && r <= -0.5) status = 'extended';
return { pct, r, status };
}
/**
* A stored setup is the latest for its direction. When price has run to/past the
* target (played out) or through the stop (invalidated), there is no fresh setup
* — the card and the ticker-level header should say so rather than present a
* stale actionable recommendation. Returns null when there's no live price.
* The only state with no tradeable setup left: price has gone through the stop.
* Returns null when there's no live price.
*/
function notActionableState(setup: TradeSetup, currentPrice?: number) {
if (currentPrice == null) return null;
const drift = entryDrift(setup, currentPrice);
const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target;
const invalidated = drift?.status === 'invalidated';
if (!playedOut && !invalidated) return null;
return { playedOut, invalidated };
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
return { invalidated: true };
}
function riskClass(risk: TradeSetup['risk_level']) {
@@ -106,25 +110,39 @@ function Chip({ children }: { children: React.ReactNode }) {
type Target = NonNullable<TradeSetup['targets']>[number];
function TargetTable({ setup, selectedPrice, onSelect }: {
function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
setup: TradeSetup;
selectedPrice: number;
onSelect: (target: Target) => void;
/** True only when the live exit policy actually takes profit at a level. */
honorsTarget: boolean;
}) {
if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
return <p className="text-xs text-gray-500">No overhead levels detected.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" role="radiogroup" aria-label="Choose the target for the rail and paper trade">
<table
className="w-full text-xs"
role="radiogroup"
aria-label={
honorsTarget
? 'Choose the take-profit level for the rail and paper trade'
: 'Choose a level to preview on the rail (does not affect the exit)'
}
>
<thead>
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
<th className="py-2 pr-3">Classification</th>
<th className="py-2 pr-3">Price</th>
<th className="py-2 pr-3">Band</th>
<th className="py-2 pr-3">Level</th>
<th className="py-2 pr-3">Distance</th>
<th className="py-2 pr-3">R:R</th>
<th className="py-2">Probability</th>
<th className="py-2 pr-3" title="Reward-to-risk if the trade were exited at this level. Used by the activation gate — not an exit.">
Gate R:R
</th>
<th className="py-2" title="Modelled odds of price TOUCHING this level within ~30 days. Not the odds of the trade winning — the trade does not exit here.">
Touch odds
</th>
</tr>
</thead>
<tbody>
@@ -170,13 +188,14 @@ function TargetTable({ setup, selectedPrice, onSelect }: {
);
}
function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, onSelectPrice }: {
function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, selectedPrice, onSelectPrice }: {
setup?: TradeSetup;
action?: TradeSetup['recommended_action'];
currentPrice?: number;
risk: RiskSettings;
regime?: MarketRegime;
/** Controlled target selection (lifted so the candlestick chart can follow). */
exitPolicy?: ExitPolicy;
/** Controlled level selection (lifted so the candlestick chart can follow). */
selectedPrice?: number | null;
onSelectPrice?: (price: number) => void;
}) {
@@ -194,12 +213,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
const prob = primaryTargetProbability(setup);
// When price has run to/past the target (played out) or through the stop
// (invalidated), there is no fresh setup — show a plain "no current setup"
// state instead of an actionable card with no reward left.
const inactive = notActionableState(setup, currentPrice);
const invalidated = inactive?.invalidated ?? false;
const notActionable = inactive != null;
// The real exit rules. `honorsTarget` is false under the production policy —
// the level ladder below is context, not a menu of exits.
const exitPlan = deriveExitPlan(setup, exitPolicy);
const honorsTarget = exitPlan?.honorsTarget ?? false;
// Only price through the stop leaves no tradeable setup.
const notActionable = notActionableState(setup, currentPrice) != null;
const createTrade = useCreatePaperTrade();
const [taking, setTaking] = useState(false);
@@ -239,7 +259,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
entry_price: takeEntry,
shares: takeShares,
stop_loss: setup.stop_loss,
target: takeTarget,
// Only a real choice when the exit honors it. Otherwise record the
// setup's own primary level, so the stored value doesn't silently depend
// on which row the user happened to click while exploring the chart.
target: honorsTarget ? takeTarget : setup.target,
},
{ onSuccess: () => setTaking(false) },
);
@@ -254,13 +277,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
<span className="num ml-auto text-xs text-gray-500">
now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''} · last target {formatPrice(setup.target)}
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
</span>
</div>
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
{invalidated
? `The last ${dir} setup is invalidated — price (${formatPrice(currentPrice!)}) has passed the stop (${formatPrice(setup.stop_loss)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`
: `The last ${dir} setup has played out — price (${formatPrice(currentPrice!)}) is at or past the target (${formatPrice(setup.target)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`}
The last {dir} setup is invalidated price ({formatPrice(currentPrice!)}) has passed the stop
({formatPrice(setup.stop_loss)}). No fresh {dir} setup right now; the scanner surfaces a new one
when it forms.
</p>
</div>
);
@@ -279,10 +302,23 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
preferred
</span>
)}
{setup.momentum_percentile != null && (
<span
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
>
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
</span>
)}
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
<Chip>R:R {activeRR.toFixed(1)}:1</Chip>
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
{selected && !selected.is_primary && <Chip>custom target</Chip>}
<span
className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-500"
title="Gate metrics: the R:R and touch-odds of the selected level are what admitted this setup through the activation gate. They are NOT forecasts of this trade — it does not exit at that level."
>
gate · R:R {activeRR.toFixed(1)}:1
{activeProb != null && ` · touch ${Math.round(activeProb)}%`}
</span>
{selected && !selected.is_primary && <Chip>custom level</Chip>}
<span className="ml-auto flex flex-wrap items-center gap-3">
{sizing ? (
<span
@@ -323,28 +359,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
({regime.benchmark ?? 'SPY'}). Lower odds size down or wait for confirmation.
</p>
)}
{drift && drift.status === 'invalidated' && (
<p className="text-[11px] text-red-300">
Price ({formatPrice(currentPrice!)}) is past the stop this setup is invalidated.
</p>
)}
{drift && drift.status === 'stale' && (
{/* No 'invalidated' branch here: that state returns the "no current
setup" card above, so it can never reach this block. */}
{drift && drift.status === 'extended' && drift.r != null && (
<p className="text-[11px] text-amber-400">
{drift.towardTarget
? `${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.`
: `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
{drift.r >= 1
? ` Price has run ${drift.r.toFixed(1)}R past the scan entry (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%). Entering now means chasing — your stop sits further away, so the same dollar risk buys fewer shares.`
: `⚠ Price has drifted ${Math.abs(drift.r).toFixed(1)}R toward the stop (${drift.pct.toFixed(1)}%) — the entry is stale.`}
</p>
)}
</div>
)}
{activeProb != null && activeProb < 15 && (
<p className="mt-2.5 text-[11px] text-amber-400">
This target has only a {Math.round(activeProb)}% probability pick a nearer one from the target list below.
</p>
)}
{/* The setup, spatially — stop/entry/now hold still, the *selected*
target moves along a scale that always spans the whole ladder */}
level moves along a scale that always spans the whole ladder */}
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
@@ -354,6 +382,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
scaleTo={(setup.targets ?? []).map((t) => t.price)}
/>
{/* The rules that actually close the trade. Above the ladder on purpose:
this is the plan, the levels below are only context. */}
{exitPlan && <ExitPlanPanel plan={exitPlan} direction={setup.direction} />}
{/* Take dialog — portaled overlay so the panel structure stays put */}
{taking && createPortal(
<div
@@ -370,10 +402,22 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
<span className="num ml-auto text-[10px] uppercase tracking-[0.16em] text-gray-500">paper trade</span>
</div>
<p className="num mt-1.5 text-[11px] text-gray-500">
stop {formatPrice(setup.stop_loss)} · target {formatPrice(takeTarget)}
stop {formatPrice(setup.stop_loss)}
{honorsTarget && <> · take profit {formatPrice(takeTarget)}</>}
{sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}</>}
</p>
{/* The exit is the trailing stop, not a target. Say so here, where the
user is actually committing — this dialog used to offer a target
dropdown whose value the exit never reads. */}
{exitPlan && !honorsTarget && (
<p className="mt-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-2.5 py-2 text-[11px] leading-relaxed text-gray-400">
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">exits on</span>{' '}
{exitPlan.headline}. No take-profit the {formatPrice(setup.target)} level is recorded for
reference only and will not close this trade.
</p>
)}
<div className="mt-4 grid grid-cols-2 gap-3">
<label className="block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
@@ -398,9 +442,12 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
/>
</label>
</div>
{setup.targets && setup.targets.length > 1 ? (
{/* Only a real choice when the live exit policy takes profit at a
level. Under `atr_trailing` (production) the value is inert, so
offering it would imply control the user does not have. */}
{honorsTarget && setup.targets && setup.targets.length > 1 ? (
<label className="mt-3 block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Target</span>
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Take profit at</span>
<select
value={takeTarget}
onChange={(e) => setTakeTarget(Number(e.target.value))}
@@ -408,7 +455,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
>
{setup.targets.map((t) => (
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
{formatPrice(t.price)} · {t.probability.toFixed(0)}% · {t.classification}{t.is_primary ? ' · primary' : ''}
{formatPrice(t.price)} · {t.probability.toFixed(0)}% touch odds · {t.classification}{t.is_primary ? ' · primary' : ''}
</option>
))}
</select>
@@ -435,16 +482,28 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o
document.body,
)}
{/* Target ladder — open by default; clicking a row previews it on the rail */}
{/* Levels ladder — still fully explorable (clicking a row drives the rail
and the candlestick overlay), but framed as what it is: overhead
structure used to screen the setup, not a menu of exits. */}
{setup.targets && setup.targets.length > 0 && (
<details className="mt-3" open>
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
Targets ({setup.targets.length}) · select a row to preview it on the rail and use it when taking
{honorsTarget
? `Take-profit levels (${setup.targets.length}) · select one to preview it and use it when taking`
: `Overhead levels (${setup.targets.length}) · select one to preview it on the rail and chart`}
</summary>
{!honorsTarget && (
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-600">
Resistance levels the scanner found. Their R:R and touch odds are what got this setup
through the gate but the trade exits on the trailing stop, so price reaching one of
these is not a sell signal. Clicking only moves the marker.
</p>
)}
<div className="mt-2">
<TargetTable
setup={setup}
selectedPrice={activePrice}
honorsTarget={honorsTarget}
onSelect={(t) => {
selectTargetPrice(t.price);
setTakeTarget(t.price);
@@ -514,6 +573,8 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
: undefined;
const { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data;
const exitPolicy = useExitPolicy().data;
const baseRates = productionBaseRates(useBacktestReport().data);
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
@@ -537,9 +598,9 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
return null;
}
// If the preferred setup has played out / been invalidated, the stored
// ticker-level bias and reasoning are stale — don't headline "Strong Long"
// above a "no current setup" card.
// If the preferred setup has been invalidated (price through the stop), the
// stored ticker-level bias and reasoning are stale — don't headline "Strong
// Long" above a "no current setup" card.
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
const body = (
@@ -549,7 +610,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<div className="min-w-0">
{preferredInactive ? (
<span className="text-sm font-semibold text-gray-400">
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span>
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} invalidated at the stop)</span>
</span>
) : (() => {
const reasoning = summary?.reasoning ?? '';
@@ -578,7 +639,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
earningsDays <= EARNINGS_HORIZON_DAYS ? (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) inside the ~30-day
target horizon. A report can gap price through your stop or target; consider waiting or sizing down.
max hold. A report can gap price straight through your stop; consider waiting or sizing down.
</p>
) : (
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
@@ -587,7 +648,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
{preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
{alternativeSetup && (
<details>
@@ -595,17 +656,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary>
<div className="mt-3">
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
</div>
</details>
)}
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
</div>
)}
{/* System-level base rates: what actually happens to trades like these,
under the real exit. The honest counterpart to per-target "probability". */}
{baseRates && <BaseRatesPanel rates={baseRates} />}
</div>
);
+81
View File
@@ -0,0 +1,81 @@
/**
* Base rates for the strategy as it actually runs.
*
* The per-target `probability` on a setup answers "will price touch this S/R
* level?" — a question about a level we never exit at. These numbers answer the
* question the user is really asking ("what tends to happen when I take one of
* these?") and they are measured under the *real* exit policy, from the same
* backtest report the Track Record page already consumes.
*/
interface MonitorRun {
strategy?: string;
lookback?: string;
is_production?: boolean;
sharpe?: number | null;
cagr_pct?: number | null;
max_drawdown_pct?: number | null;
trades?: number | null;
win_rate?: number | null;
avg_hold_days?: number | null;
best_trade_r?: number | null;
worst_trade_r?: number | null;
exit_reasons?: Record<string, number> | null;
}
export interface BaseRates {
lookbackLabel: string;
trades: number;
winRate: number;
avgHoldDays: number | null;
bestR: number | null;
worstR: number | null;
sharpe: number | null;
/** How trades actually ended, as shares of the total (0-1). */
exits: { reason: string; label: string; count: number; share: number }[];
}
const EXIT_LABELS: Record<string, string> = {
stop: 'initial stop',
trailing_stop: 'trailing stop',
time: 'max hold',
target: 'target',
};
/**
* Pull the production strategy's full-history row out of a backtest report.
* Returns null when the report hasn't run or has no production row.
*/
export function productionBaseRates(report: unknown): BaseRates | null {
const monitor = (report as { portfolio_monitor?: { production_strategy?: string; runs?: MonitorRun[] } })
?.portfolio_monitor;
if (!monitor?.runs?.length) return null;
const strategy = monitor.production_strategy;
const row =
monitor.runs.find((r) => r.strategy === strategy && r.lookback === 'all') ??
monitor.runs.find((r) => r.is_production && r.lookback === 'all');
if (!row || !row.trades) return null;
const reasons = row.exit_reasons ?? {};
const total = Object.values(reasons).reduce((a, b) => a + b, 0);
const exits = Object.entries(reasons)
.map(([reason, count]) => ({
reason,
label: EXIT_LABELS[reason] ?? reason,
count,
share: total > 0 ? count / total : 0,
}))
.sort((a, b) => b.count - a.count);
return {
lookbackLabel: 'all history',
trades: row.trades,
winRate: row.win_rate ?? 0,
avgHoldDays: row.avg_hold_days ?? null,
bestR: row.best_trade_r ?? null,
worstR: row.worst_trade_r ?? null,
sharpe: row.sharpe ?? null,
exits,
};
}
+134
View File
@@ -0,0 +1,134 @@
/**
* What actually closes a trade.
*
* The setup's `target` is NOT an exit under the production policy: it is a
* screening artifact — the nearest S/R level, used to compute the R:R and
* probability that admit the setup through the activation gate. The live exit
* (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing`
* closes on the initial stop, a trailing stop, or the max hold.
*
* This module derives the real plan so the UI can show it instead of implying
* a take-profit that will never fire. See docs/research/sr-levels-and-exits.md.
*/
import type { ExitPolicy, TradeSetup } from './types';
/**
* Stop width used when the scanner builds a setup: stop = entry ∓ 1.5 × ATR
* (`rr_scanner_service.scan_symbol`, and `backtest_service.ATR_MULTIPLIER`).
* Lets us recover ATR from a setup without another round trip:
* ATR = |entry stop| / 1.5
* Guarded by test_prod_strategy_parity.py so a backend change can't silently
* desync this.
*/
export const SETUP_STOP_ATR_MULTIPLIER = 1.5;
export interface ExitPlan {
mode: ExitPolicy['mode'];
/** Does the setup's target actually close the trade? Only when mode === 'target'. */
honorsTarget: boolean;
/** Distance from entry to the initial stop, i.e. 1R per share. */
riskPerShare: number;
initialStop: number;
/** Trailing-stop width in price, once the trail is active (atr_trailing only). */
trailWidth: number | null;
/**
* Price the trade must reach before the trailing stop rises above the initial
* stop and takes over. Below this, the initial stop is what's protecting you.
*/
trailTakesOverAt: number | null;
/** Trail width expressed in R — the intuitive "how much give-back". */
trailWidthR: number | null;
maxHoldDays: number;
headline: string;
}
/**
* Derive the real exit plan for a setup under the live policy.
* Returns null when the setup has no usable risk distance.
*/
export function deriveExitPlan(setup: TradeSetup, policy?: ExitPolicy): ExitPlan | null {
const isLong = setup.direction === 'long';
const riskPerShare = Math.abs(setup.entry_price - setup.stop_loss);
if (!(riskPerShare > 0)) return null;
// Fall back to the shipped defaults when the policy hasn't loaded yet, so the
// card never renders a blank or (worse) a target-based plan.
const mode = policy?.mode ?? 'atr_trailing';
const maxHoldDays = policy?.hold_days ?? 30;
const atrMultiplier = policy?.atr_multiplier ?? 3;
const atr = riskPerShare / SETUP_STOP_ATR_MULTIPLIER;
if (mode === 'atr_trailing') {
const trailWidth = atrMultiplier * atr;
// The trail only bites once it clears the initial stop:
// highestClose trailWidth > stop ⇔ highestClose > entry ± (trailWidth R)
const takeoverOffset = trailWidth - riskPerShare;
const trailTakesOverAt = isLong
? setup.entry_price + takeoverOffset
: setup.entry_price - takeoverOffset;
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth,
trailTakesOverAt,
trailWidthR: trailWidth / riskPerShare,
maxHoldDays,
headline: `${atrMultiplier}× ATR trailing stop · max ${maxHoldDays} trading days`,
};
}
if (mode === 'trailing') {
const trailWidth = (setup.entry_price * (policy?.trailing_pct ?? 12)) / 100;
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth,
trailTakesOverAt: null,
trailWidthR: trailWidth / riskPerShare,
maxHoldDays,
headline: `${policy?.trailing_pct ?? 12}% trailing stop · max ${maxHoldDays} trading days`,
};
}
if (mode === 'target') {
return {
mode,
honorsTarget: true,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth: null,
trailTakesOverAt: null,
trailWidthR: null,
maxHoldDays,
headline: 'Take profit at the selected level, or exit at the stop',
};
}
// 'time'
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth: null,
trailTakesOverAt: null,
trailWidthR: null,
maxHoldDays,
headline: `Hold to the stop or ${maxHoldDays} trading days — no target, no trail`,
};
}
/** How far price has run from the scan entry, in R. Sign is direction-aware. */
export function driftInR(setup: TradeSetup, currentPrice: number): number | null {
const risk = Math.abs(setup.entry_price - setup.stop_loss);
if (!(risk > 0)) return null;
const moved = setup.direction === 'long'
? currentPrice - setup.entry_price
: setup.entry_price - currentPrice;
return moved / risk;
}
+26 -19
View File
@@ -92,7 +92,7 @@ function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarR
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
} ${qualified ? '' : 'opacity-60'}`}
title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''} · click to focus`}
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · touch odds ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
>
<span className="num text-[11px] text-gray-500">{rank}</span>
<span className="min-w-0">
@@ -164,34 +164,41 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
conviction {convictionLabel(setup.recommended_action)}
</span>
{setup.momentum_percentile != null && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
residual momentum {Math.round(setup.momentum_percentile)}th %ile
</span>
)}
</div>
</div>
</div>
<div className="flex gap-10 text-right">
<div>
<p className="section-index">reward / risk</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{setup.rr_ratio.toFixed(1)}
<span className="text-lg text-gray-400"> : 1</span>
</p>
</div>
{prob != null && (
<div>
<p className="section-index">target probability</p>
{/* The headline stat is the signal that actually selected this ticker.
R:R and touch odds are gate inputs computed from an S/R level the
trade never exits at — they get quiet, labelled treatment. */}
<div className="flex items-start gap-10 text-right">
{setup.momentum_percentile != null && (
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
<p className="section-index">residual momentum</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{Math.round(prob)}
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
<span className="text-lg text-gray-400">%</span>
</p>
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
<span className="block h-full rounded-full bg-blue-500" style={{ width: `${Math.round(prob)}%` }} />
<span
className="block h-full rounded-full bg-blue-500"
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
/>
</div>
</div>
)}
<div
className="max-w-[13rem]"
title="Gate metrics. The reward/risk and touch odds of the nearest S/R level are what admitted this setup through the activation gate. The trade does NOT exit at that level — it exits on the trailing stop."
>
<p className="section-index">gate metrics</p>
<p className="num mt-1.5 text-sm text-gray-300">
R:R {setup.rr_ratio.toFixed(1)}:1
{prob != null && <> · touch {Math.round(prob)}%</>}
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
screening only exits on the trailing stop, not at the level
</p>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+281
View File
@@ -0,0 +1,281 @@
"""Compare backtest report JSONs side by side.
Reads every backtest-*.json in this folder and shows one table of runs across
all of them, so a strategy can be compared across reports (or reports against
each other) without hand-diffing JSON. Highlights the best row for the chosen
metric. Stdlib only (tkinter) — run it with any Python 3:
python reports/compare_reports.py
"""
from __future__ import annotations
import glob
import json
import os
import tkinter as tk
from tkinter import ttk
HERE = os.path.dirname(os.path.abspath(__file__))
# Each report section exposes the same portfolio metrics; only the key naming
# the run (and whether it has lookbacks) differs.
SECTIONS = {
"Portfolio monitor (prod strategies)": {
"path": ("portfolio_monitor", "runs"),
"name_key": "strategy",
"lookbacks": True,
},
"Entry variants": {
"path": ("strategy_variants", "variants"),
"name_key": "variant",
"lookbacks": False,
},
"Exit policies": {
"path": ("exit_policy_variants", "variants"),
"name_key": "exit_policy",
"lookbacks": False,
},
"Portfolio sim (policies)": {
"path": ("portfolio_sim", "policies"),
"name_key": "policy",
"lookbacks": False,
},
}
# label, json key, format, higher_is_better (None = not rankable)
COLUMNS = [
("Report", "_report", "{}", None),
("Run", "_name", "{}", None),
("Lookback", "_lookback", "{}", None),
("CAGR %", "cagr_pct", "{:+.1f}", True),
("Max DD %", "max_drawdown_pct", "-{:.1f}", False),
("Sharpe", "sharpe", "{:.2f}", True),
("Total ret %", "total_return_pct", "{:+.1f}", True),
("SPY %", "spy_return_pct", "{:+.1f}", None),
("Trades", "trades", "{:.0f}", None),
("Win %", "win_rate", "{:.1f}", True),
("Hold d", "avg_hold_days", "{:.1f}", None),
]
RANKABLE = [c[0] for c in COLUMNS if c[3] is not None]
def load_reports() -> list[dict]:
reports = []
for path in sorted(glob.glob(os.path.join(HERE, "backtest-*.json"))):
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"skipping {os.path.basename(path)}: {exc}")
continue
name = os.path.basename(path)[len("backtest-") : -len(".json")]
reports.append({
"name": name,
"data": data,
"generated": (data.get("generated_at") or "")[:16].replace("T", " "),
"qualified": data.get("qualified"),
})
return reports
def rows_for(report: dict, section: str) -> list[dict]:
"""Flatten one report's section into rows, tagging report/run/lookback."""
cfg = SECTIONS[section]
top, inner = cfg["path"]
block = report["data"].get(top) or {}
raw = block.get(inner) or []
rows = []
for item in raw:
if not isinstance(item, dict):
continue
row = dict(item)
row["_report"] = report["name"]
row["_name"] = item.get(cfg["name_key"]) or "?"
row["_lookback"] = item.get("lookback") or "-"
row["_production"] = bool(item.get("is_production"))
rows.append(row)
return rows
class App:
def __init__(self, root: tk.Tk, reports: list[dict]) -> None:
self.root = root
self.reports = reports
self.rows: list[dict] = []
root.title("Backtest report comparison")
root.geometry("1250x680")
controls = ttk.Frame(root, padding=8)
controls.pack(fill="x")
ttk.Label(controls, text="Section").pack(side="left")
self.section = ttk.Combobox(
controls, values=list(SECTIONS), state="readonly", width=32
)
self.section.current(0)
self.section.pack(side="left", padx=(4, 14))
self.section.bind("<<ComboboxSelected>>", lambda _e: self.on_section())
ttk.Label(controls, text="Lookback").pack(side="left")
self.lookback = ttk.Combobox(controls, state="readonly", width=10)
self.lookback.pack(side="left", padx=(4, 14))
self.lookback.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
ttk.Label(controls, text="Best by").pack(side="left")
self.metric = ttk.Combobox(
controls, values=RANKABLE, state="readonly", width=12
)
self.metric.set("Sharpe")
self.metric.pack(side="left", padx=(4, 14))
self.metric.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
body = ttk.Frame(root, padding=(8, 0))
body.pack(fill="both", expand=True)
left = ttk.Frame(body)
left.pack(side="left", fill="y", padx=(0, 8))
ttk.Label(left, text="Reports (select to filter)").pack(anchor="w")
self.report_list = tk.Listbox(
left, selectmode="extended", width=34, height=24, exportselection=False
)
for rep in self.reports:
label = f"{rep['name']} ({rep['generated'][5:]}"
label += f", q={rep['qualified']})" if rep["qualified"] else ")"
self.report_list.insert("end", label)
self.report_list.select_set(0, "end")
self.report_list.pack(fill="y", expand=True)
self.report_list.bind("<<ListboxSelect>>", lambda _e: self.refresh())
headers = [c[0] for c in COLUMNS]
self.tree = ttk.Treeview(body, columns=headers, show="headings")
for label, _key, _fmt, _hib in COLUMNS:
self.tree.heading(
label, text=label, command=lambda c=label: self.sort_by(c)
)
width = 190 if label == "Run" else (150 if label == "Report" else 82)
self.tree.column(label, width=width, anchor="w" if width > 100 else "e")
scroll = ttk.Scrollbar(body, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=scroll.set)
self.tree.pack(side="left", fill="both", expand=True)
scroll.pack(side="left", fill="y")
self.tree.tag_configure("best", background="#c8e6c9")
self.tree.tag_configure("prod", font=("TkDefaultFont", 9, "bold"))
self.status = ttk.Label(root, padding=8, anchor="w")
self.status.pack(fill="x")
self.sort_col: str | None = None
self.sort_desc = True
self.on_section()
def selected_reports(self) -> list[dict]:
picked = self.report_list.curselection()
return [self.reports[i] for i in picked] if picked else self.reports
def on_section(self) -> None:
cfg = SECTIONS[self.section.get()]
if cfg["lookbacks"]:
seen: list[str] = []
for rep in self.reports:
for row in rows_for(rep, self.section.get()):
if row["_lookback"] not in seen:
seen.append(row["_lookback"])
self.lookback.configure(values=["(all rows)"] + seen, state="readonly")
self.lookback.set("all" if "all" in seen else "(all rows)")
else:
self.lookback.configure(values=["(n/a)"], state="disabled")
self.lookback.set("(n/a)")
self.sort_col = None
self.refresh()
def refresh(self) -> None:
section = self.section.get()
rows: list[dict] = []
missing: list[str] = []
for rep in self.selected_reports():
found = rows_for(rep, section)
if not found:
missing.append(rep["name"])
rows.extend(found)
chosen = self.lookback.get()
if SECTIONS[section]["lookbacks"] and chosen not in ("(all rows)", "(n/a)"):
rows = [r for r in rows if r["_lookback"] == chosen]
metric_label = self.metric.get()
key, higher = next(
(c[1], c[3]) for c in COLUMNS if c[0] == metric_label
)
ranked = [r for r in rows if isinstance(r.get(key), (int, float))]
best = None
if ranked:
best = (max if higher else min)(ranked, key=lambda r: r[key])
sort_col = self.sort_col or metric_label
sort_key, sort_hib = next(
(c[1], c[3]) for c in COLUMNS if c[0] == sort_col
)
if self.sort_col is None:
# Default order: best value first for the chosen metric.
reverse = bool(higher)
else:
reverse = self.sort_desc
def sort_value(row: dict):
val = row.get(sort_key)
if isinstance(val, (int, float)):
return (1, val, "")
return (0, 0.0, str(val or ""))
rows.sort(key=sort_value, reverse=reverse)
self.tree.delete(*self.tree.get_children())
for row in rows:
values = []
for label, k, fmt, _hib in COLUMNS:
val = row.get(k)
if isinstance(val, (int, float)):
values.append(fmt.format(val))
else:
values.append("-" if val in (None, "") else str(val))
tags = []
if best is not None and row is best:
tags.append("best")
if row.get("_production"):
tags.append("prod")
self.tree.insert("", "end", values=values, tags=tags)
self.rows = rows
parts = [f"{len(rows)} runs from {len(self.selected_reports())} report(s)"]
if best is not None:
parts.append(
f"best {metric_label}: {best['_name']} "
f"({best['_report']}) = {best[key]:.2f}"
)
if missing:
parts.append(f"no '{section}' data in: {', '.join(missing)}")
parts.append("bold = production row")
self.status.configure(text=" | ".join(parts))
def sort_by(self, column: str) -> None:
if self.sort_col == column:
self.sort_desc = not self.sort_desc
else:
self.sort_col = column
self.sort_desc = True
self.refresh()
def main() -> None:
reports = load_reports()
if not reports:
raise SystemExit(f"No backtest-*.json found in {HERE}")
root = tk.Tk()
App(root, reports)
root.mainloop()
if __name__ == "__main__":
main()
+9 -3
View File
@@ -1,9 +1,9 @@
"""Create a minimal local SQLite snapshot for offline backtest research.
Copies only the data required by app.services.backtest_service.run_backtest:
tickers, OHLCV bars, SPY benchmark closes, and activation/recommendation
settings. Other system settings are intentionally skipped to avoid copying
secrets into local snapshot files.
tickers, OHLCV bars, SPY benchmark closes, and the activation / recommendation /
paper-exit settings the run reads. Other system settings are intentionally
skipped to avoid copying secrets into local snapshot files.
"""
from __future__ import annotations
@@ -144,6 +144,12 @@ async def _main() -> None:
where=or_(
SystemSetting.key.like("activation_%"),
SystemSetting.key.like("recommendation_%"),
# The production portfolio-monitor row replays the RUNTIME
# exit policy via get_exit_policy(). Without these keys a
# snapshot silently falls back to the code defaults, so a
# live-tuned exit would not be reflected — the snapshot run
# would disagree with prod and give no hint why.
SystemSetting.key.like("paper_%"),
),
),
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
+17
View File
@@ -12,6 +12,7 @@ import pytest
from app.services import paper_trade_service
from app.services.admin_service import ACTIVATION_DEFAULTS
from app.services.backtest_service import (
ATR_MULTIPLIER,
ATR_TRAIL_MULTIPLIER,
LIVE_EXIT_MODE_TO_SIM,
PORTFOLIO_MONITOR_STRATEGIES,
@@ -43,6 +44,22 @@ def test_every_live_exit_mode_has_a_sim_mapping() -> None:
assert set(paper_trade_service._VALID_EXIT_MODES) == set(LIVE_EXIT_MODE_TO_SIM)
def test_setup_stop_width_matches_the_frontend_constant() -> None:
"""The UI recovers ATR from a setup as |entry - stop| / 1.5 to render the real
exit plan (frontend/src/lib/exitPlan.ts: SETUP_STOP_ATR_MULTIPLIER). Nothing
else transmits ATR, so if the scanner's stop width changes here the UI would
silently draw the trailing stop in the wrong place."""
import inspect
from app.services import rr_scanner_service
frontend_constant = 1.5
assert ATR_MULTIPLIER == frontend_constant
for fn in (rr_scanner_service.scan_ticker, rr_scanner_service.scan_all_tickers):
signature = inspect.signature(fn)
assert signature.parameters["atr_multiplier"].default == frontend_constant
def test_gate_default_matches_the_promoted_cutoff() -> None:
prod = _production_monitor_row()
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))