Compare commits
6
Commits
cb215e2595
...
ea11efe3d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea11efe3d1 | ||
|
|
906d1db7d1 | ||
|
|
9789e3d762 | ||
|
|
85b3ef618f | ||
|
|
fa26ec3ec4 | ||
|
|
88527f39b6 |
+2
-1
@@ -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
|
||||
|
||||
@@ -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 (0–100) 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 (0–100) 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"
|
||||
)
|
||||
)
|
||||
@@ -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:
|
||||
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 "
|
||||
|
||||
@@ -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.5–4.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.2–1.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.
|
||||
@@ -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%** |
|
||||
| 90–98% | 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 303–427, 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.3–1.6, test Sharpe ~2.5–2.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’s where the strategy’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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<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>
|
||||
<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">
|
||||
{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>
|
||||
<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
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
Reference in New Issue
Block a user