Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c6967c9c | ||
|
|
d13c54e3c7 | ||
|
|
d858475ddb | ||
|
|
5155d00d9e | ||
|
|
1a6f82bf6d | ||
|
|
5385f46064 | ||
|
|
cf294a7d2b | ||
|
|
bbc7383d3a | ||
|
|
9800114fc4 | ||
|
|
27bc8a6631 | ||
|
|
0cd9ee7689 | ||
|
|
13f57b2525 | ||
|
|
65a462271c | ||
|
|
bc50ba9136 | ||
|
|
1e9f2dc4fb |
@@ -43,3 +43,6 @@ combined-ca-bundle.pem
|
|||||||
# Backtest reports in reports/ are tracked: they are the evidence behind the
|
# 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.
|
# production baseline in the README. The snapshot DBs they run against are not.
|
||||||
backtest_snapshots/
|
backtest_snapshots/
|
||||||
|
# Rebuildable pickle caches are local accelerators, not decision evidence.
|
||||||
|
reports/*.pkl
|
||||||
|
reports/*.pk1
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
Investing-signal platform for US equities. It runs one strategy, and it is a boring one:
|
Investing-signal platform for US equities. It runs one strategy, and it is a boring one:
|
||||||
|
|
||||||
> **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.
|
> **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. After an initial-stop exit, re-enter only after the gate has failed and subsequently qualified again.
|
||||||
|
|
||||||
**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.
|
**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 Structural S/R are **display context**, not validated predictors. The Gate Target Ladder is screening machinery that preserves the production setup population; it is not a claim about true market structure. In particular:
|
**What is NOT the edge — read this before trusting a number on screen.** The composite score, the 5 dimensions, sentiment, fundamentals, and Structural S/R are **display context**, not validated predictors. The Gate Target Ladder is screening machinery that preserves the production setup population; it is not a claim about true market structure. In particular:
|
||||||
|
|
||||||
- **The headline "target" is not an exit.** It comes from the internal **Gate Target Ladder** and exists only to compute the R:R and reach-probability used by the activation gate. Human-facing chart S/R is a separate model. The live exit reads neither. 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 headline "target" is not an exit.** It comes from the internal **Gate Target Ladder** and exists only to compute the R:R and reach-probability used by the activation gate. Human-facing chart S/R is a separate model. The live exit reads neither. Across 472 trades in the current daily gate-reset replay, the exit reasons were **229 initial stop, 147 trailing stop, 96 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.
|
- **The composite score does not select trades.** Residual momentum does.
|
||||||
|
|
||||||
Full experiment log — everything tested, kept, and rejected: **[docs/research/](docs/research/README.md)**.
|
Full experiment log — everything tested, kept, and rejected: **[docs/research/](docs/research/README.md)**.
|
||||||
@@ -36,19 +36,31 @@ flowchart TD
|
|||||||
BOOK -->|yes| OPEN["OPEN — size at 1% account risk"]
|
BOOK -->|yes| OPEN["OPEN — size at 1% account risk"]
|
||||||
|
|
||||||
OPEN --> EXIT{"Exit — whichever comes first"}
|
OPEN --> EXIT{"Exit — whichever comes first"}
|
||||||
EXIT --> E1["Initial stop hit<br/>entry − 1.5 × ATR → −1R<br/><b>45% of trades</b>"]
|
EXIT --> E1["Initial stop hit<br/>entry − 1.5 × ATR → −1R<br/><b>49% 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 --> 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 --> E3["Max hold reached<br/>30 trading days<br/><b>20% of trades</b>"]
|
||||||
EXIT -.->|"NEVER"| E4["Gate Target Ladder target<br/><b>0% of trades</b>"]
|
EXIT -.->|"NEVER"| E4["Gate Target Ladder target<br/><b>0% of trades</b>"]
|
||||||
|
|
||||||
|
E1 --> LOCK["Re-entry locked"]
|
||||||
|
LOCK --> GF{"Later daily scan<br/>fails the gate?"}
|
||||||
|
GF -->|no| LOCK
|
||||||
|
GF -->|yes| GQ{"A subsequent daily scan<br/>qualifies again?"}
|
||||||
|
GQ -->|no| GQ
|
||||||
|
GQ -->|yes| RANK
|
||||||
|
|
||||||
style M fill:#1e3a5f,color:#fff
|
style M fill:#1e3a5f,color:#fff
|
||||||
style OPEN fill:#1e4d2b,color:#fff
|
style OPEN fill:#1e4d2b,color:#fff
|
||||||
style E4 fill:#2a2a2a,color:#888
|
style E4 fill:#2a2a2a,color:#888
|
||||||
style E1 fill:#4a1f1f,color:#fff
|
style E1 fill:#4a1f1f,color:#fff
|
||||||
style E2 fill:#1e4d2b,color:#fff
|
style E2 fill:#1e4d2b,color:#fff
|
||||||
|
style LOCK fill:#4a351f,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 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 36.2%), a few win big (best trade +12.0R), and *that is why there is no take-profit*.
|
||||||
|
|
||||||
|
**What happens after an initial stop.** The stop always closes the trade and realizes its costs. The ticker is then locked until a successful daily full-universe scan first observes it outside the production gate and a later scan observes a fresh qualification. A continuously qualified ticker therefore cannot generate an immediate duplicate entry. Other exit reasons do not start this reset. See the [daily post-stop re-entry study](docs/research/post-stop-reentry.md).
|
||||||
|
|
||||||
|
**Live timing matters.** The full daily pipeline runs the R:R scan before Outcome Eval. A stop closed by that Outcome Eval—or by an intraday evaluation after the day's full scan—therefore cannot use its stop-day gate state. The earliest failure observation is the next successful full scan, and requalification needs a subsequent full scan. The research `gate_reset` arm evaluated the stop before its same-session gate check; the live boundary is consequently analogous to the study's stricter `strict_gate_reset` arm. This known event-ordering difference is quantified below.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ Once a day (default 07:00). Steps run **in dependency order**, each consuming th
|
|||||||
|
|
||||||
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
|
1. **OHLCV** — fetch the latest daily bars for every tracked ticker (Alpaca); new tickers backfill ~5 years.
|
||||||
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
|
2. **Sentiment** — fetch sentiment for the names that matter and are stale (> 5 days): top-pick feeders (residual-momentum leaders with a tradeable long setup), the watchlist, and open paper trades, plus a top-N-by-composite discovery net. Runs *before* the scan so the scan sees fresh sentiment.
|
||||||
3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 12‑1 momentum activation percentile plus the promoted 80/20 production rank.
|
3. **R:R Scan** — persist clean Structural S/R for charts/alerts, recompute the 5-dimension scores, and build long/short setups from a transient Gate Target Ladder (ATR stops and nominal gate targets) for every ticker. Attach each ticker's residual 12‑1 momentum activation percentile plus the promoted 80/20 production rank. The completed full-universe scan also advances post-stop locks from gate failure to later requalification; failed scans never count as a transition.
|
||||||
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: 3x ATR trail with a 30-trading-day max hold).
|
4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and auto-close paper trades per the exit policy (default: 3x ATR trail with a 30-trading-day max hold).
|
||||||
5. **Market Regime** — recompute the regime index (breadth/trend).
|
5. **Market Regime** — recompute the regime index (breadth/trend).
|
||||||
6. **Regime Monitor** — separate v2 State/Warning risk thermometer with fixed-basket breadth, VIX, credit, and point-in-time fundamentals; feeds no trades.
|
6. **Regime Monitor** — separate v2 State/Warning risk thermometer with fixed-basket breadth, VIX, credit, and point-in-time fundamentals; feeds no trades.
|
||||||
@@ -155,6 +167,7 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **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 |
|
| **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 |
|
||||||
| **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) |
|
| **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) |
|
||||||
|
| **Post-stop gate reset** | **Production re-entry policy** | The initial stop always closes; the ticker must later fail the daily gate and subsequently qualify again. At the production capacity of 10: Sharpe 1.67 → 1.77, CAGR 45.2% → 48.3%, DD 24.3% → 21.6% versus immediate re-entry. [Full study](docs/research/post-stop-reentry.md) |
|
||||||
| **Structural S/R** | **Human-facing context only — not a gate and not an exit** | Clean, capped zones are persisted for charts and alerts. The scanner deliberately does not read them. |
|
| **Structural S/R** | **Human-facing context only — not a gate and not an exit** | Clean, capped zones are persisted for charts and alerts. The scanner deliberately does not read them. |
|
||||||
| **Gate Target Ladder** | **Gate input only — not market structure and not an exit** | Volume-free range grid + pivots preserves the useful legacy screening behavior exactly: 1,086/1,086 qualified setups retained and identical Sharpe 2.03 / CAGR 50.0% / DD 21.4% / 321 trades. The exit never reads its target. [Full write-up](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder) |
|
| **Gate Target Ladder** | **Gate input only — not market structure and not an exit** | Volume-free range grid + pivots preserves the useful legacy screening behavior exactly: 1,086/1,086 qualified setups retained and identical Sharpe 2.03 / CAGR 50.0% / DD 21.4% / 321 trades. The exit never reads its target. [Full write-up](docs/research/sr-levels-and-exits.md#explicit-gate-target-ladder) |
|
||||||
| 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`) |
|
| 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`) |
|
||||||
@@ -167,11 +180,28 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we
|
|||||||
|
|
||||||
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.
|
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
|
### Daily post-stop re-entry decision (2026-07-17)
|
||||||
|
|
||||||
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.
|
The production policy is **normal gate reset**, evaluated with daily setup opportunities and live-like full-universe ranking. An initial stop always closes. Re-entry unlocks only after a later successful daily scan observes the ticker failing the gate and a subsequent scan observes it qualifying again. The study replayed 1,011,248 point-in-time candidate observations across 505 tickers from 2022-06-24 through 2026-07-02, with the production GTL gate, 80/20 rank, exit, fees, sizing, and 10-position capacity.
|
||||||
|
|
||||||
| Item | Current baseline |
|
| Re-entry policy | Total return | CAGR | Max DD | Sharpe | Trades |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Immediate | 348.4% | 45.2% | -24.3% | 1.67 | 489 |
|
||||||
|
| **Gate reset (selected study arm)** | **388.1%** | **48.3%** | **-21.6%** | **1.77** | **472** |
|
||||||
|
| Strict gate reset (live timing analogue) | 342.7% | 44.8% | -23.4% | 1.68 | 471 |
|
||||||
|
| Fixed five-session cooldown | 250.8% | 36.6% | -22.2% | 1.47 | 473 |
|
||||||
|
|
||||||
|
In the disjoint 2025+ book, gate reset also beat immediate re-entry (Sharpe 1.66 vs 1.55; CAGR 41.8% vs 39.3%) and the fixed five-session rule (Sharpe 1.43; CAGR 32.7%). Its lead over both survived costs of 0.2% and 0.3% per side. The result is capacity-specific: cooldown 5 won at capacity 5, while immediate had slightly higher return and Sharpe at capacity 15. Production uses capacity 10, so that is the portfolio for which this decision is valid.
|
||||||
|
|
||||||
|
Those promotion numbers belong to the selected normal-reset study arm. Under the live scheduler's stricter first-observation timing, the full-period analogue was Sharpe 1.68 / CAGR 44.8% / DD 23.4%; in the disjoint 2025+ book it was Sharpe 1.38 / CAGR 32.9% / DD 21.0%. The matrix therefore validates the state-machine choice but is not exact scheduler-order parity. Closing this timing gap would require a separately reviewed pipeline-order change, not a documentation reinterpretation.
|
||||||
|
|
||||||
|
`gate_reset` and a simple `next_session` block happened to produce the same executed live-universe portfolio in this sample. Their rules are still different: this establishes that same-day re-entry was harmful here, but does not isolate a separate historical return premium from the reset condition. Gate reset was promoted because it represents a genuinely new signal episode and did not sacrifice results in the production book. Full definitions, all nine policy arms, cost/capacity sensitivity, and legacy-rank results are in [docs/research/post-stop-reentry.md](docs/research/post-stop-reentry.md); source report: [`reports/daily_reentry_matrix.json`](reports/daily_reentry_matrix.json).
|
||||||
|
|
||||||
|
### Historical weekly production baseline (pre gate-reset)
|
||||||
|
|
||||||
|
Use this as the historical ranking/exit regression guardrail, not as a return promise or the current re-entry-policy result. This run predates the post-stop gate reset and uses weekly entry replay, so its portfolio headline is not directly comparable with the daily matrix above. 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 | Historical weekly baseline |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Strategy version | `residual_highvol_80_20_atr_trail3_v1` |
|
| Strategy version | `residual_highvol_80_20_atr_trail3_v1` |
|
||||||
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, headline gate-target R:R >= 2.0 (live `activation_min_rr`; the code default is 1.2), primary-target reach-probability >= 20%, NEUTRAL excluded, confidence floor off (0) |
|
| Production gate | Long-only, residual 12-1 momentum percentile >= 80, headline gate-target R:R >= 2.0 (live `activation_min_rr`; the code default is 1.2), primary-target reach-probability >= 20%, NEUTRAL excluded, confidence floor off (0) |
|
||||||
@@ -218,6 +248,7 @@ A systematic single-variable sweep (offline prod snapshot, production gate/rank/
|
|||||||
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
| Momentum lookback: 6-1, 3-1, 12-7 (Novy-Marx), composites | **Keep residual 12-1** | 6-1/3-1 rank-IC ≈ 0; 12-7 IC 0.045 / t 1.58 — weaker than residual 12-1 (0.055 / t 1.98) |
|
||||||
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep 80 × 10** | Monotonically worse in both directions from 80; the 10-slot cap never binds (<10 concurrent) |
|
| Selection cutoff {70, 75, 85, 90} × book size {10, 15, 20} | **Keep 80 × 10** | Monotonically worse in both directions from 80; the 10-slot cap never binds (<10 concurrent) |
|
||||||
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
| Position sizing: equal-weight, inverse-vol, risk-% sweep | **Keep 1% fixed-fractional** | See the inverse-vol warning below |
|
||||||
|
| Post-stop re-entry: immediate, fixed 2–5 sessions, gate resets, confirmation filters | **Keep normal gate reset for the 10-position production book** | Sharpe 1.77 vs 1.67 immediate and 1.47 cooldown 5; rerun before changing portfolio capacity |
|
||||||
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
| FIP path-smoothness as an in-book tie-breaker/filter | **Reject** (but see the lead below) | Non-monotonic across FIP quintiles within the qualified set; either half of a median split underperforms the full book — thinning the entry stream costs more compounding than the tilt returns |
|
||||||
|
|
||||||
Two findings future sessions must not re-litigate:
|
Two findings future sessions must not re-litigate:
|
||||||
@@ -450,6 +481,14 @@ python scripts/run_backtest_snapshot.py backtest_snapshots/prod.sqlite --workers
|
|||||||
.venv\Scripts\python.exe scripts\run_backtest_snapshot.py backtest_snapshots\prod.sqlite --workers 6 --allow-spawn
|
.venv\Scripts\python.exe scripts\run_backtest_snapshot.py backtest_snapshots\prod.sqlite --workers 6 --allow-spawn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Weekly remains the resource-safe default. Add `--cadence daily` for live-like daily entry opportunities; this performs roughly five times as many setup evaluations. To generate the complete weekly/daily × immediate/gate-reset comparison in one invocation, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/run_backtest_cadence_comparison.py backtest_snapshots/prod.sqlite --workers 7
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, add `--allow-spawn`. The comparison runner writes the two full cadence reports plus one compact four-arm report. For the larger nine-policy daily research matrix used in the post-stop decision, see `scripts/run_daily_reentry_matrix.py` and the [research record](docs/research/post-stop-reentry.md).
|
||||||
|
|
||||||
On an 8-thread machine, `--workers 6` is a good starting point: it leaves a
|
On an 8-thread machine, `--workers 6` is a good starting point: it leaves a
|
||||||
couple of threads for Windows, the shell, and browser/UI work while still using
|
couple of threads for Windows, the shell, and browser/UI work while still using
|
||||||
most of the CPU.
|
most of the CPU.
|
||||||
@@ -490,6 +529,7 @@ matching decision. Every change still goes through the factor harness first (see
|
|||||||
| `gate_ablation` | Net expectancy with each floor removed | Drop a floor only if removing it doesn't hurt net expectancy |
|
| `gate_ablation` | Net expectancy with each floor removed | Drop a floor only if removing it doesn't hurt net expectancy |
|
||||||
| `time_exit_sweep` | Net avg R / net R-per-day by hold length | Whether a fixed time exit beats the promoted ATR trail |
|
| `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 |
|
| `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 |
|
||||||
|
| `production_cadence_comparison` | Immediate vs production gate reset at the selected weekly or daily cadence | Isolates the re-entry rule while keeping gate, rank, exit, fees, sizing, and capacity fixed |
|
||||||
| `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` |
|
| `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` |
|
| `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 |
|
| `recommendation`, `research_recommendation` | The report's own headline read | A starting point, not a substitute for the sections above |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""add persistent post-stop gate-reset observation
|
||||||
|
|
||||||
|
Revision ID: 022
|
||||||
|
Revises: 021
|
||||||
|
Create Date: 2026-07-17 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "022"
|
||||||
|
down_revision: Union[str, None] = "021"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"paper_trades",
|
||||||
|
sa.Column("reentry_gate_failed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"paper_trades",
|
||||||
|
sa.Column(
|
||||||
|
"reentry_gate_requalified_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# The policy starts at this deployment. Historical NULL values mean the
|
||||||
|
# scanner never recorded reset observations, not that those old episodes
|
||||||
|
# are still active. Mark both transitions complete so only stops created
|
||||||
|
# after the migration can open a re-entry lock.
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE paper_trades
|
||||||
|
SET reentry_gate_failed_at = closed_at,
|
||||||
|
reentry_gate_requalified_at = closed_at
|
||||||
|
WHERE status = 'closed'
|
||||||
|
AND close_reason = 'stop'
|
||||||
|
AND closed_at IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("paper_trades", "reentry_gate_requalified_at")
|
||||||
|
op.drop_column("paper_trades", "reentry_gate_failed_at")
|
||||||
@@ -36,3 +36,13 @@ class PaperTrade(Base):
|
|||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
||||||
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
close_reason: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||||
|
# A trade stopped at its initial stop starts a re-entry gate-reset episode.
|
||||||
|
# The daily full-universe scanner records both state transitions: the first
|
||||||
|
# failed gate observation and a later fresh qualification. Re-entry remains
|
||||||
|
# non-actionable until both timestamps exist.
|
||||||
|
reentry_gate_failed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
reentry_gate_requalified_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ async def trigger_job(
|
|||||||
db,
|
db,
|
||||||
job_name,
|
job_name,
|
||||||
target_model=body.target_model if body is not None else None,
|
target_model=body.target_model if body is not None else None,
|
||||||
|
cadence=body.cadence if body is not None else None,
|
||||||
)
|
)
|
||||||
return APIEnvelope(status="success", data=result)
|
return APIEnvelope(status="success", data=result)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async def list_trade_setups(
|
|||||||
recommended_action=recommended_action,
|
recommended_action=recommended_action,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
exclude_open_trade_tickers=True,
|
exclude_open_trade_tickers=True,
|
||||||
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
@@ -98,6 +99,7 @@ async def get_ticker_trade_setups(
|
|||||||
db,
|
db,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
|
include_reentry_gate_lock=True,
|
||||||
)
|
)
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
+37
-10
@@ -37,8 +37,10 @@ from app.services import fundamental_service, ingestion_service, sentiment_servi
|
|||||||
from app.services.alert_service import dispatch_alerts
|
from app.services.alert_service import dispatch_alerts
|
||||||
from app.services.backtest_service import (
|
from app.services.backtest_service import (
|
||||||
BACKTEST_TARGET_MODELS,
|
BACKTEST_TARGET_MODELS,
|
||||||
|
DEFAULT_BACKTEST_CADENCE,
|
||||||
PRODUCTION_GTL_TARGET_MODEL,
|
PRODUCTION_GTL_TARGET_MODEL,
|
||||||
run_and_store as run_backtest_and_store,
|
run_and_store as run_backtest_and_store,
|
||||||
|
validate_backtest_cadence,
|
||||||
validate_backtest_target_model,
|
validate_backtest_target_model,
|
||||||
)
|
)
|
||||||
from app.services.benchmark_service import refresh_benchmark_prices
|
from app.services.benchmark_service import refresh_benchmark_prices
|
||||||
@@ -112,6 +114,7 @@ def _idle_runtime() -> dict[str, object]:
|
|||||||
|
|
||||||
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
|
_job_runtime: dict[str, dict[str, object]] = {name: _idle_runtime() for name in _JOB_NAMES}
|
||||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||||
|
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -119,23 +122,44 @@ _next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def queue_backtest_target_model(target_model: str | None) -> str:
|
def queue_backtest_options(
|
||||||
"""Select the model for the next manual backtest run only.
|
target_model: str | None,
|
||||||
|
cadence: str | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Select model and cadence for the next manual backtest run only.
|
||||||
|
|
||||||
Scheduled runs and subsequent manual runs return to the production GTL.
|
Scheduled and subsequent manual runs return to production GTL at the
|
||||||
|
resource-safe weekly cadence.
|
||||||
"""
|
"""
|
||||||
global _next_backtest_target_model
|
global _next_backtest_target_model, _next_backtest_cadence
|
||||||
selected = validate_backtest_target_model(
|
selected_model = validate_backtest_target_model(
|
||||||
target_model or PRODUCTION_GTL_TARGET_MODEL
|
target_model or PRODUCTION_GTL_TARGET_MODEL
|
||||||
)
|
)
|
||||||
_next_backtest_target_model = selected
|
selected_cadence = validate_backtest_cadence(
|
||||||
|
cadence or DEFAULT_BACKTEST_CADENCE
|
||||||
|
)
|
||||||
|
_next_backtest_target_model = selected_model
|
||||||
|
_next_backtest_cadence = selected_cadence
|
||||||
|
return selected_model, selected_cadence
|
||||||
|
|
||||||
|
|
||||||
|
def queue_backtest_target_model(target_model: str | None) -> str:
|
||||||
|
"""Compatibility wrapper for callers selecting only the target model."""
|
||||||
|
selected, _ = queue_backtest_options(target_model, DEFAULT_BACKTEST_CADENCE)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_backtest_options() -> tuple[str, str]:
|
||||||
|
global _next_backtest_target_model, _next_backtest_cadence
|
||||||
|
selected = (_next_backtest_target_model, _next_backtest_cadence)
|
||||||
|
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||||
|
_next_backtest_cadence = DEFAULT_BACKTEST_CADENCE
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def _consume_backtest_target_model() -> str:
|
def _consume_backtest_target_model() -> str:
|
||||||
global _next_backtest_target_model
|
"""Compatibility wrapper consuming all queued one-run options."""
|
||||||
selected = _next_backtest_target_model
|
selected, _ = _consume_backtest_options()
|
||||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
|
|
||||||
@@ -1028,12 +1052,13 @@ async def compute_regime_monitor() -> None:
|
|||||||
async def run_backtest_job() -> None:
|
async def run_backtest_job() -> None:
|
||||||
"""Replay the price-derived engine over history and cache the report."""
|
"""Replay the price-derived engine over history and cache the report."""
|
||||||
job_name = "backtest"
|
job_name = "backtest"
|
||||||
target_model = _consume_backtest_target_model()
|
target_model, cadence = _consume_backtest_options()
|
||||||
_log_event(
|
_log_event(
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
"job_start",
|
"job_start",
|
||||||
job=job_name,
|
job=job_name,
|
||||||
target_model=target_model,
|
target_model=target_model,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
_runtime_start(job_name)
|
_runtime_start(job_name)
|
||||||
|
|
||||||
@@ -1051,6 +1076,7 @@ async def run_backtest_job() -> None:
|
|||||||
db,
|
db,
|
||||||
_on_progress,
|
_on_progress,
|
||||||
target_model=target_model,
|
target_model=target_model,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
|
|
||||||
_runtime_finish(
|
_runtime_finish(
|
||||||
@@ -1058,6 +1084,7 @@ async def run_backtest_job() -> None:
|
|||||||
processed=report.get("tickers", 0), total=report.get("tickers", 0),
|
processed=report.get("tickers", 0), total=report.get("tickers", 0),
|
||||||
message=(
|
message=(
|
||||||
f"{BACKTEST_TARGET_MODELS[target_model]}: "
|
f"{BACKTEST_TARGET_MODELS[target_model]}: "
|
||||||
|
f"{cadence} cadence, "
|
||||||
f"{report.get('candidates', 0)} setups, "
|
f"{report.get('candidates', 0)} setups, "
|
||||||
f"{report.get('qualified', 0)} qualified"
|
f"{report.get('qualified', 0)} qualified"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class JobToggle(BaseModel):
|
|||||||
class JobTriggerRequest(BaseModel):
|
class JobTriggerRequest(BaseModel):
|
||||||
"""Optional parameters for a one-time manual job run."""
|
"""Optional parameters for a one-time manual job run."""
|
||||||
target_model: Literal["production_gtl", "structural_sr"] | None = None
|
target_model: Literal["production_gtl", "structural_sr"] | None = None
|
||||||
|
cadence: Literal["weekly", "daily"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class RecommendationConfigUpdate(BaseModel):
|
class RecommendationConfigUpdate(BaseModel):
|
||||||
|
|||||||
@@ -59,5 +59,6 @@ class TradeSetupResponse(BaseModel):
|
|||||||
momentum_percentile: float | None = None
|
momentum_percentile: float | None = None
|
||||||
strategy_rank: float | None = None
|
strategy_rank: float | None = None
|
||||||
volatility_percentile: float | None = None
|
volatility_percentile: float | None = None
|
||||||
|
reentry_gate_reset_required: bool = False
|
||||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ async def trigger_job(
|
|||||||
job_name: str,
|
job_name: str,
|
||||||
*,
|
*,
|
||||||
target_model: str | None = None,
|
target_model: str | None = None,
|
||||||
|
cadence: str | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Trigger a manual job run via the scheduler.
|
"""Trigger a manual job run via the scheduler.
|
||||||
|
|
||||||
@@ -616,6 +617,8 @@ async def trigger_job(
|
|||||||
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
|
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
|
||||||
if target_model is not None and job_name != "backtest":
|
if target_model is not None and job_name != "backtest":
|
||||||
raise ValidationError("target_model is supported only for the backtest job")
|
raise ValidationError("target_model is supported only for the backtest job")
|
||||||
|
if cadence is not None and job_name != "backtest":
|
||||||
|
raise ValidationError("cadence is supported only for the backtest job")
|
||||||
|
|
||||||
from app.scheduler import get_job_runtime_snapshot, scheduler
|
from app.scheduler import get_job_runtime_snapshot, scheduler
|
||||||
|
|
||||||
@@ -643,9 +646,9 @@ async def trigger_job(
|
|||||||
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"}
|
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"}
|
||||||
|
|
||||||
if job_name == "backtest":
|
if job_name == "backtest":
|
||||||
from app.scheduler import queue_backtest_target_model
|
from app.scheduler import queue_backtest_options
|
||||||
|
|
||||||
target_model = queue_backtest_target_model(target_model)
|
target_model, cadence = queue_backtest_options(target_model, cadence)
|
||||||
|
|
||||||
job.modify(next_run_time=None) # Reset, then trigger immediately
|
job.modify(next_run_time=None) # Reset, then trigger immediately
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -654,6 +657,8 @@ async def trigger_job(
|
|||||||
result = {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
|
result = {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
|
||||||
if target_model is not None:
|
if target_model is not None:
|
||||||
result["target_model"] = target_model
|
result["target_model"] = target_model
|
||||||
|
if cadence is not None:
|
||||||
|
result["cadence"] = cadence
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
|||||||
db,
|
db,
|
||||||
live_recommendation=True,
|
live_recommendation=True,
|
||||||
exclude_open_trade_tickers=True,
|
exclude_open_trade_tickers=True,
|
||||||
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
)
|
)
|
||||||
config = await get_activation_config(db)
|
config = await get_activation_config(db)
|
||||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Historical backtest (Phase 1): replay the price-derived engine over stored
|
"""Historical backtest (Phase 1): replay the price-derived engine over stored
|
||||||
OHLCV and measure how the CURRENT config would have performed.
|
OHLCV and measure how the CURRENT config would have performed.
|
||||||
|
|
||||||
For each ticker we step through history (weekly), and at each as-of date D we
|
For each ticker we step through history at the selected entry cadence, and at each as-of date D we
|
||||||
rebuild the setup using only bars ≤ D (no lookahead), then walk the actual bars
|
rebuild the setup using only bars ≤ D (no lookahead), then walk the actual bars
|
||||||
after D to record the realized outcome. The report contains:
|
after D to record the realized outcome. The report contains:
|
||||||
|
|
||||||
@@ -99,7 +99,17 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
KEY_REPORT = "backtest_report"
|
KEY_REPORT = "backtest_report"
|
||||||
|
|
||||||
STEP_DAYS = 5 # weekly cadence (≈ 5 trading days)
|
WEEKLY_BACKTEST_CADENCE = "weekly"
|
||||||
|
DAILY_BACKTEST_CADENCE = "daily"
|
||||||
|
DEFAULT_BACKTEST_CADENCE = WEEKLY_BACKTEST_CADENCE
|
||||||
|
PRODUCTION_REENTRY_POLICY = "gate_reset"
|
||||||
|
BACKTEST_CADENCE_SESSIONS = {
|
||||||
|
WEEKLY_BACKTEST_CADENCE: 5,
|
||||||
|
DAILY_BACKTEST_CADENCE: 1,
|
||||||
|
}
|
||||||
|
# Compatibility alias for research scripts built around the original weekly
|
||||||
|
# replay. New code should select a cadence and call ``backtest_step_sessions``.
|
||||||
|
STEP_DAYS = BACKTEST_CADENCE_SESSIONS[WEEKLY_BACKTEST_CADENCE]
|
||||||
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
|
MIN_LOOKBACK = 60 # bars needed before D for indicators (EMA cross needs 51)
|
||||||
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
||||||
ATR_MULTIPLIER = 1.5
|
ATR_MULTIPLIER = 1.5
|
||||||
@@ -156,6 +166,30 @@ def validate_backtest_target_model(value: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backtest_cadence(value: str) -> str:
|
||||||
|
"""Validate the supported entry-replay cadences."""
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in BACKTEST_CADENCE_SESSIONS:
|
||||||
|
allowed = ", ".join(BACKTEST_CADENCE_SESSIONS)
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown backtest cadence {value!r}; expected one of {allowed}"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def backtest_step_sessions(cadence: str) -> int:
|
||||||
|
return BACKTEST_CADENCE_SESSIONS[validate_backtest_cadence(cadence)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ranking_period(as_of: date, cadence: str) -> tuple:
|
||||||
|
"""Cross-section key for activation ranks at the selected entry cadence."""
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
|
if cadence == DAILY_BACKTEST_CADENCE:
|
||||||
|
return ("date", as_of.toordinal())
|
||||||
|
iso = as_of.isocalendar()
|
||||||
|
return ("week", iso[0], iso[1])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# RESEARCH / DIAGNOSTIC FALLBACKS (retired experiments)
|
# RESEARCH / DIAGNOSTIC FALLBACKS (retired experiments)
|
||||||
#
|
#
|
||||||
@@ -455,14 +489,17 @@ def _replay_ticker(
|
|||||||
activation: dict,
|
activation: dict,
|
||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Walk one ticker's history weekly, building setups and their realized outcomes."""
|
"""Walk one ticker at the selected cadence and resolve each setup outcome."""
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
|
step_sessions = backtest_step_sessions(cadence)
|
||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
n = len(records)
|
n = len(records)
|
||||||
if n < MIN_LOOKBACK + HORIZON:
|
if n < MIN_LOOKBACK + HORIZON:
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
for i in range(MIN_LOOKBACK - 1, n - HORIZON, STEP_DAYS):
|
for i in range(MIN_LOOKBACK - 1, n - HORIZON, step_sessions):
|
||||||
window = records[: i + 1]
|
window = records[: i + 1]
|
||||||
forward = records[i + 1 :]
|
forward = records[i + 1 :]
|
||||||
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
||||||
@@ -511,6 +548,7 @@ def _replay_ticker(
|
|||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"date": records[i].date.isoformat(),
|
"date": records[i].date.isoformat(),
|
||||||
"iso_week": (iso[0], iso[1]),
|
"iso_week": (iso[0], iso[1]),
|
||||||
|
"ranking_period": _ranking_period(records[i].date, cadence),
|
||||||
"direction": s["direction"],
|
"direction": s["direction"],
|
||||||
"entry": s["entry"],
|
"entry": s["entry"],
|
||||||
"stop": s["stop"],
|
"stop": s["stop"],
|
||||||
@@ -967,6 +1005,7 @@ def _replay_and_signals(
|
|||||||
activation: dict,
|
activation: dict,
|
||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> tuple[list[dict], dict]:
|
) -> tuple[list[dict], dict]:
|
||||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
||||||
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
||||||
@@ -986,11 +1025,117 @@ def _replay_and_signals(
|
|||||||
activation,
|
activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
|
cadence,
|
||||||
),
|
),
|
||||||
_signal_series(bars, benchmark_closes),
|
_signal_series(bars, benchmark_closes),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_candidates_for_period(
|
||||||
|
symbol: str,
|
||||||
|
columns: tuple,
|
||||||
|
config: dict,
|
||||||
|
activation: dict,
|
||||||
|
benchmark_closes: dict[date, float] | None,
|
||||||
|
start_date: date,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
|
include_short_candidates: bool = False,
|
||||||
|
include_universe_rank_observations: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Slim picklable replay used by local event studies.
|
||||||
|
|
||||||
|
Unlike the full report worker it skips factor-series construction and only
|
||||||
|
evaluates setup dates on or after ``start_date``. Long-only remains the
|
||||||
|
compatibility default. Set ``include_short_candidates`` when the caller
|
||||||
|
needs the legacy full-backtest candidate-ranking universe; shorts can then
|
||||||
|
contribute to those historical percentiles while the portfolio simulator
|
||||||
|
still trades only qualified longs. ``include_universe_rank_observations``
|
||||||
|
additionally marks exactly one row per ticker/session for a live-like rank
|
||||||
|
across tickers rather than across directional setup candidates. If no setup
|
||||||
|
exists on that session, a non-tradeable rank-only row is emitted.
|
||||||
|
"""
|
||||||
|
date_ords, opens, highs, lows, closes, volumes = columns
|
||||||
|
bars = [
|
||||||
|
SimpleNamespace(
|
||||||
|
date=date.fromordinal(o), open=op, high=hi, low=lo, close=cl, volume=vo
|
||||||
|
)
|
||||||
|
for o, op, hi, lo, cl, vo in zip(
|
||||||
|
date_ords, opens, highs, lows, closes, volumes
|
||||||
|
)
|
||||||
|
]
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
|
candidates: list[dict] = []
|
||||||
|
for i in range(
|
||||||
|
MIN_LOOKBACK - 1,
|
||||||
|
len(bars) - HORIZON,
|
||||||
|
backtest_step_sessions(cadence),
|
||||||
|
):
|
||||||
|
if bars[i].date < start_date:
|
||||||
|
continue
|
||||||
|
window = bars[: i + 1]
|
||||||
|
window_closes = [float(r.close) for r in window]
|
||||||
|
window_dates = [r.date for r in window]
|
||||||
|
residual_momentum = _residual_momentum_12_1(
|
||||||
|
window_dates,
|
||||||
|
window_closes,
|
||||||
|
len(window) - 1,
|
||||||
|
benchmark_closes,
|
||||||
|
)
|
||||||
|
vol_6m = _realized_vol_6m(window_closes, len(window) - 1)
|
||||||
|
iso = bars[i].date.isocalendar()
|
||||||
|
raw_momentum = (
|
||||||
|
window_closes[-22] / window_closes[-253] - 1.0
|
||||||
|
if len(window_closes) >= 253 and window_closes[-253] > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
setups = [
|
||||||
|
setup
|
||||||
|
for setup in _window_setups(window, config, activation)
|
||||||
|
if include_short_candidates or setup["direction"] == "long"
|
||||||
|
]
|
||||||
|
observation_emitted = False
|
||||||
|
for setup in setups:
|
||||||
|
candidate = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"date": bars[i].date.isoformat(),
|
||||||
|
"iso_week": (iso[0], iso[1]),
|
||||||
|
"ranking_period": _ranking_period(bars[i].date, cadence),
|
||||||
|
"direction": setup["direction"],
|
||||||
|
"entry": setup["entry"],
|
||||||
|
"stop": setup["stop"],
|
||||||
|
"target": setup["target"],
|
||||||
|
"rr": setup["rr"],
|
||||||
|
"confidence": setup["confidence"],
|
||||||
|
"primary_prob": setup["primary_prob"],
|
||||||
|
"best_prob": setup["best_prob"],
|
||||||
|
"momentum": setup["momentum"],
|
||||||
|
"residual_momentum": residual_momentum,
|
||||||
|
"vol_6m": vol_6m,
|
||||||
|
"meets_core": setup["meets_core"],
|
||||||
|
"action": setup["action"],
|
||||||
|
"risk_level": setup["risk_level"],
|
||||||
|
}
|
||||||
|
if include_universe_rank_observations and not observation_emitted:
|
||||||
|
candidate["_universe_rank_observation"] = True
|
||||||
|
observation_emitted = True
|
||||||
|
candidates.append(candidate)
|
||||||
|
if include_universe_rank_observations and not observation_emitted:
|
||||||
|
candidates.append({
|
||||||
|
"symbol": symbol,
|
||||||
|
"date": bars[i].date.isoformat(),
|
||||||
|
"iso_week": (iso[0], iso[1]),
|
||||||
|
"ranking_period": _ranking_period(bars[i].date, cadence),
|
||||||
|
"direction": "rank_only",
|
||||||
|
"momentum": raw_momentum,
|
||||||
|
"residual_momentum": residual_momentum,
|
||||||
|
"vol_6m": vol_6m,
|
||||||
|
"meets_core": False,
|
||||||
|
"_universe_rank_observation": True,
|
||||||
|
"_rank_only": True,
|
||||||
|
})
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def _backtest_worker_count() -> int:
|
def _backtest_worker_count() -> int:
|
||||||
"""How many worker processes to replay tickers across. Capped to cpu_count-1
|
"""How many worker processes to replay tickers across. Capped to cpu_count-1
|
||||||
so a core stays free for the web server; 1 means sequential."""
|
so a core stays free for the web server; 1 means sequential."""
|
||||||
@@ -1057,14 +1202,17 @@ def _assign_signal_percentiles(
|
|||||||
value_key: str,
|
value_key: str,
|
||||||
percentile_key: str,
|
percentile_key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Per ISO week, rank candidates by ``value_key`` and attach a 0-100
|
"""Per replay period, rank candidates by ``value_key`` and attach a 0-100
|
||||||
percentile under ``percentile_key`` (100 = strongest). Missing values get
|
percentile under ``percentile_key`` (100 = strongest). Missing values get
|
||||||
None and therefore cannot clear a gate based on that signal."""
|
None and therefore cannot clear a gate based on that signal."""
|
||||||
by_week: dict = defaultdict(list)
|
by_period: dict = defaultdict(list)
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
if c.get(value_key) is not None:
|
if c.get(value_key) is not None:
|
||||||
by_week[c["iso_week"]].append(c)
|
# Hand-built/research candidates predating the cadence flag retain
|
||||||
for group in by_week.values():
|
# the weekly key as a compatibility fallback.
|
||||||
|
period = c.get("ranking_period") or c["iso_week"]
|
||||||
|
by_period[period].append(c)
|
||||||
|
for group in by_period.values():
|
||||||
ordered = sorted(group, key=lambda c: c[value_key])
|
ordered = sorted(group, key=lambda c: c[value_key])
|
||||||
n = len(ordered)
|
n = len(ordered)
|
||||||
for rank, c in enumerate(ordered):
|
for rank, c in enumerate(ordered):
|
||||||
@@ -1074,9 +1222,9 @@ def _assign_signal_percentiles(
|
|||||||
|
|
||||||
|
|
||||||
def _assign_momentum_percentiles(candidates: list[dict]) -> None:
|
def _assign_momentum_percentiles(candidates: list[dict]) -> None:
|
||||||
"""Per ISO week, rank candidates by their ticker's 12-1 momentum and attach a
|
"""Per replay period, rank candidates by 12-1 momentum and attach a
|
||||||
0-100 ``momentum_percentile`` (100 = highest momentum in the universe that
|
0-100 ``momentum_percentile`` (100 = highest momentum in the universe that
|
||||||
week). Candidates whose momentum is unknown (insufficient lookback) get None
|
period). Candidates whose momentum is unknown (insufficient lookback) get None
|
||||||
and therefore can't clear a momentum gate. Mutates ``candidates``."""
|
and therefore can't clear a momentum gate. Mutates ``candidates``."""
|
||||||
_assign_signal_percentiles(candidates, "momentum", "momentum_percentile")
|
_assign_signal_percentiles(candidates, "momentum", "momentum_percentile")
|
||||||
|
|
||||||
@@ -1089,7 +1237,7 @@ def _assign_residual_momentum_percentiles(candidates: list[dict]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _assign_low_volatility_percentiles(candidates: list[dict]) -> None:
|
def _assign_low_volatility_percentiles(candidates: list[dict]) -> None:
|
||||||
"""Per ISO week, attach volatility ranks where 100 = lowest 6-month vol."""
|
"""Per replay period, attach volatility ranks where 100 = lowest 6-month vol."""
|
||||||
_assign_signal_percentiles(candidates, "vol_6m", VOL_PERCENTILE_KEY)
|
_assign_signal_percentiles(candidates, "vol_6m", VOL_PERCENTILE_KEY)
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
raw = c.get(VOL_PERCENTILE_KEY)
|
raw = c.get(VOL_PERCENTILE_KEY)
|
||||||
@@ -1281,6 +1429,72 @@ LIVE_EXIT_MODE_TO_SIM = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_gate_reset_reentry_fn(
|
||||||
|
candidates: list[dict],
|
||||||
|
prices: dict[str, tuple],
|
||||||
|
*,
|
||||||
|
cadence: str,
|
||||||
|
qualified_fn: Callable[[dict], bool] | None = None,
|
||||||
|
ranking_key: str = PRODUCTION_PERCENTILE_KEY,
|
||||||
|
) -> Callable[[str, int, dict, Any], dict | None]:
|
||||||
|
"""Build the production post-stop gate-reset callback.
|
||||||
|
|
||||||
|
Missing candidates count as a gate failure only on dates on which that
|
||||||
|
ticker was actually evaluated at the selected replay cadence. This keeps a
|
||||||
|
weekly backtest from treating the four non-evaluation sessions between two
|
||||||
|
weekly observations as false gate exits.
|
||||||
|
"""
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
|
if qualified_fn is None:
|
||||||
|
def _default_qualified(candidate: dict) -> bool:
|
||||||
|
return bool(candidate.get("qualified"))
|
||||||
|
|
||||||
|
qualified_fn = _default_qualified
|
||||||
|
|
||||||
|
evaluation_ords: dict[str, set[int]] = {}
|
||||||
|
step_sessions = backtest_step_sessions(cadence)
|
||||||
|
for symbol, columns in prices.items():
|
||||||
|
ordinals = columns[0]
|
||||||
|
evaluation_ords[symbol] = {
|
||||||
|
int(ordinals[index])
|
||||||
|
for index in range(MIN_LOOKBACK - 1, len(ordinals) - HORIZON, step_sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualified_by_symbol_date: dict[tuple[str, int], dict] = {}
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.get("direction") != "long" or not qualified_fn(candidate):
|
||||||
|
continue
|
||||||
|
key = (
|
||||||
|
str(candidate["symbol"]),
|
||||||
|
date.fromisoformat(str(candidate["date"])).toordinal(),
|
||||||
|
)
|
||||||
|
previous = qualified_by_symbol_date.get(key)
|
||||||
|
if previous is None or float(candidate.get(ranking_key) or 0.0) > float(
|
||||||
|
previous.get(ranking_key) or 0.0
|
||||||
|
):
|
||||||
|
qualified_by_symbol_date[key] = candidate
|
||||||
|
|
||||||
|
def _gate_reset(
|
||||||
|
symbol: str,
|
||||||
|
asof_ord: int,
|
||||||
|
state: dict,
|
||||||
|
_bar: Any,
|
||||||
|
) -> dict | None:
|
||||||
|
if asof_ord not in evaluation_ords.get(symbol, set()):
|
||||||
|
return None
|
||||||
|
candidate = qualified_by_symbol_date.get((symbol, asof_ord))
|
||||||
|
if candidate is None:
|
||||||
|
state["gate_went_unqualified"] = True
|
||||||
|
return None
|
||||||
|
if not state.get("gate_went_unqualified"):
|
||||||
|
return None
|
||||||
|
emitted = dict(candidate)
|
||||||
|
emitted["_reentry_reason"] = "gate_failed_then_requalified"
|
||||||
|
return emitted
|
||||||
|
|
||||||
|
return _gate_reset
|
||||||
|
|
||||||
|
|
||||||
def _simulate_portfolio(
|
def _simulate_portfolio(
|
||||||
candidates: list[dict],
|
candidates: list[dict],
|
||||||
prices: dict[str, tuple],
|
prices: dict[str, tuple],
|
||||||
@@ -1293,9 +1507,18 @@ def _simulate_portfolio(
|
|||||||
max_positions: int = SIM_MAX_POSITIONS,
|
max_positions: int = SIM_MAX_POSITIONS,
|
||||||
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
||||||
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
|
||||||
|
cost_per_side: float = COST_PER_SIDE,
|
||||||
|
reentry_cooldown_sessions: int = 0,
|
||||||
|
initial_stop_refresh_fn: (
|
||||||
|
Callable[[str, int, float, dict, Any], float | None] | None
|
||||||
|
) = None,
|
||||||
|
post_stop_reentry_fn: (
|
||||||
|
Callable[[str, int, dict, Any], dict | None] | None
|
||||||
|
) = None,
|
||||||
start_date: date | None = None,
|
start_date: date | None = None,
|
||||||
end_date: date | None = None,
|
end_date: date | None = None,
|
||||||
include_curve: bool = False,
|
include_curve: bool = False,
|
||||||
|
include_trades: bool = False,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||||
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
portfolio economics from the daily equity curve (return, CAGR, drawdown,
|
||||||
@@ -1309,8 +1532,19 @@ def _simulate_portfolio(
|
|||||||
runs the ATR trail *and* the S/R take-profit together — the trade ends at
|
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
|
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.
|
modeled); positions still open at the end are closed at their last mark.
|
||||||
Returns None when there is nothing to trade.
|
``reentry_cooldown_sessions`` blocks a ticker for that many market sessions
|
||||||
|
after an initial-stop loss. Profitable trailing-stop exits do not trigger
|
||||||
|
it. ``initial_stop_refresh_fn`` may supply a lower, point-in-time valid long
|
||||||
|
stop when the active initial stop is touched; the replacement is still
|
||||||
|
checked against the same bar. ``post_stop_reentry_fn`` turns an initial
|
||||||
|
stop-out into a stateful episode and is the only path by which that ticker
|
||||||
|
can re-enter until the callback emits a new candidate. Returns None when
|
||||||
|
there is nothing to trade. ``cost_per_side`` is charged on entry and exit
|
||||||
|
and therefore changes both cash availability and subsequent position sizing.
|
||||||
"""
|
"""
|
||||||
|
cost_rate = float(cost_per_side)
|
||||||
|
if not 0.0 <= cost_rate < 1.0:
|
||||||
|
raise ValueError("cost_per_side must be between 0 (inclusive) and 1")
|
||||||
if qualified_fn is None:
|
if qualified_fn is None:
|
||||||
def _default_qualified(c: dict) -> bool:
|
def _default_qualified(c: dict) -> bool:
|
||||||
return bool(c.get("qualified"))
|
return bool(c.get("qualified"))
|
||||||
@@ -1362,6 +1596,14 @@ def _simulate_portfolio(
|
|||||||
curve: list[tuple[int, float]] = []
|
curve: list[tuple[int, float]] = []
|
||||||
trades: list[dict] = []
|
trades: list[dict] = []
|
||||||
skipped_full = 0
|
skipped_full = 0
|
||||||
|
skipped_cooldown = 0
|
||||||
|
cooldown_until_index: dict[str, int] = {}
|
||||||
|
stop_refresh_attempts = 0
|
||||||
|
stop_refreshes = 0
|
||||||
|
stop_refresh_same_bar_hits = 0
|
||||||
|
post_stop_states: dict[str, dict] = {}
|
||||||
|
post_stop_events = 0
|
||||||
|
reentry_events: list[dict] = []
|
||||||
technical_cache: dict[tuple[str, int], float | None] = {}
|
technical_cache: dict[tuple[str, int], float | None] = {}
|
||||||
atr_cache: dict[tuple[str, int], float | None] = {}
|
atr_cache: dict[tuple[str, int], float | None] = {}
|
||||||
|
|
||||||
@@ -1426,24 +1668,37 @@ def _simulate_portfolio(
|
|||||||
atr_cache[key] = None
|
atr_cache[key] = None
|
||||||
return atr_cache[key]
|
return atr_cache[key]
|
||||||
|
|
||||||
def _close_trade(sym: str, fill: float, reason: str) -> None:
|
def _close_trade(sym: str, fill: float, reason: str) -> dict:
|
||||||
nonlocal cash
|
nonlocal cash
|
||||||
pos = positions.pop(sym)
|
pos = positions.pop(sym)
|
||||||
proceeds = pos["shares"] * fill
|
proceeds = pos["shares"] * fill
|
||||||
cost = proceeds * COST_PER_SIDE
|
cost = proceeds * cost_rate
|
||||||
cash += proceeds - cost
|
cash += proceeds - cost
|
||||||
risk = pos["entry"] - pos["initial_stop"]
|
risk = pos["entry"] - pos["initial_stop"]
|
||||||
trades.append({
|
trades.append({
|
||||||
|
"symbol": sym,
|
||||||
|
"entry_ord": pos["entry_ord"],
|
||||||
|
"exit_ord": o,
|
||||||
|
"entry": pos["entry"],
|
||||||
|
"initial_stop": pos["initial_stop"],
|
||||||
|
"active_stop": pos["stop"],
|
||||||
|
"fill": fill,
|
||||||
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
|
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
|
||||||
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
||||||
"hold": pos["bars_held"],
|
"hold": pos["bars_held"],
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
|
"stop_refreshes": pos["stop_refreshes"],
|
||||||
|
"is_reentry": pos["is_reentry"],
|
||||||
|
"reentry_wait_sessions": pos["reentry_wait_sessions"],
|
||||||
|
"transaction_cost": pos["entry_cost"] + cost,
|
||||||
})
|
})
|
||||||
|
return pos
|
||||||
|
|
||||||
def _marked_equity() -> float:
|
def _marked_equity() -> float:
|
||||||
return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
|
return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
|
||||||
|
|
||||||
for o in calendar:
|
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
|
||||||
|
for calendar_index, o in enumerate(calendar):
|
||||||
# 1) exits on today's bars (stop intraday, target intraday, time at close)
|
# 1) exits on today's bars (stop intraday, target intraday, time at close)
|
||||||
for sym in list(positions):
|
for sym in list(positions):
|
||||||
pos = positions[sym]
|
pos = positions[sym]
|
||||||
@@ -1460,8 +1715,43 @@ def _simulate_portfolio(
|
|||||||
if pos["stop"] > pos["initial_stop"] + 1e-9
|
if pos["stop"] > pos["initial_stop"] + 1e-9
|
||||||
else "stop"
|
else "stop"
|
||||||
)
|
)
|
||||||
_close_trade(sym, min(pos["stop"], bar.open), reason)
|
survived_refresh = False
|
||||||
continue
|
if reason == "stop" and initial_stop_refresh_fn is not None:
|
||||||
|
stop_refresh_attempts += 1
|
||||||
|
refreshed_stop = initial_stop_refresh_fn(
|
||||||
|
sym, o, float(pos["stop"]), pos, bar
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
refreshed_stop is not None
|
||||||
|
and 0 < float(refreshed_stop) < pos["stop"] - 1e-9
|
||||||
|
):
|
||||||
|
pos["stop"] = float(refreshed_stop)
|
||||||
|
pos["stop_refreshes"] += 1
|
||||||
|
stop_refreshes += 1
|
||||||
|
if bar.low > pos["stop"]:
|
||||||
|
survived_refresh = True
|
||||||
|
else:
|
||||||
|
stop_refresh_same_bar_hits += 1
|
||||||
|
if not survived_refresh:
|
||||||
|
fill = min(pos["stop"], bar.open)
|
||||||
|
closed_pos = _close_trade(sym, fill, reason)
|
||||||
|
if reason == "stop" and cooldown_sessions:
|
||||||
|
cooldown_until_index[sym] = calendar_index + cooldown_sessions
|
||||||
|
if reason == "stop" and post_stop_reentry_fn is not None:
|
||||||
|
post_stop_events += 1
|
||||||
|
post_stop_states[sym] = {
|
||||||
|
"stop_ord": o,
|
||||||
|
"stop_calendar_index": calendar_index,
|
||||||
|
"stop_day_high": float(bar.high),
|
||||||
|
"stop_day_low": float(bar.low),
|
||||||
|
"stop_day_close": float(bar.close),
|
||||||
|
"exit_fill": float(fill),
|
||||||
|
"previous_entry": float(closed_pos["entry"]),
|
||||||
|
"previous_stop": float(closed_pos["initial_stop"]),
|
||||||
|
"previous_rank": closed_pos["entry_rank"],
|
||||||
|
"gate_went_unqualified": False,
|
||||||
|
}
|
||||||
|
continue
|
||||||
if exit_policy in ("target", "atr_trail3_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")
|
_close_trade(sym, pos["target"], "target")
|
||||||
continue
|
continue
|
||||||
@@ -1493,8 +1783,31 @@ def _simulate_portfolio(
|
|||||||
|
|
||||||
# 2) entries at today's close, best momentum first
|
# 2) entries at today's close, best momentum first
|
||||||
equity = _marked_equity()
|
equity = _marked_equity()
|
||||||
|
fixed_todays = list(entries_by_ord.get(o, ()))
|
||||||
|
reentry_todays: list[dict] = []
|
||||||
|
if post_stop_reentry_fn is not None and (
|
||||||
|
end_ord is None or o < end_ord
|
||||||
|
):
|
||||||
|
fixed_todays = [
|
||||||
|
candidate
|
||||||
|
for candidate in fixed_todays
|
||||||
|
if candidate["symbol"] not in post_stop_states
|
||||||
|
]
|
||||||
|
for sym, state in list(post_stop_states.items()):
|
||||||
|
bar = _bar(sym, o)
|
||||||
|
if bar is None:
|
||||||
|
continue
|
||||||
|
state["sessions_since_stop"] = (
|
||||||
|
calendar_index - state["stop_calendar_index"]
|
||||||
|
)
|
||||||
|
candidate = post_stop_reentry_fn(sym, o, state, bar)
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
tagged = dict(candidate)
|
||||||
|
tagged["_post_stop_reentry"] = True
|
||||||
|
reentry_todays.append(tagged)
|
||||||
todays = sorted(
|
todays = sorted(
|
||||||
entries_by_ord.get(o, ()),
|
fixed_todays + reentry_todays,
|
||||||
key=lambda c: c.get(ranking_key) or 0.0,
|
key=lambda c: c.get(ranking_key) or 0.0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
@@ -1502,6 +1815,9 @@ def _simulate_portfolio(
|
|||||||
sym = c["symbol"]
|
sym = c["symbol"]
|
||||||
if sym in positions:
|
if sym in positions:
|
||||||
continue
|
continue
|
||||||
|
if calendar_index < cooldown_until_index.get(sym, -1):
|
||||||
|
skipped_cooldown += 1
|
||||||
|
continue
|
||||||
if len(positions) >= max_positions:
|
if len(positions) >= max_positions:
|
||||||
skipped_full += 1
|
skipped_full += 1
|
||||||
continue
|
continue
|
||||||
@@ -1512,15 +1828,29 @@ def _simulate_portfolio(
|
|||||||
shares = min(
|
shares = min(
|
||||||
(equity * risk_per_trade) / risk_ps,
|
(equity * risk_per_trade) / risk_ps,
|
||||||
(equity * SIM_NOTIONAL_CAP) / entry,
|
(equity * SIM_NOTIONAL_CAP) / entry,
|
||||||
max(cash, 0.0) / (entry * (1.0 + COST_PER_SIDE)),
|
max(cash, 0.0) / (entry * (1.0 + cost_rate)),
|
||||||
)
|
)
|
||||||
if shares * entry < 1.0: # can't fund a meaningful position
|
if shares * entry < 1.0: # can't fund a meaningful position
|
||||||
continue
|
continue
|
||||||
entry_cost = shares * entry * COST_PER_SIDE
|
entry_cost = shares * entry * cost_rate
|
||||||
cash -= shares * entry + entry_cost
|
cash -= shares * entry + entry_cost
|
||||||
|
is_reentry = bool(c.get("_post_stop_reentry"))
|
||||||
|
reentry_wait_sessions: int | None = None
|
||||||
|
if is_reentry:
|
||||||
|
state = post_stop_states.pop(sym, None)
|
||||||
|
if state is not None:
|
||||||
|
reentry_wait_sessions = int(state["sessions_since_stop"])
|
||||||
|
reentry_events.append({
|
||||||
|
"symbol": sym,
|
||||||
|
"stop_ord": state["stop_ord"],
|
||||||
|
"reentry_ord": o,
|
||||||
|
"wait_sessions": reentry_wait_sessions,
|
||||||
|
"reason": c.get("_reentry_reason"),
|
||||||
|
})
|
||||||
positions[sym] = {
|
positions[sym] = {
|
||||||
"shares": shares,
|
"shares": shares,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
|
"entry_ord": o,
|
||||||
"initial_stop": stop,
|
"initial_stop": stop,
|
||||||
"stop": stop,
|
"stop": stop,
|
||||||
"target": float(c["target"]) if c.get("target") else None,
|
"target": float(c["target"]) if c.get("target") else None,
|
||||||
@@ -1528,6 +1858,12 @@ def _simulate_portfolio(
|
|||||||
"bars_held": 0,
|
"bars_held": 0,
|
||||||
"last_close": entry,
|
"last_close": entry,
|
||||||
"highest_close": entry,
|
"highest_close": entry,
|
||||||
|
"entry_rank": (
|
||||||
|
float(c[ranking_key]) if c.get(ranking_key) is not None else None
|
||||||
|
),
|
||||||
|
"stop_refreshes": 0,
|
||||||
|
"is_reentry": is_reentry,
|
||||||
|
"reentry_wait_sessions": reentry_wait_sessions,
|
||||||
}
|
}
|
||||||
equity = _marked_equity()
|
equity = _marked_equity()
|
||||||
|
|
||||||
@@ -1633,6 +1969,7 @@ def _simulate_portfolio(
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
"starting_capital": SIM_STARTING_CAPITAL,
|
"starting_capital": SIM_STARTING_CAPITAL,
|
||||||
|
"cost_per_side_pct": round(cost_rate * 100.0, 3),
|
||||||
"final_equity": round(final_equity, 2),
|
"final_equity": round(final_equity, 2),
|
||||||
"total_return_pct": round(total_return_pct, 1),
|
"total_return_pct": round(total_return_pct, 1),
|
||||||
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
||||||
@@ -1659,6 +1996,42 @@ def _simulate_portfolio(
|
|||||||
result["equity_curve"] = curve_payload
|
result["equity_curve"] = curve_payload
|
||||||
if benchmark_payload is not None:
|
if benchmark_payload is not None:
|
||||||
result["benchmark_curve"] = benchmark_payload
|
result["benchmark_curve"] = benchmark_payload
|
||||||
|
if cooldown_sessions:
|
||||||
|
result["reentry_cooldown_sessions"] = cooldown_sessions
|
||||||
|
result["skipped_cooldown"] = skipped_cooldown
|
||||||
|
if initial_stop_refresh_fn is not None:
|
||||||
|
result["stop_refresh_attempts"] = stop_refresh_attempts
|
||||||
|
result["stop_refreshes"] = stop_refreshes
|
||||||
|
result["stop_refresh_same_bar_hits"] = stop_refresh_same_bar_hits
|
||||||
|
if post_stop_reentry_fn is not None:
|
||||||
|
result["post_stop_events"] = post_stop_events
|
||||||
|
result["post_stop_reentries"] = len(reentry_events)
|
||||||
|
result["post_stop_states_open_at_end"] = len(post_stop_states)
|
||||||
|
result["reentry_events"] = [
|
||||||
|
{
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in event.items()
|
||||||
|
if key not in {"stop_ord", "reentry_ord"}
|
||||||
|
},
|
||||||
|
"stop_date": date.fromordinal(event["stop_ord"]).isoformat(),
|
||||||
|
"reentry_date": date.fromordinal(event["reentry_ord"]).isoformat(),
|
||||||
|
}
|
||||||
|
for event in reentry_events
|
||||||
|
]
|
||||||
|
if include_trades:
|
||||||
|
result["trade_details"] = [
|
||||||
|
{
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in trade.items()
|
||||||
|
if key not in {"entry_ord", "exit_ord"}
|
||||||
|
},
|
||||||
|
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
|
||||||
|
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
|
||||||
|
}
|
||||||
|
for trade in trades
|
||||||
|
]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -2018,19 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
|||||||
"exit_policy": "hold",
|
"exit_policy": "hold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
"strategy": "production_live_immediate",
|
||||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail",
|
"label": "Live setup + 3x ATR trail (immediate re-entry)",
|
||||||
"description": (
|
"description": (
|
||||||
"The live strategy: production activation gate and Admin exit policy "
|
"Exact live activation, ordering, and Admin exit policy, with only "
|
||||||
"as currently configured, 80/20 residual/high-vol rank."
|
"the post-stop gate reset disabled as the comparison baseline."
|
||||||
),
|
),
|
||||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||||
"exit_policy": "atr_trail3",
|
"exit_policy": "atr_trail3",
|
||||||
|
"reentry_policy": "immediate",
|
||||||
|
"use_live_config": True,
|
||||||
|
"comparison_arm": "live_immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
||||||
|
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + gate reset",
|
||||||
|
"description": (
|
||||||
|
"The live strategy: production activation gate and Admin exit policy "
|
||||||
|
"as currently configured, 80/20 residual/high-vol rank, and re-entry "
|
||||||
|
"only after the gate fails and later qualifies again."
|
||||||
|
),
|
||||||
|
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||||
|
"exit_policy": "atr_trail3",
|
||||||
|
"reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||||
# The production row replays what the platform actually does right now:
|
# The production row replays what the platform actually does right now:
|
||||||
# the live qualification flag (runtime Admin activation settings) and the
|
# the live qualification flag (runtime Admin activation settings) and the
|
||||||
# live Admin exit policy, instead of the frozen research-variant gate.
|
# live Admin exit policy, instead of the frozen research-variant gate.
|
||||||
"use_live_config": True,
|
"use_live_config": True,
|
||||||
"is_production": True,
|
"is_production": True,
|
||||||
|
"comparison_arm": "live_gate_reset",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2133,6 +2522,7 @@ def _min_rr_sweep(
|
|||||||
threshold: float,
|
threshold: float,
|
||||||
hold_days: int,
|
hold_days: int,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Portfolio economics of the production book at each R:R floor.
|
"""Portfolio economics of the production book at each R:R floor.
|
||||||
|
|
||||||
@@ -2149,6 +2539,7 @@ def _min_rr_sweep(
|
|||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
@@ -2188,6 +2579,19 @@ def _min_rr_sweep(
|
|||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
post_stop_reentry_fn=(
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=str(
|
||||||
|
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
),
|
||||||
start_date=sweep_start,
|
start_date=sweep_start,
|
||||||
)
|
)
|
||||||
if sim is None:
|
if sim is None:
|
||||||
@@ -2212,6 +2616,7 @@ def _min_rr_sweep(
|
|||||||
"live_qualified_setups": live_qualified,
|
"live_qualified_setups": live_qualified,
|
||||||
"reproduces_production_gate": reproduces,
|
"reproduces_production_gate": reproduces,
|
||||||
"exit_policy": exit_policy,
|
"exit_policy": exit_policy,
|
||||||
|
"reentry_policy": reentry_policy,
|
||||||
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
||||||
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
@@ -2245,6 +2650,7 @@ def _holdout_evaluation(
|
|||||||
hold_days: int,
|
hold_days: int,
|
||||||
split: date,
|
split: date,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The production strategy simulated on entries BEFORE the split (train) and
|
"""The production strategy simulated on entries BEFORE the split (train) and
|
||||||
on entries ON/AFTER it (test), as separate books.
|
on entries ON/AFTER it (test), as separate books.
|
||||||
@@ -2267,6 +2673,7 @@ def _holdout_evaluation(
|
|||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
if strategy.get("use_live_config") and live_exit_policy is not None:
|
if strategy.get("use_live_config") and live_exit_policy is not None:
|
||||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
@@ -2279,6 +2686,20 @@ def _holdout_evaluation(
|
|||||||
None if strategy.get("use_live_config")
|
None if strategy.get("use_live_config")
|
||||||
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
||||||
)
|
)
|
||||||
|
ranking_key = str(
|
||||||
|
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
|
||||||
|
)
|
||||||
|
post_stop_reentry_fn = (
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for window, start, end in (
|
for window, start, end in (
|
||||||
@@ -2292,10 +2713,11 @@ def _holdout_evaluation(
|
|||||||
exit_policy,
|
exit_policy,
|
||||||
row_hold_days,
|
row_hold_days,
|
||||||
qualified_fn=qualified_fn,
|
qualified_fn=qualified_fn,
|
||||||
ranking_key=str(entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]),
|
ranking_key=ranking_key,
|
||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
@@ -2307,6 +2729,7 @@ def _holdout_evaluation(
|
|||||||
return {
|
return {
|
||||||
"split_date": split.isoformat(),
|
"split_date": split.isoformat(),
|
||||||
"strategy": strategy["strategy"],
|
"strategy": strategy["strategy"],
|
||||||
|
"reentry_policy": reentry_policy,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"note": (
|
"note": (
|
||||||
"Train = entries before the split; test = entries on/after it. The two "
|
"Train = entries before the split; test = entries on/after it. The two "
|
||||||
@@ -2322,6 +2745,7 @@ def _portfolio_monitor(
|
|||||||
_spy_closes: dict[date, float] | None,
|
_spy_closes: dict[date, float] | None,
|
||||||
hold_days: int,
|
hold_days: int,
|
||||||
live_exit_policy: dict | None = None,
|
live_exit_policy: dict | None = None,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
@@ -2339,6 +2763,7 @@ def _portfolio_monitor(
|
|||||||
# policy. The overlay opts into this deliberately so only ordering
|
# policy. The overlay opts into this deliberately so only ordering
|
||||||
# changes relative to the production row.
|
# changes relative to the production row.
|
||||||
use_live = bool(strategy.get("use_live_config"))
|
use_live = bool(strategy.get("use_live_config"))
|
||||||
|
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||||
exit_policy = str(strategy["exit_policy"])
|
exit_policy = str(strategy["exit_policy"])
|
||||||
row_hold_days = hold_days
|
row_hold_days = hold_days
|
||||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||||
@@ -2354,6 +2779,17 @@ def _portfolio_monitor(
|
|||||||
None if use_live
|
None if use_live
|
||||||
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
else lambda c, config=entry_cfg: _qualifies_strategy_variant(c, config)
|
||||||
)
|
)
|
||||||
|
post_stop_reentry_fn = (
|
||||||
|
_make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence=cadence,
|
||||||
|
qualified_fn=qualified_fn,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
)
|
||||||
|
if reentry_policy == "gate_reset"
|
||||||
|
else None
|
||||||
|
)
|
||||||
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
|
for lookback in PORTFOLIO_MONITOR_LOOKBACKS:
|
||||||
start = _lookback_start(latest_ord, lookback["days"])
|
start = _lookback_start(latest_ord, lookback["days"])
|
||||||
sim = _simulate_portfolio(
|
sim = _simulate_portfolio(
|
||||||
@@ -2367,6 +2803,7 @@ def _portfolio_monitor(
|
|||||||
max_positions=int(entry_cfg["max_positions"]),
|
max_positions=int(entry_cfg["max_positions"]),
|
||||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||||
atr_trail_multiplier=trail_multiplier,
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
)
|
)
|
||||||
@@ -2377,10 +2814,12 @@ def _portfolio_monitor(
|
|||||||
"label": strategy["label"],
|
"label": strategy["label"],
|
||||||
"description": strategy["description"],
|
"description": strategy["description"],
|
||||||
"is_production": bool(strategy.get("is_production")),
|
"is_production": bool(strategy.get("is_production")),
|
||||||
|
"comparison_arm": strategy.get("comparison_arm"),
|
||||||
"entry_variant": strategy["entry_variant"],
|
"entry_variant": strategy["entry_variant"],
|
||||||
"ranking_key": ranking_key,
|
"ranking_key": ranking_key,
|
||||||
"exit_policy": exit_policy,
|
"exit_policy": exit_policy,
|
||||||
"live_exit_mode": live_exit_mode,
|
"live_exit_mode": live_exit_mode,
|
||||||
|
"reentry_policy": reentry_policy,
|
||||||
"lookback": lookback["lookback"],
|
"lookback": lookback["lookback"],
|
||||||
"lookback_label": lookback["label"],
|
"lookback_label": lookback["label"],
|
||||||
**sim,
|
**sim,
|
||||||
@@ -2393,6 +2832,8 @@ def _portfolio_monitor(
|
|||||||
"label": s["label"],
|
"label": s["label"],
|
||||||
"description": s["description"],
|
"description": s["description"],
|
||||||
"is_production": bool(s.get("is_production")),
|
"is_production": bool(s.get("is_production")),
|
||||||
|
"comparison_arm": s.get("comparison_arm"),
|
||||||
|
"reentry_policy": str(s.get("reentry_policy", "immediate")),
|
||||||
}
|
}
|
||||||
for s in strategies
|
for s in strategies
|
||||||
],
|
],
|
||||||
@@ -2405,7 +2846,48 @@ def _portfolio_monitor(
|
|||||||
"Portfolio monitor runs supported named strategies across cached lookbacks. "
|
"Portfolio monitor runs supported named strategies across cached lookbacks. "
|
||||||
"The structural overlay appears only in its explicit research arm and changes "
|
"The structural overlay appears only in its explicit research arm and changes "
|
||||||
"ordering, not production qualification. Local snapshot backtests remain the "
|
"ordering, not production qualification. Local snapshot backtests remain the "
|
||||||
"research surface for broad variant sweeps."
|
"research surface for broad variant sweeps. The production row applies the "
|
||||||
|
"same post-initial-stop gate-reset rule as the live setup list."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _production_cadence_comparison(
|
||||||
|
monitor: dict | None,
|
||||||
|
cadence: str,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Compact full-history live/immediate vs live/gate-reset comparison."""
|
||||||
|
if not monitor:
|
||||||
|
return None
|
||||||
|
arms: list[dict] = []
|
||||||
|
for row in monitor.get("runs") or []:
|
||||||
|
comparison_arm = row.get("comparison_arm")
|
||||||
|
if not comparison_arm or row.get("lookback") != "all":
|
||||||
|
continue
|
||||||
|
compact = {
|
||||||
|
key: value
|
||||||
|
for key, value in row.items()
|
||||||
|
if key not in {"equity_curve", "benchmark_curve"}
|
||||||
|
}
|
||||||
|
arm_name = (
|
||||||
|
"prod_live_setup"
|
||||||
|
if comparison_arm == "live_immediate"
|
||||||
|
else "gate_reset"
|
||||||
|
)
|
||||||
|
compact["arm"] = f"{arm_name}_{cadence}"
|
||||||
|
compact["entry_cadence"] = cadence
|
||||||
|
arms.append(compact)
|
||||||
|
if not arms:
|
||||||
|
return None
|
||||||
|
arms.sort(key=lambda row: row.get("reentry_policy") != "immediate")
|
||||||
|
return {
|
||||||
|
"entry_cadence": cadence,
|
||||||
|
"lookback": "all",
|
||||||
|
"arms": arms,
|
||||||
|
"note": (
|
||||||
|
"Both arms use the exact same live gate, ordering, Admin exit policy, "
|
||||||
|
"fees, and candidate cadence. Only the post-stop gate-reset rule "
|
||||||
|
"changes."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2618,7 +3100,8 @@ def _build_recommendation(report: dict) -> dict:
|
|||||||
if production_row is not None:
|
if production_row is not None:
|
||||||
headline = (
|
headline = (
|
||||||
"Production baseline: residual/high-vol 80/20 entry rank with a "
|
"Production baseline: residual/high-vol 80/20 entry rank with a "
|
||||||
"3x ATR trailing exit and 30-trading-day max hold."
|
"3x ATR trailing exit, 30-trading-day max hold, and re-entry only "
|
||||||
|
"after the gate fails and later qualifies again."
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
production_row.get("cagr_pct") is not None
|
production_row.get("cagr_pct") is not None
|
||||||
@@ -2787,9 +3270,11 @@ async def run_backtest(
|
|||||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||||
*,
|
*,
|
||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
|
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
|
||||||
target_model = validate_backtest_target_model(target_model)
|
target_model = validate_backtest_target_model(target_model)
|
||||||
|
cadence = validate_backtest_cadence(cadence)
|
||||||
config = await get_recommendation_config(db)
|
config = await get_recommendation_config(db)
|
||||||
activation = await get_activation_config(db)
|
activation = await get_activation_config(db)
|
||||||
|
|
||||||
@@ -2798,7 +3283,9 @@ async def run_backtest(
|
|||||||
total = len(tickers)
|
total = len(tickers)
|
||||||
|
|
||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
# collected[signal_name][iso_week] -> list of (signal_value, forward_return)
|
# Signal IC remains a weekly, non-overlapping diagnostic regardless of the
|
||||||
|
# entry cadence. Production activation ranks are assigned from candidates
|
||||||
|
# at their own weekly or exact-date ``ranking_period`` below.
|
||||||
collected: dict = defaultdict(lambda: defaultdict(list))
|
collected: dict = defaultdict(lambda: defaultdict(list))
|
||||||
|
|
||||||
# Residual momentum needs a point-in-time benchmark return stream. Best-effort:
|
# Residual momentum needs a point-in-time benchmark return stream. Best-effort:
|
||||||
@@ -2855,6 +3342,7 @@ async def run_backtest(
|
|||||||
pool, _replay_and_signals, ticker.symbol, columns, config, activation,
|
pool, _replay_and_signals, ticker.symbol, columns, config, activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
|
cadence,
|
||||||
))
|
))
|
||||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -2877,6 +3365,7 @@ async def run_backtest(
|
|||||||
_replay_and_signals, ticker.symbol, columns, config, activation,
|
_replay_and_signals, ticker.symbol, columns, config, activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
|
cadence,
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||||
@@ -2966,17 +3455,19 @@ async def run_backtest(
|
|||||||
portfolio_monitor_report = _portfolio_monitor(
|
portfolio_monitor_report = _portfolio_monitor(
|
||||||
candidates, price_columns, spy_closes, hold_horizon,
|
candidates, price_columns, spy_closes, hold_horizon,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
split = _holdout_split()
|
split = _holdout_split()
|
||||||
if split is not None:
|
if split is not None:
|
||||||
holdout_report = _holdout_evaluation(
|
holdout_report = _holdout_evaluation(
|
||||||
candidates, price_columns, spy_closes, hold_horizon, split,
|
candidates, price_columns, spy_closes, hold_horizon, split,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
if _min_rr_sweep_enabled():
|
if _min_rr_sweep_enabled():
|
||||||
min_rr_sweep_report = _min_rr_sweep(
|
min_rr_sweep_report = _min_rr_sweep(
|
||||||
candidates, price_columns, spy_closes, activation, current_min_pct,
|
candidates, price_columns, spy_closes, activation, current_min_pct,
|
||||||
hold_horizon, live_exit_policy=live_exit_policy,
|
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Portfolio simulation failed")
|
logger.exception("Portfolio simulation failed")
|
||||||
@@ -2987,13 +3478,19 @@ async def run_backtest(
|
|||||||
"candidates": len(candidates),
|
"candidates": len(candidates),
|
||||||
"qualified": len(qualified),
|
"qualified": len(qualified),
|
||||||
"params": {
|
"params": {
|
||||||
"step_days": STEP_DAYS,
|
# Keep step_days for old report consumers; the value counts stored
|
||||||
|
# market sessions rather than calendar days.
|
||||||
|
"step_days": backtest_step_sessions(cadence),
|
||||||
|
"step_sessions": backtest_step_sessions(cadence),
|
||||||
|
"entry_cadence": cadence,
|
||||||
|
"signal_eval_cadence": WEEKLY_BACKTEST_CADENCE,
|
||||||
"horizon_days": HORIZON,
|
"horizon_days": HORIZON,
|
||||||
"min_lookback": MIN_LOOKBACK,
|
"min_lookback": MIN_LOOKBACK,
|
||||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||||
"target_model": target_model,
|
"target_model": target_model,
|
||||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||||
|
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||||
},
|
},
|
||||||
"activation": activation,
|
"activation": activation,
|
||||||
"overall_qualified": _bucket_stats(qualified),
|
"overall_qualified": _bucket_stats(qualified),
|
||||||
@@ -3055,6 +3552,11 @@ async def run_backtest(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
"portfolio_monitor": portfolio_monitor_report,
|
"portfolio_monitor": portfolio_monitor_report,
|
||||||
|
"production_cadence_comparison": (
|
||||||
|
_production_cadence_comparison(portfolio_monitor_report, cadence)
|
||||||
|
if target_model == PRODUCTION_GTL_TARGET_MODEL
|
||||||
|
else None
|
||||||
|
),
|
||||||
"holdout": holdout_report,
|
"holdout": holdout_report,
|
||||||
"min_rr_sweep": min_rr_sweep_report,
|
"min_rr_sweep": min_rr_sweep_report,
|
||||||
"target_model_diagnostics": _target_model_diagnostics(
|
"target_model_diagnostics": _target_model_diagnostics(
|
||||||
@@ -3090,9 +3592,15 @@ async def run_and_store(
|
|||||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||||
*,
|
*,
|
||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run the backtest and cache the report in a SystemSetting. Job entrypoint."""
|
"""Run the backtest and cache the report in a SystemSetting. Job entrypoint."""
|
||||||
report = await run_backtest(db, progress_cb, target_model=target_model)
|
report = await run_backtest(
|
||||||
|
db,
|
||||||
|
progress_cb,
|
||||||
|
target_model=target_model,
|
||||||
|
cadence=cadence,
|
||||||
|
)
|
||||||
await update_setting(db, KEY_REPORT, json.dumps(report))
|
await update_setting(db, KEY_REPORT, json.dumps(report))
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.services.outcome_service import (
|
|||||||
Bar,
|
Bar,
|
||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
|
from app.services.trade_policy import get_reentry_gate_locks
|
||||||
|
|
||||||
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
||||||
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
||||||
@@ -318,6 +319,10 @@ async def create_trade(
|
|||||||
raise ValidationError("shares and entry_price must be positive")
|
raise ValidationError("shares and entry_price must be positive")
|
||||||
|
|
||||||
ticker = await _get_ticker(db, symbol)
|
ticker = await _get_ticker(db, symbol)
|
||||||
|
if ticker.id in await get_reentry_gate_locks(db):
|
||||||
|
raise ValidationError(
|
||||||
|
f"{ticker.symbol} requires a post-stop gate reset before re-entry"
|
||||||
|
)
|
||||||
trade = PaperTrade(
|
trade = PaperTrade(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
ticker_id=ticker.id,
|
ticker_id=ticker.id,
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ from app.models.ticker import Ticker
|
|||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
|
from app.services.qualification import setup_qualifies
|
||||||
from app.services.sr_service import detect_gate_target_ladder
|
from app.services.sr_service import detect_gate_target_ladder
|
||||||
|
from app.services.trade_policy import (
|
||||||
|
get_reentry_gate_locks,
|
||||||
|
observe_reentry_gate_transitions,
|
||||||
|
)
|
||||||
from app.services.recommendation_service import (
|
from app.services.recommendation_service import (
|
||||||
_risk_level_from_conflicts,
|
_risk_level_from_conflicts,
|
||||||
build_recommendation_snapshot,
|
build_recommendation_snapshot,
|
||||||
@@ -699,12 +704,24 @@ async def scan_all_tickers(
|
|||||||
``progress_callback(processed, total, current_symbol)`` is invoked as each
|
``progress_callback(processed, total, current_symbol)`` is invoked as each
|
||||||
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
|
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
|
||||||
"""
|
"""
|
||||||
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
|
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
|
||||||
# objects held across them, and touching an expired attribute afterwards
|
# ORM objects held across them, and touching an expired attribute afterwards
|
||||||
# triggers sync lazy-loading, which raises on an AsyncSession.
|
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
|
||||||
symbols = list(result.scalars().all())
|
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
|
||||||
total = len(symbols)
|
total = len(ticker_rows)
|
||||||
|
|
||||||
|
# Gate-reset observations must use the same runtime activation settings as
|
||||||
|
# the live setup list. If the config cannot be loaded, scan normally but do
|
||||||
|
# not mutate reset state from an evaluation whose rules are unknown.
|
||||||
|
activation: dict | None = None
|
||||||
|
try:
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
logger.exception("Activation config load for re-entry gate reset failed")
|
||||||
|
|
||||||
# Rank the universe up front so each new setup carries both the residual
|
# Rank the universe up front so each new setup carries both the residual
|
||||||
# activation gate percentile and the promoted production ordering score.
|
# activation gate percentile and the promoted production ordering score.
|
||||||
@@ -720,7 +737,10 @@ async def scan_all_tickers(
|
|||||||
ranks = {}
|
ranks = {}
|
||||||
|
|
||||||
all_setups: list[TradeSetup] = []
|
all_setups: list[TradeSetup] = []
|
||||||
for index, symbol in enumerate(symbols):
|
evaluated_ticker_ids: set[int] = set()
|
||||||
|
qualified_ticker_ids: set[int] = set()
|
||||||
|
gate_observation_started_at = datetime.now(timezone.utc)
|
||||||
|
for index, (ticker_id, symbol) in enumerate(ticker_rows):
|
||||||
if progress_callback is not None:
|
if progress_callback is not None:
|
||||||
progress_callback(index, total, symbol)
|
progress_callback(index, total, symbol)
|
||||||
# Refresh scores first so the scheduled scan works off current data.
|
# Refresh scores first so the scheduled scan works off current data.
|
||||||
@@ -753,10 +773,33 @@ async def scan_all_tickers(
|
|||||||
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
primary_min_rr=PRIMARY_TARGET_MIN_RR,
|
||||||
)
|
)
|
||||||
all_setups.extend(setups)
|
all_setups.extend(setups)
|
||||||
|
if activation is not None:
|
||||||
|
try:
|
||||||
|
if any(setup_qualifies(setup, activation) for setup in setups):
|
||||||
|
qualified_ticker_ids.add(ticker_id)
|
||||||
|
evaluated_ticker_ids.add(ticker_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Gate-reset qualification observation failed for %s", symbol
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
logger.exception("Error scanning ticker %s", symbol)
|
logger.exception("Error scanning ticker %s", symbol)
|
||||||
|
|
||||||
|
if activation is not None:
|
||||||
|
transitioned_ticker_ids = await observe_reentry_gate_transitions(
|
||||||
|
db,
|
||||||
|
evaluated_ticker_ids=evaluated_ticker_ids,
|
||||||
|
qualified_ticker_ids=qualified_ticker_ids,
|
||||||
|
observed_at=gate_observation_started_at,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if transitioned_ticker_ids:
|
||||||
|
logger.info(
|
||||||
|
"Updated post-stop gate-reset state for %d ticker(s)",
|
||||||
|
len(transitioned_ticker_ids),
|
||||||
|
)
|
||||||
|
|
||||||
if progress_callback is not None and total:
|
if progress_callback is not None and total:
|
||||||
progress_callback(total, total, "")
|
progress_callback(total, total, "")
|
||||||
|
|
||||||
@@ -771,6 +814,8 @@ async def get_trade_setups(
|
|||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
live_recommendation: bool = False,
|
live_recommendation: bool = False,
|
||||||
exclude_open_trade_tickers: bool = False,
|
exclude_open_trade_tickers: bool = False,
|
||||||
|
exclude_reentry_gate_locked_tickers: bool = False,
|
||||||
|
include_reentry_gate_lock: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered.
|
"""Get latest stored trade setups, optionally filtered.
|
||||||
|
|
||||||
@@ -794,15 +839,23 @@ async def get_trade_setups(
|
|||||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||||
if recommended_action is not None and not live_recommendation:
|
if recommended_action is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||||
|
excluded_ticker_ids: set[int] = set()
|
||||||
|
reentry_gate_locks: dict[int, datetime] = {}
|
||||||
if exclude_open_trade_tickers:
|
if exclude_open_trade_tickers:
|
||||||
open_trade_result = await db.execute(
|
open_trade_result = await db.execute(
|
||||||
select(PaperTrade.ticker_id)
|
select(PaperTrade.ticker_id)
|
||||||
.where(PaperTrade.status == "open")
|
.where(PaperTrade.status == "open")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()}
|
excluded_ticker_ids.update(
|
||||||
if open_ticker_ids:
|
ticker_id for ticker_id, in open_trade_result.all()
|
||||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids))
|
)
|
||||||
|
if exclude_reentry_gate_locked_tickers or include_reentry_gate_lock:
|
||||||
|
reentry_gate_locks = await get_reentry_gate_locks(db)
|
||||||
|
if exclude_reentry_gate_locked_tickers:
|
||||||
|
excluded_ticker_ids.update(reentry_gate_locks)
|
||||||
|
if excluded_ticker_ids:
|
||||||
|
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
||||||
|
|
||||||
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||||
|
|
||||||
@@ -855,6 +908,15 @@ async def get_trade_setups(
|
|||||||
),
|
),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
if include_reentry_gate_lock:
|
||||||
|
ticker_by_setup_id = {
|
||||||
|
setup.id: setup.ticker_id for setup, _ in latest_rows
|
||||||
|
}
|
||||||
|
for row in rows_out:
|
||||||
|
ticker_id = ticker_by_setup_id.get(row["id"])
|
||||||
|
row["reentry_gate_reset_required"] = (
|
||||||
|
ticker_id in reentry_gate_locks if ticker_id is not None else False
|
||||||
|
)
|
||||||
return rows_out
|
return rows_out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Shared live trading-policy state and availability checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_initial_stop_trades(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
closed_before: datetime | None = None,
|
||||||
|
) -> dict[int, PaperTrade]:
|
||||||
|
"""Return a ticker's latest closed trade only when it was an initial stop."""
|
||||||
|
ranked_stmt = (
|
||||||
|
select(
|
||||||
|
PaperTrade.id.label("trade_id"),
|
||||||
|
func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=PaperTrade.ticker_id,
|
||||||
|
order_by=(PaperTrade.closed_at.desc(), PaperTrade.id.desc()),
|
||||||
|
)
|
||||||
|
.label("recency"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PaperTrade.status == "closed",
|
||||||
|
PaperTrade.closed_at.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if closed_before is not None:
|
||||||
|
ranked_stmt = ranked_stmt.where(PaperTrade.closed_at <= closed_before)
|
||||||
|
ranked = ranked_stmt.subquery()
|
||||||
|
stmt = (
|
||||||
|
select(PaperTrade)
|
||||||
|
.join(ranked, ranked.c.trade_id == PaperTrade.id)
|
||||||
|
.where(
|
||||||
|
ranked.c.recency == 1,
|
||||||
|
PaperTrade.close_reason == "stop",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return {trade.ticker_id: trade for trade in result.scalars()}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reentry_gate_locks(db: AsyncSession) -> dict[int, datetime]:
|
||||||
|
"""Return tickers still waiting for a post-stop gate failure.
|
||||||
|
|
||||||
|
A later qualified setup is actionable only after the daily scanner has
|
||||||
|
observed an unqualified evaluation after the latest initial-stop exit and
|
||||||
|
then a fresh qualification. The returned timestamp is the stop time and is
|
||||||
|
useful for diagnostics; callers normally only need the keys.
|
||||||
|
"""
|
||||||
|
latest = await _latest_initial_stop_trades(db)
|
||||||
|
return {
|
||||||
|
ticker_id: trade.closed_at
|
||||||
|
for ticker_id, trade in latest.items()
|
||||||
|
if trade.reentry_gate_requalified_at is None and trade.closed_at is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def observe_reentry_gate_transitions(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
evaluated_ticker_ids: Iterable[int],
|
||||||
|
qualified_ticker_ids: Iterable[int],
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
) -> set[int]:
|
||||||
|
"""Persist gate-failure and later requalification observations.
|
||||||
|
|
||||||
|
Only tickers whose scan completed successfully belong in
|
||||||
|
``evaluated_ticker_ids``. This prevents a scanner exception from being
|
||||||
|
mistaken for a real gate exit. The caller owns the transaction; this helper
|
||||||
|
flushes so the new state is immediately visible in that transaction.
|
||||||
|
"""
|
||||||
|
evaluated = {int(ticker_id) for ticker_id in evaluated_ticker_ids}
|
||||||
|
if not evaluated:
|
||||||
|
return set()
|
||||||
|
qualified = {int(ticker_id) for ticker_id in qualified_ticker_ids}
|
||||||
|
timestamp = observed_at or datetime.now(timezone.utc)
|
||||||
|
latest = await _latest_initial_stop_trades(db, closed_before=timestamp)
|
||||||
|
updated: set[int] = set()
|
||||||
|
for ticker_id in evaluated:
|
||||||
|
trade = latest.get(ticker_id)
|
||||||
|
if trade is None or trade.reentry_gate_requalified_at is not None:
|
||||||
|
continue
|
||||||
|
if trade.reentry_gate_failed_at is None:
|
||||||
|
if ticker_id not in qualified:
|
||||||
|
trade.reentry_gate_failed_at = timestamp
|
||||||
|
updated.add(ticker_id)
|
||||||
|
elif ticker_id in qualified:
|
||||||
|
trade.reentry_gate_requalified_at = timestamp
|
||||||
|
updated.add(ticker_id)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await db.flush()
|
||||||
|
return updated
|
||||||
+10
-2
@@ -8,7 +8,9 @@ was run and the data said no.** Detail lives in the linked docs and in
|
|||||||
**The one-line summary of the whole platform:** it is a **long-only
|
**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
|
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
|
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
|
trail at 3× ATR for up to 30 trading days. After an initial stop, require the
|
||||||
|
daily production gate to fail and subsequently qualify again before re-entry.
|
||||||
|
Everything else in the app (composite
|
||||||
score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
||||||
**display or screening**, not edge.
|
**display or screening**, not edge.
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
|||||||
| 80/20 residual-momentum / 6m-volatility rank | Ranking tilt | Buys ~2pp CAGR over momentum-only; costs ~6pp drawdown |
|
| 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 |
|
| 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 |
|
| 3× ATR trailing stop, 30-day max hold | Real exit | Best Sharpe of every exit tested |
|
||||||
|
| Post-stop normal gate reset | Re-entry policy | Stop always closes; a later gate failure and subsequent fresh qualification define the next signal episode. The selected study arm reached Sharpe 1.77 / CAGR 48.3% at capacity 10; live scan-before-outcome timing is stricter (Sharpe 1.68 / CAGR 44.8% analogue). [Full study](post-stop-reentry.md) |
|
||||||
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice |
|
| Max 10 concurrent positions, 1% risk per trade | Sizing | Cap never binds in practice |
|
||||||
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
|
| Structural S/R | Human-facing product context | Clean, capped zones for charts and alerts; not read by the scanner |
|
||||||
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
|
| Gate Target Ladder | Screening machinery | Volume-free transient proposals preserve the production candidate set exactly; never an exit |
|
||||||
@@ -63,6 +66,7 @@ invites overfitting.
|
|||||||
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
| Primary-target R:R selector | **Keep 1.5** — target choice is intentionally independent of the later 2.0 activation floor |
|
||||||
| Exit policy (hold / SMA50 / 20-day low / technical-40 / ATR trail) | **Keep 3× ATR trail** — best Sharpe (2.04) |
|
| 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 |
|
| **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 |
|
||||||
|
| Post-stop re-entry (nine daily policy arms) | **Keep normal gate reset at production capacity 10** — Sharpe 1.77 vs 1.67 immediate and 1.47 fixed cooldown 5. The result changes with book capacity; see [post-stop-reentry.md](post-stop-reentry.md) |
|
||||||
|
|
||||||
### The `min_rr` sweep (2026-07-12)
|
### The `min_rr` sweep (2026-07-12)
|
||||||
|
|
||||||
@@ -142,6 +146,10 @@ it is internal screening machinery whose broad historical-price-traffic behavior
|
|||||||
was preserved explicitly and volume-free, with exact full-period parity. It is
|
was preserved explicitly and volume-free, with exact full-period parity. It is
|
||||||
still neither market structure nor an exit. The one component that *does* have
|
still neither market structure nor an exit. The one component that *does* have
|
||||||
measured predictive edge is the momentum gate, and every knob on it has been
|
measured predictive edge is the momentum gate, and every knob on it has been
|
||||||
swept and confirmed.
|
swept and confirmed. After an initial-stop exit, that same gate now also defines
|
||||||
|
when a new episode may begin: one later failed observation followed by a fresh
|
||||||
|
qualification. The [daily re-entry matrix](post-stop-reentry.md) supports this
|
||||||
|
for the current 10-position book, but not as a universal rule for other
|
||||||
|
portfolio capacities.
|
||||||
|
|
||||||
The next real evidence is **forward**, not backward: the live paper-trade record.
|
The next real evidence is **forward**, not backward: the live paper-trade record.
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Post-stop re-entry: daily policy study and production decision
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use a **normal gate reset** after an initial-stop exit:
|
||||||
|
|
||||||
|
1. The initial stop always closes the trade. It is never cancelled because the
|
||||||
|
ticker still passes the gate.
|
||||||
|
2. Re-entry remains locked until a later full-universe daily scan observes the
|
||||||
|
ticker **failing** the production activation gate.
|
||||||
|
3. The lock remains in place until a subsequent daily scan observes a **fresh
|
||||||
|
qualification**.
|
||||||
|
4. Only then may the ticker return to the actionable setup list or be opened
|
||||||
|
through `create_trade`.
|
||||||
|
|
||||||
|
Trailing-stop, time, target, and manual exits do not start this state machine.
|
||||||
|
Scanner errors do not count as a gate failure. The two transitions are persisted
|
||||||
|
on the latest initial-stop `PaperTrade`, so neither a service call nor a restart
|
||||||
|
can bypass the rule.
|
||||||
|
|
||||||
|
Migration 022 applies the policy prospectively. Existing initial-stop rows are
|
||||||
|
grandfathered by marking both reset timestamps complete at their historical
|
||||||
|
`closed_at`; otherwise their new NULL columns would be mistaken for active
|
||||||
|
locks despite no scanner observations having existed. At runtime, only the
|
||||||
|
actual latest closed trade per ticker can start a lock, and only when that exit
|
||||||
|
was an initial stop. A newer trailing, time, target, or manual exit therefore
|
||||||
|
cannot revive an older stop episode.
|
||||||
|
|
||||||
|
This replaces the previously proposed fixed five-session lockdown. The normal
|
||||||
|
reset counts an unqualified stop-day close when that close is observed after the
|
||||||
|
stop. The stricter experiment, which required a failed close on a later session,
|
||||||
|
was not promoted as the research policy.
|
||||||
|
|
||||||
|
### Live scheduling boundary
|
||||||
|
|
||||||
|
The live daily pipeline runs the R:R scan **before** Outcome Eval. A trade that
|
||||||
|
is closed at its initial stop by that Outcome Eval—or by an intraday evaluation
|
||||||
|
after the full scan—was therefore still open when the day's gate observation
|
||||||
|
ran. Its stop-day state cannot establish the failure. The earliest possible
|
||||||
|
failure is the next successful full scan, and a fresh qualification requires a
|
||||||
|
subsequent full scan.
|
||||||
|
|
||||||
|
The study simulator closes positions before checking same-session re-entry
|
||||||
|
state, so its normal `gate_reset` arm can count the stop-day close. At this first
|
||||||
|
transition boundary, current live ordering is instead analogous to
|
||||||
|
`strict_gate_reset`. The distinction is material: the strict full-period row
|
||||||
|
recorded Sharpe 1.68, CAGR 44.8%, and 23.4% drawdown; its disjoint 2025+ row
|
||||||
|
recorded Sharpe 1.38, CAGR 32.9%, and 21.0% drawdown. The selected normal-reset
|
||||||
|
result (Sharpe 1.77) is therefore policy-study evidence, not exact live
|
||||||
|
scheduler-order parity. Changing that ordering would be a separate production
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## Experiment design
|
||||||
|
|
||||||
|
Source: [`reports/daily_reentry_matrix.json`](../../reports/daily_reentry_matrix.json),
|
||||||
|
generated 2026-07-17.
|
||||||
|
|
||||||
|
| Input | Value |
|
||||||
|
|---|---|
|
||||||
|
| Snapshot | Production SQLite snapshot through 2026-07-02 |
|
||||||
|
| Period used by the all/5y rows | 2022-06-24 to 2026-07-02 |
|
||||||
|
| Tickers | 505 |
|
||||||
|
| Point-in-time candidate observations | 1,011,248 (492,850 long; 518,398 short) |
|
||||||
|
| Live-universe rank observations | 584,393 |
|
||||||
|
| Qualified candidates under `live_universe` ranking | 5,189 |
|
||||||
|
| Entry cadence | Daily |
|
||||||
|
| Selection and ordering | Production GTL gate; residual/high-vol 80/20 rank; long-only after ranking |
|
||||||
|
| Exit | 1.5× ATR initial stop; 3× ATR trailing stop; 30-session maximum hold |
|
||||||
|
| Portfolio | 10 positions; 1% risk per trade; $10,000 initial capital |
|
||||||
|
| Trading cost | 0.1% per side in the primary matrix; 0.1–0.3% robustness sweep |
|
||||||
|
| Holdout split | 2025-01-01 |
|
||||||
|
|
||||||
|
The expensive daily candidate replay was performed once. Every policy arm then
|
||||||
|
used the same candidates, prices, costs, position sizing, capacity, and exit
|
||||||
|
logic. `live_universe` ranks all eligible tickers once per session like the live
|
||||||
|
scanner. `backtest_legacy` retains the older candidate-only rank approximation as
|
||||||
|
a sensitivity check.
|
||||||
|
|
||||||
|
### Policies tested
|
||||||
|
|
||||||
|
| Arm | Rule after an initial stop |
|
||||||
|
|---|---|
|
||||||
|
| `immediate` | No memory; a same-day close re-entry is possible |
|
||||||
|
| `next_session` | Block only the stop session |
|
||||||
|
| `cooldown_2/3/5` | Re-entry allowed at wait-session N |
|
||||||
|
| `gate_reset` | Require a failed gate observation, then a later qualification; the stop-day close may establish the failure |
|
||||||
|
| `strict_gate_reset` | Ignore the stop-day failure; require a later failed close and then requalification |
|
||||||
|
| `gate_reset_improved` | Gate reset plus a higher new stop and non-weaker production rank |
|
||||||
|
| `two_session_confirmation` | Require two consecutive qualified post-stop closes |
|
||||||
|
|
||||||
|
## Primary result: production-like `live_universe` ranking
|
||||||
|
|
||||||
|
The available history is shorter than five years, so the report's `5y` and
|
||||||
|
`all` rows cover the same period.
|
||||||
|
|
||||||
|
| Policy | Total return | CAGR | Max DD | Sharpe | Trades | Win rate | Post-stop re-entries |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Immediate | 348.4% | 45.2% | 24.3% | 1.67 | 489 | 35.6% | 155 |
|
||||||
|
| Next session | 388.1% | 48.3% | 21.6% | 1.77 | 472 | 36.2% | 142 |
|
||||||
|
| Cooldown 2 | 343.5% | 44.8% | 23.4% | 1.68 | 472 | 35.8% | 146 |
|
||||||
|
| Cooldown 3 | 293.4% | 40.6% | 22.7% | 1.56 | 474 | 35.9% | 146 |
|
||||||
|
| Cooldown 5 | 250.8% | 36.6% | 22.2% | 1.47 | 473 | 35.9% | 145 |
|
||||||
|
| **Gate reset** | **388.1%** | **48.3%** | **21.6%** | **1.77** | **472** | **36.2%** | **142** |
|
||||||
|
| Strict gate reset | 342.7% | 44.8% | 23.4% | 1.68 | 471 | 35.9% | 144 |
|
||||||
|
| Gate reset + improved setup | 267.4% | 38.2% | 24.9% | 1.60 | 422 | 36.3% | 69 |
|
||||||
|
| Two-session confirmation | 296.1% | 40.8% | **17.6%** | 1.65 | 441 | **37.9%** | 96 |
|
||||||
|
|
||||||
|
At the production capacity, normal gate reset improved all four portfolio
|
||||||
|
objectives relative to immediate re-entry: higher total return, CAGR, and
|
||||||
|
Sharpe, with lower drawdown. The fixed five-session rule reduced churn but gave
|
||||||
|
up too many profitable re-entry opportunities.
|
||||||
|
|
||||||
|
`gate_reset` and `next_session` produced exactly the same executed portfolio in
|
||||||
|
the `live_universe` runs. Their rules are not equivalent. In this sample, the
|
||||||
|
portfolio-level candidate path happened to converge to the same trades. This is
|
||||||
|
evidence that blocking same-day re-entry helped; it does **not** isolate an
|
||||||
|
independent return premium for the reset condition itself.
|
||||||
|
|
||||||
|
## Disjoint 2025+ test window
|
||||||
|
|
||||||
|
These are separate books with entries on or after 2025-01-01. They are a useful
|
||||||
|
temporal sensitivity check, but not forward evidence: the policy was still
|
||||||
|
selected after the historical data existed.
|
||||||
|
|
||||||
|
| Policy | Total return | CAGR | Max DD | Sharpe | Trades |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Immediate | 64.1% | 39.3% | 19.6% | 1.55 | 181 |
|
||||||
|
| Next session | 68.5% | 41.8% | 19.2% | 1.66 | 183 |
|
||||||
|
| **Gate reset** | **68.5%** | **41.8%** | **19.2%** | **1.66** | **183** |
|
||||||
|
| Cooldown 5 | 52.7% | 32.7% | 19.8% | 1.43 | 175 |
|
||||||
|
| Strict gate reset | 52.9% | 32.9% | 21.0% | 1.38 | 181 |
|
||||||
|
| Two-session confirmation | 35.4% | 22.5% | **18.1%** | 1.02 | 183 |
|
||||||
|
|
||||||
|
The gate-reset result did not depend solely on the earlier training period: it
|
||||||
|
also beat immediate and the fixed five-session rule in the disjoint test book.
|
||||||
|
|
||||||
|
## Cost and capacity sensitivity
|
||||||
|
|
||||||
|
At the production capacity of 10, gate reset remained ahead of both immediate
|
||||||
|
and cooldown 5 as costs increased.
|
||||||
|
|
||||||
|
| Cost per side | Policy | Total return | CAGR | Max DD | Sharpe |
|
||||||
|
|---:|---|---:|---:|---:|---:|
|
||||||
|
| 0.1% | Immediate | 348.4% | 45.2% | 24.3% | 1.67 |
|
||||||
|
| 0.1% | **Gate reset** | **388.1%** | **48.3%** | **21.6%** | **1.77** |
|
||||||
|
| 0.1% | Cooldown 5 | 250.8% | 36.6% | 22.2% | 1.47 |
|
||||||
|
| 0.2% | Immediate | 296.3% | 40.8% | 25.2% | 1.54 |
|
||||||
|
| 0.2% | **Gate reset** | **333.0%** | **44.0%** | **22.9%** | **1.64** |
|
||||||
|
| 0.2% | Cooldown 5 | 209.9% | 32.5% | 23.4% | 1.33 |
|
||||||
|
| 0.3% | Immediate | 249.8% | 36.5% | 26.0% | 1.41 |
|
||||||
|
| 0.3% | **Gate reset** | **284.0%** | **39.7%** | **24.2%** | **1.51** |
|
||||||
|
| 0.3% | Cooldown 5 | 164.0% | 27.3% | 24.7% | 1.16 |
|
||||||
|
|
||||||
|
The capacity sweep is a real limitation, not a footnote:
|
||||||
|
|
||||||
|
| Capacity at 0.1% cost | Immediate Sharpe / CAGR / DD | Gate-reset Sharpe / CAGR / DD | Cooldown-5 Sharpe / CAGR / DD |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 5 | 1.33 / 31.9% / 16.8% | 1.37 / 32.8% / 17.5% | **1.46 / 35.8% / 18.3%** |
|
||||||
|
| **10 (production)** | 1.67 / 45.2% / 24.3% | **1.77 / 48.3% / 21.6%** | 1.47 / 36.6% / 22.2% |
|
||||||
|
| 15 | **1.66 / 44.8% / 24.3%** | 1.63 / 43.0% / **21.6%** | 1.33 / 32.8% / 22.2% |
|
||||||
|
|
||||||
|
The promotion is therefore specific to the actual 10-position production book.
|
||||||
|
At capacity 5, cooldown 5 ranked best; at capacity 15, immediate had slightly
|
||||||
|
higher return and Sharpe while gate reset retained the shallower drawdown. Do
|
||||||
|
not generalize the chosen rule to a differently sized portfolio without rerunning
|
||||||
|
the matrix.
|
||||||
|
|
||||||
|
## Legacy-rank sensitivity
|
||||||
|
|
||||||
|
The older candidate-only ranking approximation also favored normal gate reset
|
||||||
|
over immediate and cooldown 5, although `next_session` was slightly stronger.
|
||||||
|
|
||||||
|
| Policy | Total return | CAGR | Max DD | Sharpe | Trades |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Immediate | 357.4% | 45.9% | 17.9% | 1.73 | 480 |
|
||||||
|
| Next session | **421.5%** | **50.8%** | 18.5% | **1.86** | 466 |
|
||||||
|
| **Gate reset** | 408.3% | 49.8% | 18.3% | 1.84 | 464 |
|
||||||
|
| Cooldown 5 | 332.3% | 43.9% | 19.6% | 1.71 | 459 |
|
||||||
|
| Strict gate reset | 351.3% | 45.5% | 20.4% | 1.72 | 457 |
|
||||||
|
|
||||||
|
## Why gate reset was promoted
|
||||||
|
|
||||||
|
- It is tied to a new signal episode instead of an arbitrary elapsed time.
|
||||||
|
- At the production capacity, it beat immediate and five-session cooldown on
|
||||||
|
return, CAGR, drawdown, and Sharpe.
|
||||||
|
- The advantage survived costs of 0.2% and 0.3% per side and the disjoint 2025+
|
||||||
|
test book.
|
||||||
|
- It avoids cancelling a valid stop: the loss and transaction costs are always
|
||||||
|
realized before any later trade.
|
||||||
|
- It avoids the extra filters that weakened strict reset, improved-setup reset,
|
||||||
|
and two-close confirmation.
|
||||||
|
|
||||||
|
The correct interpretation is deliberately modest: **normal gate reset is the
|
||||||
|
best production rule among the tested policies for the current 10-position
|
||||||
|
book.** It is not proof that gate reset is a universal source of alpha. Forward
|
||||||
|
paper-trade monitoring is still the only genuinely new evidence.
|
||||||
@@ -201,9 +201,11 @@ export interface TriggerJobResponse {
|
|||||||
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
|
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
|
||||||
message: string;
|
message: string;
|
||||||
target_model?: BacktestTargetModel;
|
target_model?: BacktestTargetModel;
|
||||||
|
cadence?: BacktestCadence;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
||||||
|
export type BacktestCadence = 'weekly' | 'daily';
|
||||||
|
|
||||||
export function listJobs() {
|
export function listJobs() {
|
||||||
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
|
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
|
||||||
@@ -219,7 +221,10 @@ export function toggleJob(jobName: string, enabled: boolean) {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
|
export function triggerJob(
|
||||||
|
jobName: string,
|
||||||
|
options?: { target_model?: BacktestTargetModel; cadence?: BacktestCadence },
|
||||||
|
) {
|
||||||
return apiClient
|
return apiClient
|
||||||
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
|
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||||
import { triggerJob } from '../../api/admin';
|
import { triggerJob } from '../../api/admin';
|
||||||
import type { BacktestTargetModel } from '../../api/admin';
|
import type { BacktestCadence, BacktestTargetModel } from '../../api/admin';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
@@ -145,6 +145,7 @@ export function BacktestPanel() {
|
|||||||
const [selectedStrategy, setSelectedStrategy] = useState('');
|
const [selectedStrategy, setSelectedStrategy] = useState('');
|
||||||
const [selectedLookback, setSelectedLookback] = useState('');
|
const [selectedLookback, setSelectedLookback] = useState('');
|
||||||
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
|
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
|
||||||
|
const [cadence, setCadence] = useState<BacktestCadence>('weekly');
|
||||||
|
|
||||||
const monitor = report?.portfolio_monitor ?? null;
|
const monitor = report?.portfolio_monitor ?? null;
|
||||||
const activeStrategy =
|
const activeStrategy =
|
||||||
@@ -161,11 +162,11 @@ export function BacktestPanel() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
|
mutationFn: () => triggerJob('backtest', { target_model: targetModel, cadence }),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
if (res.status === 'triggered') {
|
if (res.status === 'triggered') {
|
||||||
const label = targetModel === 'production_gtl' ? 'Live GTL' : 'Structural S/R comparison';
|
const label = targetModel === 'production_gtl' ? 'Live GTL' : 'Structural S/R comparison';
|
||||||
toast.addToast('success', `${label} backtest started — results appear when it finishes.`);
|
toast.addToast('success', `${label} ${cadence} backtest started — results appear when it finishes.`);
|
||||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
|
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
|
||||||
} else {
|
} else {
|
||||||
toast.addToast('info', res.message || 'Could not start backtest');
|
toast.addToast('info', res.message || 'Could not start backtest');
|
||||||
@@ -180,7 +181,7 @@ export function BacktestPanel() {
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<Disclosure summary="How this is measured">
|
<Disclosure summary="How this is measured">
|
||||||
<p className="max-w-2xl text-xs text-gray-400">
|
<p className="max-w-2xl text-xs text-gray-400">
|
||||||
The backtest replays the current config weekly through history — at each point the setup is
|
The backtest replays the current config at the selected cadence — at each point the setup is
|
||||||
rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide
|
rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide
|
||||||
its outcome — then simulates one capital-constrained book against the S&P 500. Sentiment and
|
its outcome — then simulates one capital-constrained book against the S&P 500. Sentiment and
|
||||||
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||||
@@ -238,6 +239,56 @@ export function BacktestPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset className="grid w-full grid-cols-2 gap-2 sm:w-[34rem]">
|
||||||
|
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
Entry cadence
|
||||||
|
</legend>
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
||||||
|
cadence === 'weekly'
|
||||||
|
? 'border-blue-400/60 bg-blue-500/10'
|
||||||
|
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name="backtest-cadence"
|
||||||
|
value="weekly"
|
||||||
|
checked={cadence === 'weekly'}
|
||||||
|
onChange={() => setCadence('weekly')}
|
||||||
|
/>
|
||||||
|
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
||||||
|
Weekly
|
||||||
|
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||||
|
Resource-safe server run at five-session intervals.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
||||||
|
cadence === 'daily'
|
||||||
|
? 'border-amber-400/50 bg-amber-500/10'
|
||||||
|
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name="backtest-cadence"
|
||||||
|
value="daily"
|
||||||
|
checked={cadence === 'daily'}
|
||||||
|
onChange={() => setCadence('daily')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-200">Daily</span>
|
||||||
|
<span className="mt-1 block text-[11px] leading-4 text-amber-300/80">
|
||||||
|
Research run: roughly 5× the replay work; prefer the offline snapshot runner.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -257,7 +308,8 @@ export function BacktestPanel() {
|
|||||||
<>
|
<>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-gray-500">
|
||||||
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
||||||
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
|
({report.qualified} qualified) · {report.params.entry_cadence ?? 'weekly'} cadence,
|
||||||
|
{' '}{report.params.horizon_days}-day horizon
|
||||||
{report.params.cost_per_side_pct != null && (
|
{report.params.cost_per_side_pct != null && (
|
||||||
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
||||||
)}
|
)}
|
||||||
@@ -321,6 +373,9 @@ export function BacktestPanel() {
|
|||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-[11px] text-gray-500">
|
||||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
||||||
|
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||||
|
<> · Re-entry after gate failure and fresh qualification</>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||||
|
|||||||
@@ -66,14 +66,18 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
|||||||
return { pct, r, status };
|
return { pct, r, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type NotActionableState =
|
||||||
* The only state with no tradeable setup left: price has gone through the stop.
|
| { kind: 'gate-reset' }
|
||||||
* Returns null when there's no live price.
|
| { kind: 'invalidated' }
|
||||||
*/
|
| null;
|
||||||
|
|
||||||
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
||||||
|
if (setup.reentry_gate_reset_required) {
|
||||||
|
return { kind: 'gate-reset' } satisfies NotActionableState;
|
||||||
|
}
|
||||||
if (currentPrice == null) return null;
|
if (currentPrice == null) return null;
|
||||||
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||||
return { invalidated: true };
|
return { kind: 'invalidated' } satisfies NotActionableState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function riskClass(risk: TradeSetup['risk_level']) {
|
function riskClass(risk: TradeSetup['risk_level']) {
|
||||||
@@ -218,9 +222,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
||||||
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||||
|
|
||||||
// Only price through the stop leaves no tradeable setup.
|
|
||||||
const notActionable = notActionableState(setup, currentPrice) != null;
|
|
||||||
|
|
||||||
const createTrade = useCreatePaperTrade();
|
const createTrade = useCreatePaperTrade();
|
||||||
const [taking, setTaking] = useState(false);
|
const [taking, setTaking] = useState(false);
|
||||||
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
||||||
@@ -268,7 +269,24 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (notActionable) {
|
const inactiveState = notActionableState(setup, currentPrice);
|
||||||
|
if (inactiveState?.kind === 'gate-reset') {
|
||||||
|
return (
|
||||||
|
<div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<DirTag direction={setup.direction} />
|
||||||
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">awaiting gate reset</span>
|
||||||
|
<span className="num ml-auto text-xs text-gray-500">re-entry paused</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
||||||
|
This setup remains visible for context but cannot be marked as taken. The ticker must first fail
|
||||||
|
the production gate; only a later fresh qualification can become actionable again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactiveState?.kind === 'invalidated') {
|
||||||
const dir = setup.direction.toUpperCase();
|
const dir = setup.direction.toUpperCase();
|
||||||
return (
|
return (
|
||||||
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
|
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
|
||||||
@@ -617,7 +635,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{preferredInactive ? (
|
{preferredInactive ? (
|
||||||
<span className="text-sm font-semibold text-gray-400">
|
<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()} — invalidated at the stop)</span>
|
{preferredInactive.kind === 'gate-reset' ? (
|
||||||
|
<>
|
||||||
|
Re-entry paused{' '}
|
||||||
|
<span className="font-normal text-gray-500">
|
||||||
|
(waiting for the gate to fail before a fresh qualification)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
No current setup{' '}
|
||||||
|
<span className="font-normal text-gray-500">
|
||||||
|
(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const reasoning = summary?.reasoning ?? '';
|
const reasoning = summary?.reasoning ?? '';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function liveRiskReward(setup: TradeSetup, currentPrice: number): number
|
|||||||
* app/services/qualification.py — keep the two in sync.
|
* app/services/qualification.py — keep the two in sync.
|
||||||
*/
|
*/
|
||||||
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
||||||
|
if (setup.reentry_gate_reset_required) return false;
|
||||||
if (setup.rr_ratio < config.min_rr) return false;
|
if (setup.rr_ratio < config.min_rr) return false;
|
||||||
// Live R:R from current price — drops setups whose price has already run
|
// Live R:R from current price — drops setups whose price has already run
|
||||||
// toward target (reward consumed) or through the stop.
|
// toward target (reward consumed) or through the stop.
|
||||||
@@ -79,6 +80,9 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
* qualifiesSetup rule-for-rule (keep the order in sync).
|
* qualifiesSetup rule-for-rule (keep the order in sync).
|
||||||
*/
|
*/
|
||||||
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
||||||
|
if (setup.reentry_gate_reset_required) {
|
||||||
|
return 'post-stop gate reset required';
|
||||||
|
}
|
||||||
if (setup.rr_ratio < config.min_rr) {
|
if (setup.rr_ratio < config.min_rr) {
|
||||||
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export interface TradeSetup {
|
|||||||
momentum_percentile?: number | null;
|
momentum_percentile?: number | null;
|
||||||
strategy_rank?: number | null;
|
strategy_rank?: number | null;
|
||||||
volatility_percentile?: number | null;
|
volatility_percentile?: number | null;
|
||||||
|
reentry_gate_reset_required?: boolean;
|
||||||
context_as_of?: TradeSetupContextAsOf | null;
|
context_as_of?: TradeSetupContextAsOf | null;
|
||||||
recommendation_summary?: RecommendationSummary;
|
recommendation_summary?: RecommendationSummary;
|
||||||
}
|
}
|
||||||
@@ -355,15 +356,24 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_production: boolean;
|
is_production: boolean;
|
||||||
|
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
|
||||||
entry_variant: string;
|
entry_variant: string;
|
||||||
exit_policy: string;
|
exit_policy: string;
|
||||||
|
reentry_policy?: 'immediate' | 'gate_reset';
|
||||||
lookback: string;
|
lookback: string;
|
||||||
lookback_label: string;
|
lookback_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestPortfolioMonitor {
|
export interface BacktestPortfolioMonitor {
|
||||||
production_strategy: string;
|
production_strategy: string;
|
||||||
strategies: { strategy: string; label: string; description: string; is_production: boolean }[];
|
strategies: {
|
||||||
|
strategy: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
is_production: boolean;
|
||||||
|
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
|
||||||
|
reentry_policy?: 'immediate' | 'gate_reset';
|
||||||
|
}[];
|
||||||
lookbacks: { lookback: string; label: string }[];
|
lookbacks: { lookback: string; label: string }[];
|
||||||
runs: BacktestPortfolioMonitorRun[];
|
runs: BacktestPortfolioMonitorRun[];
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -396,12 +406,16 @@ export interface BacktestReport {
|
|||||||
qualified: number;
|
qualified: number;
|
||||||
params: {
|
params: {
|
||||||
step_days: number;
|
step_days: number;
|
||||||
|
step_sessions?: number;
|
||||||
|
entry_cadence?: 'weekly' | 'daily';
|
||||||
|
signal_eval_cadence?: 'weekly';
|
||||||
horizon_days: number;
|
horizon_days: number;
|
||||||
min_lookback: number;
|
min_lookback: number;
|
||||||
cost_per_side_pct?: number;
|
cost_per_side_pct?: number;
|
||||||
target_model?: 'production_gtl' | 'structural_sr';
|
target_model?: 'production_gtl' | 'structural_sr';
|
||||||
target_model_label?: string;
|
target_model_label?: string;
|
||||||
is_production_target_model?: boolean;
|
is_production_target_model?: boolean;
|
||||||
|
production_reentry_policy?: 'gate_reset';
|
||||||
};
|
};
|
||||||
overall_qualified: BacktestBucket;
|
overall_qualified: BacktestBucket;
|
||||||
overall_all: BacktestBucket;
|
overall_all: BacktestBucket;
|
||||||
|
|||||||
@@ -25,3 +25,19 @@ in Git history if a forensic reconstruction is ever necessary.
|
|||||||
|
|
||||||
The initial untracked `backtest-20260712-sr-detector-rewrite.json` is local-only
|
The initial untracked `backtest-20260712-sr-detector-rewrite.json` is local-only
|
||||||
and is intentionally not part of the repository.
|
and is intentionally not part of the repository.
|
||||||
|
|
||||||
|
The 2026-07-17 post-stop re-entry decision is preserved in
|
||||||
|
`daily_reentry_matrix.json`. It is the canonical source for the nine-policy
|
||||||
|
daily replay, production-like full-universe ranking, the disjoint 2025+ book,
|
||||||
|
and the cost/capacity sensitivity matrix. The interpretation and production
|
||||||
|
decision live in
|
||||||
|
[`docs/research/post-stop-reentry.md`](../docs/research/post-stop-reentry.md).
|
||||||
|
|
||||||
|
The earlier `post-stop-reentry-20260717.json`,
|
||||||
|
`post-stop-cooldown-sweep-20260717.json`, and
|
||||||
|
`gate-protected-stop-20260717.json` reports were removed as superseded
|
||||||
|
intermediate experiments. They used weekly/hybrid entry cadence or tested the
|
||||||
|
rejected stop-adjustment path, and add no decision evidence beyond the final
|
||||||
|
daily matrix and narrative. Their matching one-off runners were removed too.
|
||||||
|
All remain recoverable from Git history. Rebuildable candidate pickle caches
|
||||||
|
are intentionally ignored and must not be committed.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
"""Run the four production cadence/re-entry arms on one offline snapshot.
|
||||||
|
|
||||||
|
The command executes the complete backtest once weekly and once daily. Each
|
||||||
|
backtest contains two otherwise identical live-policy portfolio arms: immediate
|
||||||
|
post-stop re-entry and the production gate-reset rule. It writes both full
|
||||||
|
reports plus one compact four-arm comparison report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"snapshot",
|
||||||
|
help="SQLite snapshot created by scripts/create_backtest_snapshot.py.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out-dir",
|
||||||
|
default="reports",
|
||||||
|
help="Directory for the weekly, daily, and comparison JSON reports.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prefix",
|
||||||
|
default=None,
|
||||||
|
help="Output prefix. Defaults to backtest-cadence-<timestamp>.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workers",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Override worker count; on a powerful offline PC use CPU count minus one.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-spawn",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable multiprocessing spawn for the offline Windows run.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="Hide ticker progress.")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_url(path: Path) -> str:
|
||||||
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, payload: dict) -> None:
|
||||||
|
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_arms(report: dict) -> list[dict]:
|
||||||
|
comparison = report.get("production_cadence_comparison") or {}
|
||||||
|
arms = list(comparison.get("arms") or [])
|
||||||
|
if len(arms) != 2:
|
||||||
|
cadence = (report.get("params") or {}).get("entry_cadence", "unknown")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected two live comparison arms for {cadence}; found {len(arms)}"
|
||||||
|
)
|
||||||
|
return arms
|
||||||
|
|
||||||
|
|
||||||
|
def _print_arm(row: dict) -> None:
|
||||||
|
print(
|
||||||
|
f" {row['arm']}: Sharpe {row.get('sharpe')}, "
|
||||||
|
f"CAGR {row.get('cagr_pct')}%, DD {row.get('max_drawdown_pct')}%, "
|
||||||
|
f"trades {row.get('trades')}, post-stop re-entries "
|
||||||
|
f"{row.get('post_stop_reentries', 0)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
snapshot = Path(args.snapshot)
|
||||||
|
if not snapshot.exists():
|
||||||
|
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||||
|
|
||||||
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
|
if args.allow_spawn:
|
||||||
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.backtest_service import run_backtest
|
||||||
|
|
||||||
|
if args.workers is not None:
|
||||||
|
settings.backtest_workers = args.workers
|
||||||
|
|
||||||
|
out_dir = Path(args.out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
prefix = args.prefix or f"backtest-cadence-{datetime.now():%Y%m%d-%H%M%S}"
|
||||||
|
|
||||||
|
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||||
|
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
reports: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
async with Session() as db:
|
||||||
|
for cadence in ("weekly", "daily"):
|
||||||
|
last_progress: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
def progress(done: int, total: int, symbol: str) -> None:
|
||||||
|
nonlocal last_progress
|
||||||
|
if args.quiet or last_progress == (done, total):
|
||||||
|
return
|
||||||
|
last_progress = (done, total)
|
||||||
|
label = f" {symbol}" if symbol else ""
|
||||||
|
print(
|
||||||
|
f"{cadence} progress: {done}/{total}{label}",
|
||||||
|
end="\r",
|
||||||
|
)
|
||||||
|
|
||||||
|
reports[cadence] = await run_backtest(
|
||||||
|
db,
|
||||||
|
progress_cb=progress,
|
||||||
|
target_model="production_gtl",
|
||||||
|
cadence=cadence,
|
||||||
|
)
|
||||||
|
if not args.quiet:
|
||||||
|
print("")
|
||||||
|
_write_json(out_dir / f"{prefix}-{cadence}.json", reports[cadence])
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
arms = [
|
||||||
|
*_comparison_arms(reports["weekly"]),
|
||||||
|
*_comparison_arms(reports["daily"]),
|
||||||
|
]
|
||||||
|
expected = {
|
||||||
|
"prod_live_setup_weekly",
|
||||||
|
"prod_live_setup_daily",
|
||||||
|
"gate_reset_weekly",
|
||||||
|
"gate_reset_daily",
|
||||||
|
}
|
||||||
|
if {row.get("arm") for row in arms} != expected:
|
||||||
|
raise RuntimeError("The generated cadence report does not contain all four arms")
|
||||||
|
arm_order = {
|
||||||
|
"prod_live_setup_weekly": 0,
|
||||||
|
"prod_live_setup_daily": 1,
|
||||||
|
"gate_reset_weekly": 2,
|
||||||
|
"gate_reset_daily": 3,
|
||||||
|
}
|
||||||
|
arms.sort(key=lambda row: arm_order[str(row["arm"])])
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"target_model": "production_gtl",
|
||||||
|
"arms": arms,
|
||||||
|
"full_reports": {
|
||||||
|
cadence: str((out_dir / f"{prefix}-{cadence}.json").resolve())
|
||||||
|
for cadence in ("weekly", "daily")
|
||||||
|
},
|
||||||
|
"note": (
|
||||||
|
"All four arms use the same snapshot, activation settings, target model, "
|
||||||
|
"live Admin exit policy, fees, sizing, and portfolio constraints. Within "
|
||||||
|
"each cadence pair, only the post-stop gate-reset rule differs."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
comparison_path = out_dir / f"{prefix}-comparison.json"
|
||||||
|
_write_json(comparison_path, comparison)
|
||||||
|
|
||||||
|
print(f"Comparison written: {comparison_path}")
|
||||||
|
for row in arms:
|
||||||
|
_print_arm(row)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -32,7 +32,10 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--out",
|
"--out",
|
||||||
default=None,
|
default=None,
|
||||||
help="JSON report path. Defaults to reports/backtest-<timestamp>.json.",
|
help=(
|
||||||
|
"JSON report path. Defaults to "
|
||||||
|
"reports/backtest-<cadence>-<timestamp>.json."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--workers",
|
"--workers",
|
||||||
@@ -55,6 +58,15 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
"structural_sr is a comparison-only chart-S/R model."
|
"structural_sr is a comparison-only chart-S/R model."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cadence",
|
||||||
|
choices=("weekly", "daily"),
|
||||||
|
default="weekly",
|
||||||
|
help=(
|
||||||
|
"Entry replay cadence. Weekly is the resource-safe production default; "
|
||||||
|
"daily performs roughly five times as many setup evaluations."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--holdout-split",
|
"--holdout-split",
|
||||||
default=None,
|
default=None,
|
||||||
@@ -63,9 +75,9 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def _default_output_path() -> Path:
|
def _default_output_path(cadence: str) -> Path:
|
||||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
return Path("reports") / f"backtest-{stamp}.json"
|
return Path("reports") / f"backtest-{cadence}-{stamp}.json"
|
||||||
|
|
||||||
|
|
||||||
def _pct(value: Any) -> str:
|
def _pct(value: Any) -> str:
|
||||||
@@ -93,6 +105,7 @@ def _print_summary(report: dict) -> None:
|
|||||||
|
|
||||||
print("")
|
print("")
|
||||||
print("Backtest summary")
|
print("Backtest summary")
|
||||||
|
print(f" entry cadence: {(report.get('params') or {}).get('entry_cadence', 'weekly')}")
|
||||||
print(f" candidates: {report.get('candidates')}")
|
print(f" candidates: {report.get('candidates')}")
|
||||||
print(f" qualified: {report.get('qualified')}")
|
print(f" qualified: {report.get('qualified')}")
|
||||||
print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}")
|
print(f" all setups net avg R: {_r(all_setups.get('net_avg_r'))}")
|
||||||
@@ -183,7 +196,7 @@ async def _main() -> None:
|
|||||||
if args.workers is not None:
|
if args.workers is not None:
|
||||||
settings.backtest_workers = args.workers
|
settings.backtest_workers = args.workers
|
||||||
|
|
||||||
output = Path(args.out) if args.out else _default_output_path()
|
output = Path(args.out) if args.out else _default_output_path(args.cadence)
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||||
@@ -208,6 +221,7 @@ async def _main() -> None:
|
|||||||
db,
|
db,
|
||||||
progress_cb=progress,
|
progress_cb=progress,
|
||||||
target_model=args.target_model,
|
target_model=args.target_model,
|
||||||
|
cadence=args.cadence,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,861 @@
|
|||||||
|
"""Run the full daily post-stop re-entry study from one candidate replay.
|
||||||
|
|
||||||
|
The expensive point-in-time setup replay happens once. The result is ranked in
|
||||||
|
both the existing backtest candidate universe and a live-like one-row-per-ticker
|
||||||
|
universe. Every policy, lookback, transaction-cost, capacity, and holdout arm is
|
||||||
|
then evaluated under both ranking modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
POLICY_NAMES = (
|
||||||
|
"immediate",
|
||||||
|
"next_session",
|
||||||
|
"cooldown_2",
|
||||||
|
"cooldown_3",
|
||||||
|
"cooldown_5",
|
||||||
|
"gate_reset",
|
||||||
|
"strict_gate_reset",
|
||||||
|
"gate_reset_improved",
|
||||||
|
"two_session_confirmation",
|
||||||
|
)
|
||||||
|
RANKING_MODES = ("backtest_legacy", "live_universe")
|
||||||
|
CACHE_VERSION = "daily-reentry-matrix-v3-dual-ranking"
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_url(path: Path) -> str:
|
||||||
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("snapshot", help="SQLite backtest snapshot.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--start-date",
|
||||||
|
default=None,
|
||||||
|
help="Optional earliest replay/simulation date (YYYY-MM-DD).",
|
||||||
|
)
|
||||||
|
parser.add_argument("--workers", type=int, default=6)
|
||||||
|
parser.add_argument("--out", default=None)
|
||||||
|
parser.add_argument(
|
||||||
|
"--candidate-cache",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Optional pickle cache. It stores both ranked, long-only qualified "
|
||||||
|
"candidate sets, not the much larger raw replay."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--policies",
|
||||||
|
nargs="+",
|
||||||
|
choices=POLICY_NAMES,
|
||||||
|
default=list(POLICY_NAMES),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ranking-modes",
|
||||||
|
nargs="+",
|
||||||
|
choices=RANKING_MODES,
|
||||||
|
default=list(RANKING_MODES),
|
||||||
|
help=(
|
||||||
|
"backtest_legacy reproduces the existing directional-candidate "
|
||||||
|
"ranking; live_universe ranks each ticker once per session."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--base-cost-per-side-pct", type=float, default=0.1)
|
||||||
|
parser.add_argument("--base-capacity", type=int, default=10)
|
||||||
|
parser.add_argument(
|
||||||
|
"--costs-per-side-pct",
|
||||||
|
type=float,
|
||||||
|
nargs="+",
|
||||||
|
default=[0.1, 0.2, 0.3],
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--capacities", type=int, nargs="+", default=[5, 10, 15]
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--holdout-split",
|
||||||
|
default="2025-01-01",
|
||||||
|
help="Train/test split date (YYYY-MM-DD), or 'none' to disable.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--quiet", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_output_path() -> Path:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _period_percentiles(
|
||||||
|
observations: list[dict], value_key: str
|
||||||
|
) -> dict[tuple[str, str], float]:
|
||||||
|
"""Production-style percentiles, one deterministic symbol row per period."""
|
||||||
|
by_period: dict[tuple, list[dict]] = {}
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for row in observations:
|
||||||
|
identity = (str(row["symbol"]), str(row["date"]))
|
||||||
|
if identity in seen:
|
||||||
|
raise ValueError(f"Duplicate universe rank observation: {identity}")
|
||||||
|
seen.add(identity)
|
||||||
|
if row.get(value_key) is None:
|
||||||
|
continue
|
||||||
|
period = tuple(row["ranking_period"])
|
||||||
|
by_period.setdefault(period, []).append(row)
|
||||||
|
|
||||||
|
result: dict[tuple[str, str], float] = {}
|
||||||
|
for group in by_period.values():
|
||||||
|
ordered = sorted(
|
||||||
|
group,
|
||||||
|
key=lambda row: (float(row[value_key]), str(row["symbol"])),
|
||||||
|
)
|
||||||
|
denominator = len(ordered) - 1
|
||||||
|
for rank, row in enumerate(ordered):
|
||||||
|
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||||
|
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _live_universe_rank_map(
|
||||||
|
observations: list[dict],
|
||||||
|
benchmark_closes: dict[date, float],
|
||||||
|
momentum_weight: float,
|
||||||
|
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||||
|
"""Historical equivalent of ``compute_activation_ranks``.
|
||||||
|
|
||||||
|
Every ticker contributes at most once per session. Residual momentum starts
|
||||||
|
only once 252 benchmark closes were point-in-time available; earlier dates
|
||||||
|
use the same raw-momentum fallback as production.
|
||||||
|
"""
|
||||||
|
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
|
||||||
|
if len(identities) != len(set(identities)):
|
||||||
|
raise ValueError("Universe ranking requires one observation per ticker/date")
|
||||||
|
|
||||||
|
raw_pct = _period_percentiles(observations, "momentum")
|
||||||
|
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||||
|
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||||
|
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||||
|
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||||
|
|
||||||
|
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||||
|
for row in observations:
|
||||||
|
identity = (str(row["symbol"]), str(row["date"]))
|
||||||
|
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||||
|
momentum_pct = (
|
||||||
|
residual_pct.get(identity)
|
||||||
|
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||||
|
else raw_pct.get(identity)
|
||||||
|
)
|
||||||
|
volatility_pct = vol_pct.get(identity)
|
||||||
|
strategy_rank = (
|
||||||
|
round(
|
||||||
|
momentum_pct * momentum_weight
|
||||||
|
+ volatility_pct * (1.0 - momentum_weight),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if momentum_pct is not None and volatility_pct is not None
|
||||||
|
else momentum_pct
|
||||||
|
)
|
||||||
|
ranks[identity] = {
|
||||||
|
"momentum_percentile": momentum_pct,
|
||||||
|
"volatility_percentile": volatility_pct,
|
||||||
|
"strategy_rank": strategy_rank,
|
||||||
|
}
|
||||||
|
return ranks
|
||||||
|
|
||||||
|
|
||||||
|
class PrecomputedDailyEngine:
|
||||||
|
"""Exact date/symbol lookup over the already-ranked production gate."""
|
||||||
|
|
||||||
|
def __init__(self, qualified_candidates: list[dict]) -> None:
|
||||||
|
self.by_key = {
|
||||||
|
(row["symbol"], date.fromisoformat(row["date"]).toordinal()): row
|
||||||
|
for row in qualified_candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
def candidate(self, symbol: str, asof_ord: int) -> dict | None:
|
||||||
|
row = self.by_key.get((symbol, asof_ord))
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class ReentryPolicy:
|
||||||
|
"""Stateful policy evaluated after every initial-stop exit."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
engine: PrecomputedDailyEngine,
|
||||||
|
ranking_key: str,
|
||||||
|
) -> None:
|
||||||
|
if name not in POLICY_NAMES:
|
||||||
|
raise ValueError(f"Unknown re-entry policy: {name}")
|
||||||
|
self.name = name
|
||||||
|
self.engine = engine
|
||||||
|
self.ranking_key = ranking_key
|
||||||
|
self.checks = 0
|
||||||
|
self.gate_passes = 0
|
||||||
|
self.emitted = Counter()
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
asof_ord: int,
|
||||||
|
state: dict,
|
||||||
|
_bar: Any,
|
||||||
|
) -> dict | None:
|
||||||
|
self.checks += 1
|
||||||
|
sessions = int(state["sessions_since_stop"])
|
||||||
|
candidate = self.engine.candidate(symbol, asof_ord)
|
||||||
|
if candidate is None:
|
||||||
|
# The strict reset deliberately ignores the stop-day gate state:
|
||||||
|
# it requires a later completed session to go unqualified before
|
||||||
|
# any requalification can trigger a new entry.
|
||||||
|
if self.name != "strict_gate_reset" or sessions >= 1:
|
||||||
|
state["gate_went_unqualified"] = True
|
||||||
|
state["qualified_streak"] = 0
|
||||||
|
return None
|
||||||
|
self.gate_passes += 1
|
||||||
|
|
||||||
|
# Two-session confirmation means two complete post-stop closes. The
|
||||||
|
# stop day's close (sessions=0) deliberately does not count.
|
||||||
|
if self.name == "two_session_confirmation" and sessions == 0:
|
||||||
|
state["qualified_streak"] = 0
|
||||||
|
return None
|
||||||
|
state["qualified_streak"] = int(state.get("qualified_streak", 0)) + 1
|
||||||
|
|
||||||
|
reason: str | None = None
|
||||||
|
if self.name == "immediate":
|
||||||
|
reason = "gate_still_or_again_qualified"
|
||||||
|
elif self.name == "next_session":
|
||||||
|
if sessions >= 1:
|
||||||
|
reason = "stop_day_block_complete"
|
||||||
|
elif self.name.startswith("cooldown_"):
|
||||||
|
cooldown_sessions = int(self.name.removeprefix("cooldown_"))
|
||||||
|
if sessions >= cooldown_sessions:
|
||||||
|
reason = f"{cooldown_sessions}_session_cooldown_complete"
|
||||||
|
elif self.name == "gate_reset":
|
||||||
|
if state["gate_went_unqualified"]:
|
||||||
|
reason = "gate_failed_then_requalified"
|
||||||
|
elif self.name == "strict_gate_reset":
|
||||||
|
if state["gate_went_unqualified"]:
|
||||||
|
reason = "post_stop_gate_failed_then_requalified"
|
||||||
|
elif self.name == "gate_reset_improved":
|
||||||
|
previous_rank = state.get("previous_rank")
|
||||||
|
current_rank = candidate.get(self.ranking_key)
|
||||||
|
rank_not_weaker = (
|
||||||
|
current_rank is not None
|
||||||
|
and (
|
||||||
|
previous_rank is None
|
||||||
|
or float(current_rank) >= float(previous_rank)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
state["gate_went_unqualified"]
|
||||||
|
and float(candidate["stop"]) > float(state["previous_stop"])
|
||||||
|
and rank_not_weaker
|
||||||
|
):
|
||||||
|
reason = "gate_reset_with_improved_stop_and_rank"
|
||||||
|
elif self.name == "two_session_confirmation":
|
||||||
|
if state["qualified_streak"] >= 2:
|
||||||
|
reason = "two_qualified_post_stop_closes"
|
||||||
|
|
||||||
|
if reason is None:
|
||||||
|
return None
|
||||||
|
emitted = dict(candidate)
|
||||||
|
emitted["_reentry_reason"] = reason
|
||||||
|
self.emitted[reason] += 1
|
||||||
|
return emitted
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"daily_checks": self.checks,
|
||||||
|
"qualified_checks": self.gate_passes,
|
||||||
|
"emitted_candidates_by_reason": dict(self.emitted),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trade_summary(trades: list[dict]) -> dict:
|
||||||
|
reentries = [trade for trade in trades if trade.get("is_reentry")]
|
||||||
|
waits = [
|
||||||
|
int(trade["reentry_wait_sessions"])
|
||||||
|
for trade in reentries
|
||||||
|
if trade.get("reentry_wait_sessions") is not None
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"transaction_cost": round(
|
||||||
|
sum(float(trade["transaction_cost"]) for trade in trades), 2
|
||||||
|
),
|
||||||
|
"reentry_trades": len(reentries),
|
||||||
|
"same_day_reentries": sum(wait == 0 for wait in waits),
|
||||||
|
"next_session_reentries": sum(wait == 1 for wait in waits),
|
||||||
|
"reentries_within_5_sessions": sum(wait <= 5 for wait in waits),
|
||||||
|
"avg_reentry_wait_sessions": (
|
||||||
|
round(sum(waits) / len(waits), 1) if waits else None
|
||||||
|
),
|
||||||
|
"reentry_win_rate": (
|
||||||
|
round(
|
||||||
|
sum(float(trade["pnl"]) > 0 for trade in reentries)
|
||||||
|
/ len(reentries)
|
||||||
|
* 100.0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if reentries
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"reentry_total_pnl": round(
|
||||||
|
sum(float(trade["pnl"]) for trade in reentries), 2
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_date(value: str | None, option: str) -> date | None:
|
||||||
|
if value is None or value.strip().lower() == "none":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(f"{option} must use YYYY-MM-DD or 'none'") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _max_date(left: date | None, right: date | None) -> date | None:
|
||||||
|
if left is None:
|
||||||
|
return right
|
||||||
|
if right is None:
|
||||||
|
return left
|
||||||
|
return max(left, right)
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
snapshot = Path(args.snapshot)
|
||||||
|
if not snapshot.exists():
|
||||||
|
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||||
|
requested_start = _parse_optional_date(args.start_date, "--start-date")
|
||||||
|
holdout_split = _parse_optional_date(args.holdout_split, "--holdout-split")
|
||||||
|
if args.workers < 1:
|
||||||
|
raise SystemExit("--workers must be positive")
|
||||||
|
if args.base_capacity < 1 or any(value < 1 for value in args.capacities):
|
||||||
|
raise SystemExit("capacities must be positive")
|
||||||
|
all_costs = sorted(
|
||||||
|
set([args.base_cost_per_side_pct, *args.costs_per_side_pct])
|
||||||
|
)
|
||||||
|
if any(value < 0 or value >= 100 for value in all_costs):
|
||||||
|
raise SystemExit("cost percentages must be in [0, 100)")
|
||||||
|
all_capacities = sorted(set([args.base_capacity, *args.capacities]))
|
||||||
|
policies = tuple(dict.fromkeys(args.policies))
|
||||||
|
ranking_modes = tuple(dict.fromkeys(args.ranking_modes))
|
||||||
|
|
||||||
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
|
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.services import backtest_service as bt
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
from app.services.paper_trade_service import get_exit_policy
|
||||||
|
from app.services.recommendation_service import get_recommendation_config
|
||||||
|
|
||||||
|
db_engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||||
|
Session = async_sessionmaker(
|
||||||
|
db_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with Session() as db:
|
||||||
|
recommendation_config = await get_recommendation_config(db)
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
exit_config = await get_exit_policy(db)
|
||||||
|
benchmark_closes = await bt._load_benchmark_closes_for_backtest(
|
||||||
|
db, days=None, refresh=False
|
||||||
|
)
|
||||||
|
ticker_result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||||
|
symbols = [ticker.symbol for ticker in ticker_result.scalars().all()]
|
||||||
|
prices: dict[str, tuple] = {}
|
||||||
|
for index, symbol in enumerate(symbols, 1):
|
||||||
|
columns = await bt._fetch_columns(db, symbol)
|
||||||
|
if columns is not None:
|
||||||
|
prices[symbol] = columns
|
||||||
|
if not args.quiet and index % 50 == 0:
|
||||||
|
print(f"loaded prices: {index}/{len(symbols)}", flush=True)
|
||||||
|
finally:
|
||||||
|
await db_engine.dispose()
|
||||||
|
|
||||||
|
replay_start = requested_start or date(1900, 1, 1)
|
||||||
|
snapshot_stat = snapshot.stat()
|
||||||
|
cache_key = {
|
||||||
|
"version": CACHE_VERSION,
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"snapshot_size": snapshot_stat.st_size,
|
||||||
|
"snapshot_mtime_ns": snapshot_stat.st_mtime_ns,
|
||||||
|
"start_date": replay_start.isoformat(),
|
||||||
|
"cadence": "daily",
|
||||||
|
"target_model": "production_gtl",
|
||||||
|
}
|
||||||
|
cache_path = Path(args.candidate_cache) if args.candidate_cache else None
|
||||||
|
qualified_candidates_by_mode: dict[str, list[dict]] | None = None
|
||||||
|
entry_candidate_count = 0
|
||||||
|
entry_candidates_by_direction: dict[str, int] = {}
|
||||||
|
universe_rank_observations = 0
|
||||||
|
last_eligible_replay_date: date | None = None
|
||||||
|
if cache_path is not None and cache_path.exists():
|
||||||
|
with cache_path.open("rb") as handle:
|
||||||
|
cached = pickle.load(handle) # noqa: S301 - trusted local cache
|
||||||
|
if cached.get("key") == cache_key:
|
||||||
|
qualified_candidates_by_mode = {
|
||||||
|
mode: list(rows)
|
||||||
|
for mode, rows in cached["qualified_candidates_by_mode"].items()
|
||||||
|
}
|
||||||
|
entry_candidate_count = int(cached["entry_candidate_count"])
|
||||||
|
entry_candidates_by_direction = dict(
|
||||||
|
cached["entry_candidates_by_direction"]
|
||||||
|
)
|
||||||
|
universe_rank_observations = int(cached["universe_rank_observations"])
|
||||||
|
last_eligible_replay_date = date.fromisoformat(
|
||||||
|
cached["last_eligible_replay_date"]
|
||||||
|
)
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"loaded qualified candidate cache: {cache_path}", flush=True)
|
||||||
|
elif not args.quiet:
|
||||||
|
print(f"candidate cache mismatch; rebuilding: {cache_path}", flush=True)
|
||||||
|
|
||||||
|
if qualified_candidates_by_mode is None:
|
||||||
|
replay_rows: list[dict] = []
|
||||||
|
workers = max(1, min(int(args.workers), multiprocessing.cpu_count() - 1))
|
||||||
|
context = bt._mp_context() or multiprocessing.get_context("spawn")
|
||||||
|
with ProcessPoolExecutor(max_workers=workers, mp_context=context) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(
|
||||||
|
bt._replay_candidates_for_period,
|
||||||
|
symbol,
|
||||||
|
columns,
|
||||||
|
recommendation_config,
|
||||||
|
activation,
|
||||||
|
benchmark_closes,
|
||||||
|
replay_start,
|
||||||
|
"daily",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
): symbol
|
||||||
|
for symbol, columns in prices.items()
|
||||||
|
}
|
||||||
|
for index, future in enumerate(as_completed(futures), 1):
|
||||||
|
replay_rows.extend(future.result())
|
||||||
|
if not args.quiet and index % 25 == 0:
|
||||||
|
print(f"daily replay: {index}/{len(futures)} tickers", flush=True)
|
||||||
|
|
||||||
|
setup_candidates = [
|
||||||
|
row for row in replay_rows if not row.get("_rank_only")
|
||||||
|
]
|
||||||
|
rank_observations = [
|
||||||
|
row for row in replay_rows if row.get("_universe_rank_observation")
|
||||||
|
]
|
||||||
|
entry_candidate_count = len(setup_candidates)
|
||||||
|
entry_candidates_by_direction = dict(
|
||||||
|
Counter(row["direction"] for row in setup_candidates)
|
||||||
|
)
|
||||||
|
universe_rank_observations = len(rank_observations)
|
||||||
|
last_eligible_replay_date = max(
|
||||||
|
date.fromisoformat(row["date"]) for row in rank_observations
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing research-backtest semantics: rank every directional setup
|
||||||
|
# candidate, then apply the long-only production gate.
|
||||||
|
bt._assign_momentum_percentiles(setup_candidates)
|
||||||
|
bt._assign_residual_momentum_percentiles(setup_candidates)
|
||||||
|
bt._assign_low_volatility_percentiles(setup_candidates)
|
||||||
|
bt._assign_activation_momentum_percentiles(setup_candidates)
|
||||||
|
bt._assign_residual_high_vol_blend(setup_candidates)
|
||||||
|
threshold = float(activation.get("min_momentum_percentile", 80.0))
|
||||||
|
for candidate in setup_candidates:
|
||||||
|
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
|
||||||
|
legacy_qualified = [
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in candidate.items()
|
||||||
|
if not key.startswith("_universe_")
|
||||||
|
}
|
||||||
|
for candidate in setup_candidates
|
||||||
|
if candidate["qualified"] and candidate.get("direction") == "long"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Live semantics: rank each ticker once per session, independent of
|
||||||
|
# whether it has a setup, and attach that ticker rank only to longs.
|
||||||
|
live_ranks = _live_universe_rank_map(
|
||||||
|
rank_observations,
|
||||||
|
benchmark_closes,
|
||||||
|
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||||
|
)
|
||||||
|
live_qualified: list[dict] = []
|
||||||
|
for setup in setup_candidates:
|
||||||
|
if setup.get("direction") != "long":
|
||||||
|
continue
|
||||||
|
candidate = {
|
||||||
|
key: value
|
||||||
|
for key, value in setup.items()
|
||||||
|
if not key.startswith("_universe_")
|
||||||
|
}
|
||||||
|
rank = live_ranks[(str(setup["symbol"]), str(setup["date"]))]
|
||||||
|
candidate[bt.PRODUCTION_PERCENTILE_KEY] = rank["momentum_percentile"]
|
||||||
|
candidate[bt.VOL_PERCENTILE_KEY] = rank["volatility_percentile"]
|
||||||
|
candidate[bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] = rank["strategy_rank"]
|
||||||
|
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
|
||||||
|
if candidate["qualified"]:
|
||||||
|
live_qualified.append(candidate)
|
||||||
|
|
||||||
|
qualified_candidates_by_mode = {
|
||||||
|
"backtest_legacy": legacy_qualified,
|
||||||
|
"live_universe": live_qualified,
|
||||||
|
}
|
||||||
|
del replay_rows, setup_candidates, rank_observations, live_ranks
|
||||||
|
if cache_path is not None:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with cache_path.open("wb") as handle:
|
||||||
|
pickle.dump(
|
||||||
|
{
|
||||||
|
"key": cache_key,
|
||||||
|
"entry_candidate_count": entry_candidate_count,
|
||||||
|
"entry_candidates_by_direction": (
|
||||||
|
entry_candidates_by_direction
|
||||||
|
),
|
||||||
|
"universe_rank_observations": universe_rank_observations,
|
||||||
|
"last_eligible_replay_date": (
|
||||||
|
last_eligible_replay_date.isoformat()
|
||||||
|
),
|
||||||
|
"qualified_candidates_by_mode": (
|
||||||
|
qualified_candidates_by_mode
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handle,
|
||||||
|
protocol=pickle.HIGHEST_PROTOCOL,
|
||||||
|
)
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"wrote qualified candidate cache: {cache_path}", flush=True)
|
||||||
|
|
||||||
|
if last_eligible_replay_date is None or qualified_candidates_by_mode is None:
|
||||||
|
raise RuntimeError("Daily replay produced no ranking observations")
|
||||||
|
for mode in ranking_modes:
|
||||||
|
if not qualified_candidates_by_mode.get(mode):
|
||||||
|
raise RuntimeError(f"Daily replay produced no qualified candidates for {mode}")
|
||||||
|
|
||||||
|
strategy = next(
|
||||||
|
row for row in bt.PORTFOLIO_MONITOR_STRATEGIES if row.get("is_production")
|
||||||
|
)
|
||||||
|
entry_config = bt._entry_variant_config(str(strategy["entry_variant"]))
|
||||||
|
if entry_config is None:
|
||||||
|
raise RuntimeError("Production entry configuration missing")
|
||||||
|
ranking_key = str(
|
||||||
|
entry_config.get("ranking_key") or entry_config["percentile_key"]
|
||||||
|
)
|
||||||
|
threshold = float(activation.get("min_momentum_percentile", 80.0))
|
||||||
|
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
|
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
|
)
|
||||||
|
hold_days = int(exit_config.get("hold_days", max(bt.TIME_EXIT_DAYS)))
|
||||||
|
trail_multiplier = float(
|
||||||
|
exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER)
|
||||||
|
)
|
||||||
|
if ranking_key != bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Daily matrix expects the production 80/20 strategy ranking key"
|
||||||
|
)
|
||||||
|
simulation_prices = prices
|
||||||
|
last_candidate_date = last_eligible_replay_date
|
||||||
|
latest_ord = max(max(columns[0]) for columns in simulation_prices.values())
|
||||||
|
latest_date = date.fromordinal(latest_ord)
|
||||||
|
if holdout_split is not None and not (
|
||||||
|
(requested_start or date.min) < holdout_split <= latest_date
|
||||||
|
):
|
||||||
|
raise SystemExit(
|
||||||
|
f"--holdout-split must be after the start and no later than {latest_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_completed_sims = 0
|
||||||
|
|
||||||
|
def run_ranking_mode(mode: str, qualified_candidates: list[dict]) -> dict:
|
||||||
|
nonlocal total_completed_sims
|
||||||
|
daily_engine = PrecomputedDailyEngine(qualified_candidates)
|
||||||
|
run_cache: dict[tuple, dict] = {}
|
||||||
|
completed_sims = 0
|
||||||
|
|
||||||
|
def run_policy(
|
||||||
|
policy_name: str,
|
||||||
|
*,
|
||||||
|
start_date: date | None,
|
||||||
|
end_date: date | None,
|
||||||
|
cost_pct: float,
|
||||||
|
capacity: int,
|
||||||
|
) -> dict:
|
||||||
|
nonlocal completed_sims, total_completed_sims
|
||||||
|
key = (policy_name, start_date, end_date, float(cost_pct), int(capacity))
|
||||||
|
if key in run_cache:
|
||||||
|
return copy.deepcopy(run_cache[key])
|
||||||
|
policy = ReentryPolicy(policy_name, daily_engine, ranking_key)
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
qualified_candidates,
|
||||||
|
simulation_prices,
|
||||||
|
benchmark_closes,
|
||||||
|
exit_policy,
|
||||||
|
hold_days,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
max_positions=capacity,
|
||||||
|
risk_per_trade=float(entry_config["risk_per_trade"]),
|
||||||
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
cost_per_side=cost_pct / 100.0,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
post_stop_reentry_fn=policy,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
if sim is None:
|
||||||
|
raise RuntimeError(f"Policy {policy_name} produced no trades in {mode}")
|
||||||
|
trades = list(sim.pop("trade_details"))
|
||||||
|
events = list(sim.pop("reentry_events", []))
|
||||||
|
row = {
|
||||||
|
**sim,
|
||||||
|
"turnover": _trade_summary(trades),
|
||||||
|
"policy": policy.summary(),
|
||||||
|
"reentry_events": events,
|
||||||
|
}
|
||||||
|
run_cache[key] = row
|
||||||
|
completed_sims += 1
|
||||||
|
total_completed_sims += 1
|
||||||
|
if not args.quiet:
|
||||||
|
print(
|
||||||
|
f"portfolio simulations: {total_completed_sims} "
|
||||||
|
f"({mode}, {policy_name}, cost={cost_pct}%, capacity={capacity})",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return copy.deepcopy(row)
|
||||||
|
|
||||||
|
primary: list[dict] = []
|
||||||
|
for lookback in bt.PORTFOLIO_MONITOR_LOOKBACKS:
|
||||||
|
lookback_start = bt._lookback_start(latest_ord, lookback["days"])
|
||||||
|
sim_start = _max_date(requested_start, lookback_start)
|
||||||
|
for policy_name in policies:
|
||||||
|
row = run_policy(
|
||||||
|
policy_name,
|
||||||
|
start_date=sim_start,
|
||||||
|
end_date=None,
|
||||||
|
cost_pct=args.base_cost_per_side_pct,
|
||||||
|
capacity=args.base_capacity,
|
||||||
|
)
|
||||||
|
if lookback["lookback"] != "all":
|
||||||
|
row.pop("reentry_events", None)
|
||||||
|
primary.append({
|
||||||
|
"arm": policy_name,
|
||||||
|
"lookback": lookback["lookback"],
|
||||||
|
"lookback_label": lookback["label"],
|
||||||
|
"capacity": args.base_capacity,
|
||||||
|
**row,
|
||||||
|
})
|
||||||
|
|
||||||
|
immediate_baseline_parity: dict
|
||||||
|
if "immediate" in policies:
|
||||||
|
direct_daily_baseline = bt._simulate_portfolio(
|
||||||
|
qualified_candidates,
|
||||||
|
simulation_prices,
|
||||||
|
benchmark_closes,
|
||||||
|
exit_policy,
|
||||||
|
hold_days,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
max_positions=args.base_capacity,
|
||||||
|
risk_per_trade=float(entry_config["risk_per_trade"]),
|
||||||
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
cost_per_side=args.base_cost_per_side_pct / 100.0,
|
||||||
|
start_date=requested_start,
|
||||||
|
)
|
||||||
|
if direct_daily_baseline is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Direct daily no-lockdown baseline produced no trades in {mode}"
|
||||||
|
)
|
||||||
|
immediate_all = next(
|
||||||
|
row
|
||||||
|
for row in primary
|
||||||
|
if row["lookback"] == "all" and row["arm"] == "immediate"
|
||||||
|
)
|
||||||
|
parity_fields = tuple(sorted(direct_daily_baseline))
|
||||||
|
parity_differences = {
|
||||||
|
field: {
|
||||||
|
"direct_daily_baseline": direct_daily_baseline.get(field),
|
||||||
|
"immediate_callback": immediate_all.get(field),
|
||||||
|
}
|
||||||
|
for field in parity_fields
|
||||||
|
if direct_daily_baseline.get(field) != immediate_all.get(field)
|
||||||
|
}
|
||||||
|
if parity_differences:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Immediate callback diverges in {mode}: {parity_differences}"
|
||||||
|
)
|
||||||
|
immediate_baseline_parity = {
|
||||||
|
"passed": True,
|
||||||
|
"compared_fields": list(parity_fields),
|
||||||
|
"direct_daily_baseline": direct_daily_baseline,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
immediate_baseline_parity = {
|
||||||
|
"passed": None,
|
||||||
|
"skipped": "immediate policy was not selected",
|
||||||
|
}
|
||||||
|
|
||||||
|
robustness: list[dict] = []
|
||||||
|
for cost_pct in all_costs:
|
||||||
|
for capacity in all_capacities:
|
||||||
|
for policy_name in policies:
|
||||||
|
row = run_policy(
|
||||||
|
policy_name,
|
||||||
|
start_date=requested_start,
|
||||||
|
end_date=None,
|
||||||
|
cost_pct=cost_pct,
|
||||||
|
capacity=capacity,
|
||||||
|
)
|
||||||
|
row.pop("reentry_events", None)
|
||||||
|
robustness.append({
|
||||||
|
"arm": policy_name,
|
||||||
|
"lookback": "all",
|
||||||
|
"cost_per_side_pct_requested": cost_pct,
|
||||||
|
"capacity": capacity,
|
||||||
|
**row,
|
||||||
|
})
|
||||||
|
|
||||||
|
holdout: list[dict] = []
|
||||||
|
if holdout_split is not None:
|
||||||
|
for segment, segment_start, segment_end in (
|
||||||
|
("train", requested_start, holdout_split),
|
||||||
|
("test", _max_date(requested_start, holdout_split), None),
|
||||||
|
):
|
||||||
|
for policy_name in policies:
|
||||||
|
row = run_policy(
|
||||||
|
policy_name,
|
||||||
|
start_date=segment_start,
|
||||||
|
end_date=segment_end,
|
||||||
|
cost_pct=args.base_cost_per_side_pct,
|
||||||
|
capacity=args.base_capacity,
|
||||||
|
)
|
||||||
|
row.pop("reentry_events", None)
|
||||||
|
holdout.append({
|
||||||
|
"arm": policy_name,
|
||||||
|
"segment": segment,
|
||||||
|
"split_date": holdout_split.isoformat(),
|
||||||
|
"capacity": args.base_capacity,
|
||||||
|
**row,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"description": (
|
||||||
|
"Existing historical backtest approximation: directional setup "
|
||||||
|
"candidates form the rank cross-section; shorts never qualify."
|
||||||
|
if mode == "backtest_legacy"
|
||||||
|
else "Live-like historical rank: every ticker contributes once per "
|
||||||
|
"session before the long-only setup gate is applied."
|
||||||
|
),
|
||||||
|
"qualified_candidates": len(qualified_candidates),
|
||||||
|
"tickers_qualified": len({row["symbol"] for row in qualified_candidates}),
|
||||||
|
"primary_lookback_matrix": primary,
|
||||||
|
"immediate_baseline_parity": immediate_baseline_parity,
|
||||||
|
"cost_capacity_robustness": robustness,
|
||||||
|
"holdout": holdout,
|
||||||
|
"portfolio_simulations_executed": completed_sims,
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking_results = {
|
||||||
|
mode: run_ranking_mode(mode, qualified_candidates_by_mode[mode])
|
||||||
|
for mode in ranking_modes
|
||||||
|
}
|
||||||
|
|
||||||
|
output = Path(args.out) if args.out else _default_output_path()
|
||||||
|
report = {
|
||||||
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"period_start_requested": (
|
||||||
|
requested_start.isoformat() if requested_start else None
|
||||||
|
),
|
||||||
|
"last_eligible_candidate_date": last_candidate_date.isoformat(),
|
||||||
|
"portfolio_asof_date": latest_date.isoformat(),
|
||||||
|
"tickers_loaded": len(prices),
|
||||||
|
"entry_candidates": entry_candidate_count,
|
||||||
|
"entry_candidates_by_direction": entry_candidates_by_direction,
|
||||||
|
"universe_rank_observations": universe_rank_observations,
|
||||||
|
"qualified_candidates_by_ranking_mode": {
|
||||||
|
mode: len(qualified_candidates_by_mode[mode]) for mode in ranking_modes
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"entry_cadence": "daily",
|
||||||
|
"target_model": "production_gtl",
|
||||||
|
"ranking_modes": list(ranking_modes),
|
||||||
|
"policies": list(policies),
|
||||||
|
"base_cost_per_side_pct": args.base_cost_per_side_pct,
|
||||||
|
"base_capacity": args.base_capacity,
|
||||||
|
"robustness_costs_per_side_pct": all_costs,
|
||||||
|
"robustness_capacities": all_capacities,
|
||||||
|
"holdout_split": (
|
||||||
|
holdout_split.isoformat() if holdout_split else None
|
||||||
|
),
|
||||||
|
"setup_stop_atr_multiplier": bt.ATR_MULTIPLIER,
|
||||||
|
"exit_policy": exit_policy,
|
||||||
|
"exit_atr_multiplier": trail_multiplier,
|
||||||
|
"hold_days": hold_days,
|
||||||
|
"risk_per_trade": float(entry_config["risk_per_trade"]),
|
||||||
|
"momentum_percentile_floor": threshold,
|
||||||
|
"ranking_key": ranking_key,
|
||||||
|
},
|
||||||
|
"ranking_results": ranking_results,
|
||||||
|
"portfolio_simulations_executed": total_completed_sims,
|
||||||
|
"validation_simulations_executed": (
|
||||||
|
len(ranking_modes) if "immediate" in policies else 0
|
||||||
|
),
|
||||||
|
"note": (
|
||||||
|
"The expensive point-in-time daily setup replay is executed once. "
|
||||||
|
"backtest_legacy preserves the existing candidate-rank approximation; "
|
||||||
|
"live_universe ranks every ticker once per session like production. "
|
||||||
|
"Both modes remain strictly long-only after ranking and then run the "
|
||||||
|
"same policy, lookback, cost, capacity, and holdout matrix. Each "
|
||||||
|
"immediate callback must match its direct no-lockdown simulation exactly. "
|
||||||
|
"Immediate is the daily no-lockdown baseline; next_session blocks only "
|
||||||
|
"the stop day; cooldown_N permits re-entry at wait_sessions=N; gate_reset "
|
||||||
|
"counts the stop-day gate state, while strict_gate_reset requires an "
|
||||||
|
"unqualified close on a later completed session before requalification; "
|
||||||
|
"gate_reset_improved additionally requires a higher stop and a non-weaker "
|
||||||
|
"production rank; two_session_confirmation requires two consecutive "
|
||||||
|
"qualified post-stop closes and excludes the stop day's close. Transaction "
|
||||||
|
"costs alter cash and position sizing, not just reported P&L."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"Report written: {output}")
|
||||||
|
for mode, mode_result in ranking_results.items():
|
||||||
|
print(f"Ranking mode: {mode}")
|
||||||
|
for row in mode_result["primary_lookback_matrix"]:
|
||||||
|
if row["lookback"] == "all":
|
||||||
|
print(
|
||||||
|
f" {row['arm']}: Sharpe {row['sharpe']}, "
|
||||||
|
f"CAGR {row['cagr_pct']}%, DD {row['max_drawdown_pct']}%, "
|
||||||
|
f"trades {row['trades']}, "
|
||||||
|
f"reentries {row['turnover']['reentry_trades']}, "
|
||||||
|
f"fees ${row['turnover']['transaction_cost']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -543,6 +543,7 @@ class TestSimulatePortfolio:
|
|||||||
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
|
||||||
assert sim is not None
|
assert sim is not None
|
||||||
assert sim["trades"] == 1
|
assert sim["trades"] == 1
|
||||||
|
assert sim["cost_per_side_pct"] == pytest.approx(0.1)
|
||||||
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
|
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
|
||||||
# pnl = 2120 − 2000 − 2.00 entry cost − 2.12 exit cost = 115.88
|
# pnl = 2120 − 2000 − 2.00 entry cost − 2.12 exit cost = 115.88
|
||||||
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
|
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
|
||||||
@@ -556,6 +557,29 @@ class TestSimulatePortfolio:
|
|||||||
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
|
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_cost_parameter_changes_cash_and_position_path(self):
|
||||||
|
closes = [100.0, 102.0, 104.0, 106.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
|
||||||
|
|
||||||
|
free = bt._simulate_portfolio(
|
||||||
|
[cand], prices, None, "hold", 3, cost_per_side=0.0
|
||||||
|
)
|
||||||
|
stressed = bt._simulate_portfolio(
|
||||||
|
[cand], prices, None, "hold", 3, cost_per_side=0.002
|
||||||
|
)
|
||||||
|
|
||||||
|
assert free is not None and stressed is not None
|
||||||
|
assert free["final_equity"] == pytest.approx(10_120.0, abs=0.01)
|
||||||
|
assert stressed["cost_per_side_pct"] == pytest.approx(0.2)
|
||||||
|
assert stressed["final_equity"] == pytest.approx(10_111.76, abs=0.01)
|
||||||
|
|
||||||
|
def test_cost_parameter_rejects_invalid_rate(self):
|
||||||
|
with pytest.raises(ValueError, match="cost_per_side"):
|
||||||
|
bt._simulate_portfolio(
|
||||||
|
[], {}, None, "hold", 3, cost_per_side=-0.001
|
||||||
|
)
|
||||||
|
|
||||||
def test_target_policy_exits_at_target(self):
|
def test_target_policy_exits_at_target(self):
|
||||||
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
@@ -575,6 +599,298 @@ class TestSimulatePortfolio:
|
|||||||
assert sim["trades"] == 1
|
assert sim["trades"] == 1
|
||||||
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 − 100) / 5
|
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 − 100) / 5
|
||||||
|
|
||||||
|
def test_initial_stop_cooldown_blocks_immediate_reentry(self):
|
||||||
|
closes = [100.0, 94.0, 96.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
_sim_cand("AAA", self.ORD + 1, entry=94.0, stop=89.0, target=110.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
baseline = bt._simulate_portfolio(candidates, prices, None, "hold", 30)
|
||||||
|
cooldown = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
reentry_cooldown_sessions=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert baseline is not None and baseline["trades"] == 2
|
||||||
|
assert cooldown is not None and cooldown["trades"] == 1
|
||||||
|
assert cooldown["skipped_cooldown"] == 1
|
||||||
|
assert cooldown["reentry_cooldown_sessions"] == 5
|
||||||
|
|
||||||
|
def test_initial_stop_cooldown_unlocks_exactly_after_session_five(self):
|
||||||
|
closes = [100.0, 94.0, 96.0, 96.0, 96.0, 96.0, 97.0, 98.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
# Four completed sessions since the stop: still locked.
|
||||||
|
_sim_cand("AAA", self.ORD + 5, entry=96.0, stop=90.0, target=115.0),
|
||||||
|
# Five completed sessions since the stop: first permitted re-entry.
|
||||||
|
_sim_cand("AAA", self.ORD + 6, entry=97.0, stop=90.0, target=118.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
reentry_cooldown_sessions=5,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 2
|
||||||
|
assert sim["skipped_cooldown"] == 1
|
||||||
|
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||||
|
self.ORD + 6
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
def test_post_stop_reentry_cannot_cross_holdout_end(self):
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, [100.0, 94.0, 96.0, 98.0])}
|
||||||
|
candidate = _sim_cand(
|
||||||
|
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
|
||||||
|
)
|
||||||
|
callback_dates: list[int] = []
|
||||||
|
|
||||||
|
def reenter_after_split(symbol, asof_ord, _state, _bar):
|
||||||
|
callback_dates.append(asof_ord)
|
||||||
|
if asof_ord < self.ORD + 2:
|
||||||
|
return None
|
||||||
|
return _sim_cand(
|
||||||
|
symbol, asof_ord, entry=96.0, stop=90.0, target=115.0
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[candidate],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
3,
|
||||||
|
end_date=date.fromordinal(self.ORD + 2),
|
||||||
|
post_stop_reentry_fn=reenter_after_split,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
assert callback_dates == [self.ORD + 1]
|
||||||
|
|
||||||
|
def test_gate_reset_waits_for_failed_evaluation_then_requalification(self):
|
||||||
|
closes = [100.0] * 95
|
||||||
|
entry_ord = self.ORD + bt.MIN_LOOKBACK - 1
|
||||||
|
stop_ord = entry_ord + 1
|
||||||
|
reentry_ord = entry_ord + 3
|
||||||
|
closes[bt.MIN_LOOKBACK] = 94.0
|
||||||
|
closes[bt.MIN_LOOKBACK + 1] = 95.0
|
||||||
|
closes[bt.MIN_LOOKBACK + 2] = 96.0
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", entry_ord, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
# Still qualified on the stop day: this must not unlock re-entry.
|
||||||
|
_sim_cand("AAA", stop_ord, entry=94.0, stop=89.0, target=110.0),
|
||||||
|
# No candidate on the intervening session means the daily gate
|
||||||
|
# failed. A fresh qualification on the next session may re-enter.
|
||||||
|
_sim_cand("AAA", reentry_ord, entry=96.0, stop=90.0, target=115.0),
|
||||||
|
]
|
||||||
|
gate_reset = bt._make_gate_reset_reentry_fn(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
cadence="daily",
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
post_stop_reentry_fn=gate_reset,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["post_stop_reentries"] == 1
|
||||||
|
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||||
|
reentry_ord
|
||||||
|
).isoformat()
|
||||||
|
assert sim["reentry_events"][0]["wait_sessions"] == 2
|
||||||
|
|
||||||
|
def test_production_monitor_applies_live_gate_reset(self, monkeypatch):
|
||||||
|
def fake_simulator(*_args, **kwargs):
|
||||||
|
return {
|
||||||
|
"trades": 0,
|
||||||
|
"applied_gate_reset": kwargs.get("post_stop_reentry_fn") is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||||
|
market_ord = date(2026, 7, 1).toordinal()
|
||||||
|
prices = {"AAA": ([market_ord], [], [], [], [], [])}
|
||||||
|
|
||||||
|
monitor = bt._portfolio_monitor([], prices, None, 30)
|
||||||
|
production_rows = [
|
||||||
|
row for row in monitor["runs"] if row["is_production"]
|
||||||
|
]
|
||||||
|
immediate_rows = [
|
||||||
|
row for row in monitor["runs"]
|
||||||
|
if row["comparison_arm"] == "live_immediate"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert production_rows
|
||||||
|
assert all(
|
||||||
|
row["reentry_policy"] == "gate_reset"
|
||||||
|
and row["applied_gate_reset"] is True
|
||||||
|
for row in production_rows
|
||||||
|
)
|
||||||
|
assert immediate_rows
|
||||||
|
assert all(
|
||||||
|
row["reentry_policy"] == "immediate"
|
||||||
|
and row["applied_gate_reset"] is False
|
||||||
|
for row in immediate_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_production_cadence_comparison_names_exact_two_arms(self):
|
||||||
|
monitor = {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"comparison_arm": "live_immediate",
|
||||||
|
"lookback": "all",
|
||||||
|
"reentry_policy": "immediate",
|
||||||
|
"trades": 10,
|
||||||
|
"equity_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comparison_arm": "live_gate_reset",
|
||||||
|
"lookback": "all",
|
||||||
|
"reentry_policy": "gate_reset",
|
||||||
|
"trades": 8,
|
||||||
|
"benchmark_curve": [{"date": "2026-01-01", "value": 1.0}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
comparison = bt._production_cadence_comparison(monitor, "daily")
|
||||||
|
|
||||||
|
assert comparison is not None
|
||||||
|
assert [row["arm"] for row in comparison["arms"]] == [
|
||||||
|
"prod_live_setup_daily",
|
||||||
|
"gate_reset_daily",
|
||||||
|
]
|
||||||
|
assert all("equity_curve" not in row for row in comparison["arms"])
|
||||||
|
assert all("benchmark_curve" not in row for row in comparison["arms"])
|
||||||
|
|
||||||
|
def test_initial_stop_can_refresh_lower_and_survive_same_bar(self):
|
||||||
|
closes = [100.0, 94.0, 96.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidate = _sim_cand(
|
||||||
|
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[candidate],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
2,
|
||||||
|
initial_stop_refresh_fn=lambda *_: 90.0,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["stop_refresh_attempts"] == 1
|
||||||
|
assert sim["stop_refreshes"] == 1
|
||||||
|
assert sim["stop_refresh_same_bar_hits"] == 0
|
||||||
|
assert sim["exit_reasons"] == {"time": 1}
|
||||||
|
assert sim["trade_details"][0]["stop_refreshes"] == 1
|
||||||
|
|
||||||
|
def test_refreshed_stop_is_checked_against_same_bar(self):
|
||||||
|
ords = list(range(self.ORD, self.ORD + 2))
|
||||||
|
prices = {
|
||||||
|
"AAA": (
|
||||||
|
ords,
|
||||||
|
[100.0, 94.0],
|
||||||
|
[101.0, 96.0],
|
||||||
|
[99.0, 89.0],
|
||||||
|
[100.0, 94.0],
|
||||||
|
[1, 1],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
candidate = _sim_cand(
|
||||||
|
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[candidate],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
initial_stop_refresh_fn=lambda *_: 90.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["stop_refresh_same_bar_hits"] == 1
|
||||||
|
assert sim["worst_trade_r"] == pytest.approx(-2.0)
|
||||||
|
|
||||||
|
def test_post_stop_state_suppresses_same_episode_candidate(self):
|
||||||
|
closes = [100.0, 94.0, 96.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
_sim_cand("AAA", self.ORD + 1, entry=94.0, stop=89.0, target=110.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
post_stop_reentry_fn=lambda *_: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
assert sim["post_stop_events"] == 1
|
||||||
|
assert sim["post_stop_reentries"] == 0
|
||||||
|
assert sim["post_stop_states_open_at_end"] == 1
|
||||||
|
|
||||||
|
def test_post_stop_callback_can_reenter_same_day(self):
|
||||||
|
closes = [100.0, 94.0, 96.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
initial = _sim_cand(
|
||||||
|
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def immediate_reentry(sym, current_ord, _state, bar):
|
||||||
|
return _sim_cand(
|
||||||
|
sym,
|
||||||
|
current_ord,
|
||||||
|
entry=bar.close,
|
||||||
|
stop=bar.close - 5.0,
|
||||||
|
target=bar.close + 15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[initial],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
post_stop_reentry_fn=immediate_reentry,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 2
|
||||||
|
assert sim["post_stop_reentries"] == 1
|
||||||
|
assert sim["reentry_events"][0]["wait_sessions"] == 0
|
||||||
|
assert sim["trade_details"][1]["is_reentry"] is True
|
||||||
|
assert sim["trade_details"][1]["reentry_wait_sessions"] == 0
|
||||||
|
|
||||||
def test_sma50_policy_exits_on_close_break(self):
|
def test_sma50_policy_exits_on_close_break(self):
|
||||||
closes = [100.0] * 56 + [90.0, 91.0]
|
closes = [100.0] * 56 + [90.0, 91.0]
|
||||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
@@ -722,6 +1038,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
|
|||||||
})
|
})
|
||||||
assert rec["headline"] is not None
|
assert rec["headline"] is not None
|
||||||
assert "3x ATR trailing exit" in rec["headline"]
|
assert "3x ATR trailing exit" in rec["headline"]
|
||||||
|
assert "after the gate fails" in rec["headline"]
|
||||||
assert any(item["topic"] == "production" for item in rec["items"])
|
assert any(item["topic"] == "production" for item in rec["items"])
|
||||||
|
|
||||||
|
|
||||||
@@ -736,6 +1053,15 @@ def test_backtest_target_model_is_small_and_validated():
|
|||||||
bt.validate_backtest_target_model("legacy_range_grid_touch")
|
bt.validate_backtest_target_model("legacy_range_grid_touch")
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_cadence_is_small_validated_and_session_based():
|
||||||
|
assert bt.validate_backtest_cadence(" WEEKLY ") == "weekly"
|
||||||
|
assert bt.validate_backtest_cadence("daily") == "daily"
|
||||||
|
assert bt.backtest_step_sessions("weekly") == 5
|
||||||
|
assert bt.backtest_step_sessions("daily") == 1
|
||||||
|
with pytest.raises(ValueError, match="Unknown backtest cadence"):
|
||||||
|
bt.validate_backtest_cadence("monthly")
|
||||||
|
|
||||||
|
|
||||||
def _flat_window_records():
|
def _flat_window_records():
|
||||||
return [
|
return [
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
@@ -829,6 +1155,110 @@ def test_replay_ticker_candidates_carry_gate_fields():
|
|||||||
assert c.get("action") is not None
|
assert c.get("action") is not None
|
||||||
assert "risk_level" in c
|
assert "risk_level" in c
|
||||||
assert c["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
assert c["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
||||||
|
assert c["ranking_period"][0] == "week"
|
||||||
|
|
||||||
|
daily_cands = bt._replay_ticker(
|
||||||
|
"OSC",
|
||||||
|
bars,
|
||||||
|
dict(DEFAULT_RECOMMENDATION_CONFIG),
|
||||||
|
dict(ACTIVATION_DEFAULTS),
|
||||||
|
cadence="daily",
|
||||||
|
)
|
||||||
|
assert len(daily_cands) > len(cands)
|
||||||
|
assert all(c["ranking_period"][0] == "date" for c in daily_cands)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slim_replay_can_retain_shorts_for_ranking_universe(monkeypatch):
|
||||||
|
setup = {
|
||||||
|
"entry": 100.0,
|
||||||
|
"stop": 95.0,
|
||||||
|
"target": 110.0,
|
||||||
|
"rr": 2.0,
|
||||||
|
"confidence": 80.0,
|
||||||
|
"primary_prob": 0.6,
|
||||||
|
"best_prob": 0.7,
|
||||||
|
"momentum": 0.1,
|
||||||
|
"meets_core": True,
|
||||||
|
"action": "BUY_MODERATE",
|
||||||
|
"risk_level": "MEDIUM",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
bt,
|
||||||
|
"_window_setups",
|
||||||
|
lambda *_args, **_kwargs: [
|
||||||
|
{**setup, "direction": "long"},
|
||||||
|
{**setup, "direction": "short", "stop": 105.0, "target": 90.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
count = bt.MIN_LOOKBACK + bt.HORIZON
|
||||||
|
first_ord = date(2025, 1, 1).toordinal()
|
||||||
|
columns = (
|
||||||
|
list(range(first_ord, first_ord + count)),
|
||||||
|
[100.0] * count,
|
||||||
|
[101.0] * count,
|
||||||
|
[99.0] * count,
|
||||||
|
[100.0] * count,
|
||||||
|
[1_000_000] * count,
|
||||||
|
)
|
||||||
|
|
||||||
|
long_only = bt._replay_candidates_for_period(
|
||||||
|
"AAA", columns, {}, {}, None, date.min, "daily"
|
||||||
|
)
|
||||||
|
full_ranking_universe = bt._replay_candidates_for_period(
|
||||||
|
"AAA", columns, {}, {}, None, date.min, "daily", True
|
||||||
|
)
|
||||||
|
dual_ranking_replay = bt._replay_candidates_for_period(
|
||||||
|
"AAA", columns, {}, {}, None, date.min, "daily", True, True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [row["direction"] for row in long_only] == ["long"]
|
||||||
|
assert {row["direction"] for row in full_ranking_universe} == {
|
||||||
|
"long",
|
||||||
|
"short",
|
||||||
|
}
|
||||||
|
assert len(dual_ranking_replay) == 2
|
||||||
|
assert sum(
|
||||||
|
bool(row.get("_universe_rank_observation"))
|
||||||
|
for row in dual_ranking_replay
|
||||||
|
) == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(bt, "_window_setups", lambda *_args, **_kwargs: [])
|
||||||
|
rank_only = bt._replay_candidates_for_period(
|
||||||
|
"AAA", columns, {}, {}, None, date.min, "daily", True, True
|
||||||
|
)
|
||||||
|
assert len(rank_only) == 1
|
||||||
|
assert rank_only[0]["direction"] == "rank_only"
|
||||||
|
assert rank_only[0]["_rank_only"] is True
|
||||||
|
assert rank_only[0]["_universe_rank_observation"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_replay_uses_exact_date_ranking_periods():
|
||||||
|
candidates = [
|
||||||
|
{
|
||||||
|
"iso_week": (2026, 1),
|
||||||
|
"ranking_period": ("date", date(2026, 1, 5).toordinal()),
|
||||||
|
"momentum": 0.10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iso_week": (2026, 1),
|
||||||
|
"ranking_period": ("date", date(2026, 1, 5).toordinal()),
|
||||||
|
"momentum": 0.20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iso_week": (2026, 1),
|
||||||
|
"ranking_period": ("date", date(2026, 1, 6).toordinal()),
|
||||||
|
"momentum": 0.90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iso_week": (2026, 1),
|
||||||
|
"ranking_period": ("date", date(2026, 1, 6).toordinal()),
|
||||||
|
"momentum": 0.30,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
bt._assign_momentum_percentiles(candidates)
|
||||||
|
|
||||||
|
assert [row["momentum_percentile"] for row in candidates] == [0.0, 100.0, 100.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
|
||||||
@@ -870,12 +1300,20 @@ async def test_run_backtest_smoke(session):
|
|||||||
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
|
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
|
||||||
assert report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
assert report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
||||||
assert report["params"]["is_production_target_model"] is True
|
assert report["params"]["is_production_target_model"] is True
|
||||||
|
assert report["params"]["entry_cadence"] == "weekly"
|
||||||
|
assert report["params"]["step_sessions"] == 5
|
||||||
|
assert report["params"]["production_reentry_policy"] == "gate_reset"
|
||||||
assert "net_avg_r" in report["overall_all"]
|
assert "net_avg_r" in report["overall_all"]
|
||||||
|
|
||||||
# ablation baseline reproduces the qualified set exactly, and every row
|
# ablation baseline reproduces the qualified set exactly, and every row
|
||||||
# carries the hold-to-horizon grading alongside the target model
|
# carries the hold-to-horizon grading alongside the target model
|
||||||
ablation = {r["variant"]: r for r in report["gate_ablation"]}
|
ablation = {r["variant"]: r for r in report["gate_ablation"]}
|
||||||
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
|
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
|
||||||
|
|
||||||
|
daily_report = await bt.run_backtest(session, cadence="daily")
|
||||||
|
assert daily_report["params"]["entry_cadence"] == "daily"
|
||||||
|
assert daily_report["params"]["step_sessions"] == 1
|
||||||
|
assert daily_report["candidates"] > report["candidates"]
|
||||||
for row in report["gate_ablation"]:
|
for row in report["gate_ablation"]:
|
||||||
assert "hold_net_avg_r" in row
|
assert "hold_net_avg_r" in row
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.run_daily_reentry_matrix import (
|
||||||
|
PrecomputedDailyEngine,
|
||||||
|
ReentryPolicy,
|
||||||
|
_live_universe_rank_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
RANKING_KEY = "strategy_rank"
|
||||||
|
ORD = date(2025, 1, 6).toordinal()
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(day_ord: int, *, stop: float = 91.0, rank: float = 81.0) -> dict:
|
||||||
|
return {
|
||||||
|
"qualified": True,
|
||||||
|
"direction": "long",
|
||||||
|
"symbol": "AAA",
|
||||||
|
"date": date.fromordinal(day_ord).isoformat(),
|
||||||
|
"entry": 100.0,
|
||||||
|
"stop": stop,
|
||||||
|
"target": 120.0,
|
||||||
|
RANKING_KEY: rank,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _state(sessions: int = 0) -> dict:
|
||||||
|
return {
|
||||||
|
"sessions_since_stop": sessions,
|
||||||
|
"previous_stop": 90.0,
|
||||||
|
"previous_rank": 80.0,
|
||||||
|
"gate_went_unqualified": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _call(policy: ReentryPolicy, day_ord: int, state: dict, sessions: int):
|
||||||
|
state["sessions_since_stop"] = sessions
|
||||||
|
return policy("AAA", day_ord, state, object())
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_session_blocks_only_stop_day():
|
||||||
|
engine = PrecomputedDailyEngine([_candidate(ORD), _candidate(ORD + 1)])
|
||||||
|
policy = ReentryPolicy("next_session", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
assert _call(policy, ORD, state, 0) is None
|
||||||
|
assert _call(policy, ORD + 1, state, 1) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sessions", [2, 3, 5])
|
||||||
|
def test_cooldown_unlocks_at_exact_boundary(sessions):
|
||||||
|
engine = PrecomputedDailyEngine([
|
||||||
|
_candidate(ORD + sessions - 1),
|
||||||
|
_candidate(ORD + sessions),
|
||||||
|
])
|
||||||
|
policy = ReentryPolicy(f"cooldown_{sessions}", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
assert _call(policy, ORD + sessions - 1, state, sessions - 1) is None
|
||||||
|
assert _call(policy, ORD + sessions, state, sessions) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_reset_requires_failure_before_requalification():
|
||||||
|
engine = PrecomputedDailyEngine([_candidate(ORD), _candidate(ORD + 2)])
|
||||||
|
policy = ReentryPolicy("gate_reset", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
assert _call(policy, ORD, state, 0) is None
|
||||||
|
assert _call(policy, ORD + 1, state, 1) is None
|
||||||
|
emitted = _call(policy, ORD + 2, state, 2)
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted["_reentry_reason"] == "gate_failed_then_requalified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_gate_reset_ignores_stop_day_failure():
|
||||||
|
engine = PrecomputedDailyEngine([
|
||||||
|
_candidate(ORD + 1),
|
||||||
|
_candidate(ORD + 3),
|
||||||
|
])
|
||||||
|
policy = ReentryPolicy("strict_gate_reset", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
# An unqualified stop-day close alone does not reset the strict policy.
|
||||||
|
assert _call(policy, ORD, state, 0) is None
|
||||||
|
assert state["gate_went_unqualified"] is False
|
||||||
|
assert _call(policy, ORD + 1, state, 1) is None
|
||||||
|
|
||||||
|
# A later unqualified close establishes the reset; only then may the next
|
||||||
|
# qualified setup re-enter.
|
||||||
|
assert _call(policy, ORD + 2, state, 2) is None
|
||||||
|
assert state["gate_went_unqualified"] is True
|
||||||
|
emitted = _call(policy, ORD + 3, state, 3)
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted["_reentry_reason"] == (
|
||||||
|
"post_stop_gate_failed_then_requalified"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_improved_gate_reset_requires_better_stop_and_non_weaker_rank():
|
||||||
|
engine = PrecomputedDailyEngine([
|
||||||
|
_candidate(ORD + 1, stop=89.0, rank=82.0),
|
||||||
|
_candidate(ORD + 2, stop=92.0, rank=79.0),
|
||||||
|
_candidate(ORD + 3, stop=92.0, rank=81.0),
|
||||||
|
])
|
||||||
|
policy = ReentryPolicy("gate_reset_improved", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
assert _call(policy, ORD, state, 0) is None
|
||||||
|
assert _call(policy, ORD + 1, state, 1) is None
|
||||||
|
assert _call(policy, ORD + 2, state, 2) is None
|
||||||
|
emitted = _call(policy, ORD + 3, state, 3)
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted["_reentry_reason"] == "gate_reset_with_improved_stop_and_rank"
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_session_confirmation_excludes_stop_day_close():
|
||||||
|
engine = PrecomputedDailyEngine([
|
||||||
|
_candidate(ORD),
|
||||||
|
_candidate(ORD + 1),
|
||||||
|
_candidate(ORD + 2),
|
||||||
|
])
|
||||||
|
policy = ReentryPolicy("two_session_confirmation", engine, RANKING_KEY)
|
||||||
|
state = _state()
|
||||||
|
|
||||||
|
assert _call(policy, ORD, state, 0) is None
|
||||||
|
assert _call(policy, ORD + 1, state, 1) is None
|
||||||
|
emitted = _call(policy, ORD + 2, state, 2)
|
||||||
|
assert emitted is not None
|
||||||
|
assert emitted["_reentry_reason"] == "two_qualified_post_stop_closes"
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_observation(
|
||||||
|
symbol: str,
|
||||||
|
*,
|
||||||
|
raw: float,
|
||||||
|
residual: float,
|
||||||
|
volatility: float,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"date": date.fromordinal(ORD).isoformat(),
|
||||||
|
"ranking_period": ("date", ORD),
|
||||||
|
"momentum": raw,
|
||||||
|
"residual_momentum": residual,
|
||||||
|
"vol_6m": volatility,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_universe_rank_uses_each_ticker_once_and_residual_when_available():
|
||||||
|
observations = [
|
||||||
|
_rank_observation("AAA", raw=0.1, residual=0.3, volatility=0.1),
|
||||||
|
_rank_observation("BBB", raw=0.3, residual=0.1, volatility=0.2),
|
||||||
|
_rank_observation("CCC", raw=0.2, residual=0.2, volatility=0.3),
|
||||||
|
]
|
||||||
|
first_benchmark_day = date.fromordinal(ORD) - timedelta(days=300)
|
||||||
|
benchmark = {
|
||||||
|
first_benchmark_day + timedelta(days=offset): 100.0
|
||||||
|
for offset in range(252)
|
||||||
|
}
|
||||||
|
|
||||||
|
ranks = _live_universe_rank_map(observations, benchmark, 0.8)
|
||||||
|
|
||||||
|
assert ranks[("AAA", date.fromordinal(ORD).isoformat())] == {
|
||||||
|
"momentum_percentile": 100.0,
|
||||||
|
"volatility_percentile": 0.0,
|
||||||
|
"strategy_rank": 80.0,
|
||||||
|
}
|
||||||
|
assert ranks[("BBB", date.fromordinal(ORD).isoformat())][
|
||||||
|
"momentum_percentile"
|
||||||
|
] == 0.0
|
||||||
|
assert ranks[("CCC", date.fromordinal(ORD).isoformat())][
|
||||||
|
"strategy_rank"
|
||||||
|
] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_universe_rank_uses_raw_fallback_before_benchmark_is_ready():
|
||||||
|
observations = [
|
||||||
|
_rank_observation("AAA", raw=0.1, residual=0.3, volatility=0.1),
|
||||||
|
_rank_observation("BBB", raw=0.3, residual=0.1, volatility=0.2),
|
||||||
|
]
|
||||||
|
|
||||||
|
ranks = _live_universe_rank_map(observations, {}, 0.8)
|
||||||
|
|
||||||
|
assert ranks[("AAA", date.fromordinal(ORD).isoformat())][
|
||||||
|
"momentum_percentile"
|
||||||
|
] == 0.0
|
||||||
|
assert ranks[("BBB", date.fromordinal(ORD).isoformat())][
|
||||||
|
"momentum_percentile"
|
||||||
|
] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_universe_rank_rejects_duplicate_ticker_date():
|
||||||
|
observation = _rank_observation(
|
||||||
|
"AAA", raw=0.1, residual=0.2, volatility=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="one observation"):
|
||||||
|
_live_universe_rank_map([observation, dict(observation)], {}, 0.8)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
|
||||||
|
|
||||||
|
def _load_migration_module():
|
||||||
|
path = (
|
||||||
|
Path(__file__).resolve().parents[2]
|
||||||
|
/ "alembic"
|
||||||
|
/ "versions"
|
||||||
|
/ "022_add_paper_trade_reentry_gate_reset.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("migration_022", path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_grandfathers_only_preexisting_initial_stops():
|
||||||
|
migration = _load_migration_module()
|
||||||
|
engine = sa.create_engine("sqlite://")
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE paper_trades (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
close_reason VARCHAR,
|
||||||
|
closed_at DATETIME
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO paper_trades (id, status, close_reason, closed_at)
|
||||||
|
VALUES
|
||||||
|
(1, 'closed', 'stop', '2026-07-01 12:00:00'),
|
||||||
|
(2, 'closed', 'manual', '2026-07-02 12:00:00'),
|
||||||
|
(3, 'open', NULL, NULL)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
context = MigrationContext.configure(connection)
|
||||||
|
migration.op = Operations(context)
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
historical = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT closed_at, reentry_gate_failed_at,
|
||||||
|
reentry_gate_requalified_at
|
||||||
|
FROM paper_trades
|
||||||
|
WHERE id = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert historical[1] == historical[0]
|
||||||
|
assert historical[2] == historical[0]
|
||||||
|
|
||||||
|
unaffected = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT reentry_gate_failed_at, reentry_gate_requalified_at
|
||||||
|
FROM paper_trades
|
||||||
|
WHERE id IN (2, 3)
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert unaffected == [(None, None), (None, None)]
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO paper_trades (id, status, close_reason, closed_at)
|
||||||
|
VALUES (4, 'closed', 'stop', '2026-07-18 12:00:00')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_stop = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT reentry_gate_failed_at, reentry_gate_requalified_at
|
||||||
|
FROM paper_trades
|
||||||
|
WHERE id = 4
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert new_stop == (None, None)
|
||||||
@@ -48,6 +48,74 @@ async def test_create_and_list_open(session):
|
|||||||
assert row["current_price"] == 110.0 # marked to the latest close
|
assert row["current_price"] == 110.0 # marked to the latest close
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_trade_enforces_post_stop_gate_reset_at_service_boundary(session):
|
||||||
|
blocked_id = await _seed(session, "LOCKQ", close=100.0)
|
||||||
|
released_id = await _seed(session, "FREEQ", close=100.0)
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
def stopped_trade(ticker_id: int, *, gate_reset_complete: bool) -> PaperTrade:
|
||||||
|
closed_on = today - timedelta(days=10)
|
||||||
|
reset_at = datetime.combine(
|
||||||
|
closed_on + timedelta(days=1),
|
||||||
|
datetime.min.time(),
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
return PaperTrade(
|
||||||
|
user_id=1,
|
||||||
|
ticker_id=ticker_id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
status="closed",
|
||||||
|
opened_at=datetime.combine(
|
||||||
|
closed_on - timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
close_price=95.0,
|
||||||
|
closed_at=datetime.combine(
|
||||||
|
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
close_reason="stop",
|
||||||
|
reentry_gate_failed_at=reset_at if gate_reset_complete else None,
|
||||||
|
reentry_gate_requalified_at=(
|
||||||
|
reset_at + timedelta(days=1) if gate_reset_complete else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
stopped_trade(blocked_id, gate_reset_complete=False),
|
||||||
|
stopped_trade(released_id, gate_reset_complete=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="requires a post-stop gate reset"):
|
||||||
|
await svc.create_trade(
|
||||||
|
session,
|
||||||
|
1,
|
||||||
|
symbol="LOCKQ",
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
trade = await svc.create_trade(
|
||||||
|
session,
|
||||||
|
1,
|
||||||
|
symbol="FREEQ",
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
)
|
||||||
|
assert trade.ticker_id == released_id
|
||||||
|
|
||||||
|
|
||||||
async def test_close_uses_current_price(session):
|
async def test_close_uses_current_price(session):
|
||||||
await _seed(session, "AAA", close=112.0)
|
await _seed(session, "AAA", close=112.0)
|
||||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||||
|
|||||||
@@ -607,6 +607,102 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
|||||||
assert [row["symbol"] for row in ticker_rows] == ["OPENQ"]
|
assert [row["symbol"] for row in ticker_rows] == ["OPENQ"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_trade_setups_applies_initial_stop_gate_reset_lock(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if await db_session.get(User, 1) is None:
|
||||||
|
db_session.add(
|
||||||
|
User(id=1, username="u", password_hash="x", role="user", has_access=True)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
blocked = Ticker(symbol="STOP4")
|
||||||
|
released = Ticker(symbol="STOP5")
|
||||||
|
trailing = Ticker(symbol="TRAILQ")
|
||||||
|
db_session.add_all([blocked, released, trailing])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
for ticker in (blocked, released, trailing):
|
||||||
|
db_session.add(
|
||||||
|
TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
rr_ratio=3.0,
|
||||||
|
composite_score=80.0,
|
||||||
|
detected_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def closed_trade(
|
||||||
|
ticker: Ticker,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
gate_reset_complete: bool = False,
|
||||||
|
) -> PaperTrade:
|
||||||
|
closed_on = now.date() - timedelta(days=10)
|
||||||
|
return PaperTrade(
|
||||||
|
user_id=1,
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
status="closed",
|
||||||
|
opened_at=datetime.combine(
|
||||||
|
closed_on - timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
close_price=95.0,
|
||||||
|
closed_at=datetime.combine(
|
||||||
|
closed_on, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
close_reason=reason,
|
||||||
|
reentry_gate_failed_at=(
|
||||||
|
now - timedelta(days=9) if gate_reset_complete else None
|
||||||
|
),
|
||||||
|
reentry_gate_requalified_at=(
|
||||||
|
now - timedelta(days=8) if gate_reset_complete else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
closed_trade(blocked, "stop"),
|
||||||
|
closed_trade(released, "stop", gate_reset_complete=True),
|
||||||
|
closed_trade(trailing, "trailing"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
default_symbols = {
|
||||||
|
row["symbol"] for row in await get_trade_setups(db_session)
|
||||||
|
}
|
||||||
|
assert {"STOP4", "STOP5", "TRAILQ"}.issubset(default_symbols)
|
||||||
|
|
||||||
|
available_symbols = {
|
||||||
|
row["symbol"]
|
||||||
|
for row in await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
exclude_reentry_gate_locked_tickers=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert "STOP4" not in available_symbols
|
||||||
|
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
|
||||||
|
|
||||||
|
annotated = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="STOP4",
|
||||||
|
include_reentry_gate_lock=True,
|
||||||
|
)
|
||||||
|
assert len(annotated) == 1
|
||||||
|
assert annotated[0]["reentry_gate_reset_required"] is True
|
||||||
|
|
||||||
|
|
||||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||||
(bullish sentiment, composite 96) that yields live confidence 97.
|
(bullish sentiment, composite 96) that yields live confidence 97.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.scheduler import (
|
from app.scheduler import (
|
||||||
|
_consume_backtest_options,
|
||||||
_consume_backtest_target_model,
|
_consume_backtest_target_model,
|
||||||
_parse_frequency,
|
_parse_frequency,
|
||||||
_resume_tickers,
|
_resume_tickers,
|
||||||
_last_successful,
|
_last_successful,
|
||||||
configure_scheduler,
|
configure_scheduler,
|
||||||
|
queue_backtest_options,
|
||||||
queue_backtest_target_model,
|
queue_backtest_target_model,
|
||||||
scheduler,
|
scheduler,
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,15 @@ def test_manual_backtest_target_model_rejects_removed_research_arms():
|
|||||||
queue_backtest_target_model("production_control")
|
queue_backtest_target_model("production_control")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_backtest_options_are_one_shot_and_default_back_to_weekly():
|
||||||
|
assert queue_backtest_options("structural_sr", "daily") == (
|
||||||
|
"structural_sr",
|
||||||
|
"daily",
|
||||||
|
)
|
||||||
|
assert _consume_backtest_options() == ("structural_sr", "daily")
|
||||||
|
assert _consume_backtest_options() == ("production_gtl", "weekly")
|
||||||
|
|
||||||
|
|
||||||
class TestParseFrequency:
|
class TestParseFrequency:
|
||||||
def test_hourly(self):
|
def test_hourly(self):
|
||||||
assert _parse_frequency("hourly") == {"hours": 1}
|
assert _parse_frequency("hourly") == {"hours": 1}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.paper_trade import PaperTrade
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.trade_policy import (
|
||||||
|
get_reentry_gate_locks,
|
||||||
|
observe_reentry_gate_transitions,
|
||||||
|
)
|
||||||
|
from tests.conftest import _test_session_factory # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session():
|
||||||
|
async with _test_session_factory() as db:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
def _stopped_trade(
|
||||||
|
ticker_id: int,
|
||||||
|
*,
|
||||||
|
closed_at: datetime,
|
||||||
|
close_reason: str = "stop",
|
||||||
|
gate_failed_at: datetime | None = None,
|
||||||
|
gate_requalified_at: datetime | None = None,
|
||||||
|
) -> PaperTrade:
|
||||||
|
return PaperTrade(
|
||||||
|
user_id=1,
|
||||||
|
ticker_id=ticker_id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
status="closed",
|
||||||
|
opened_at=closed_at - timedelta(days=5),
|
||||||
|
close_price=95.0,
|
||||||
|
closed_at=closed_at,
|
||||||
|
close_reason=close_reason,
|
||||||
|
reentry_gate_failed_at=gate_failed_at,
|
||||||
|
reentry_gate_requalified_at=gate_requalified_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_observation_releases_only_evaluated_unqualified_tickers(session):
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
tickers = [
|
||||||
|
Ticker(symbol=symbol)
|
||||||
|
for symbol in ("FAILQ", "PASSQ", "ERRORQ", "LATEQ")
|
||||||
|
]
|
||||||
|
session.add_all(tickers)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
stopped_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
|
trades = [
|
||||||
|
_stopped_trade(ticker.id, closed_at=stopped_at)
|
||||||
|
for ticker in tickers[:3]
|
||||||
|
]
|
||||||
|
observed_at = datetime.now(timezone.utc)
|
||||||
|
trades.append(
|
||||||
|
_stopped_trade(
|
||||||
|
tickers[3].id,
|
||||||
|
closed_at=observed_at + timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add_all(trades)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await observe_reentry_gate_transitions(
|
||||||
|
session,
|
||||||
|
evaluated_ticker_ids={tickers[0].id, tickers[1].id, tickers[3].id},
|
||||||
|
qualified_ticker_ids={tickers[1].id},
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == {tickers[0].id}
|
||||||
|
locks = await get_reentry_gate_locks(session)
|
||||||
|
assert set(locks) == {ticker.id for ticker in tickers}
|
||||||
|
assert trades[0].reentry_gate_failed_at == observed_at
|
||||||
|
assert trades[0].reentry_gate_requalified_at is None
|
||||||
|
assert trades[1].reentry_gate_failed_at is None
|
||||||
|
assert trades[2].reentry_gate_failed_at is None
|
||||||
|
assert trades[3].reentry_gate_failed_at is None
|
||||||
|
|
||||||
|
requalified_at = observed_at + timedelta(days=1)
|
||||||
|
updated = await observe_reentry_gate_transitions(
|
||||||
|
session,
|
||||||
|
evaluated_ticker_ids={tickers[0].id},
|
||||||
|
qualified_ticker_ids={tickers[0].id},
|
||||||
|
observed_at=requalified_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == {tickers[0].id}
|
||||||
|
assert trades[0].reentry_gate_requalified_at == requalified_at
|
||||||
|
assert set(await get_reentry_gate_locks(session)) == {
|
||||||
|
tickers[1].id,
|
||||||
|
tickers[2].id,
|
||||||
|
tickers[3].id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_stop_starts_a_new_gate_reset_episode(session):
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
ticker = Ticker(symbol="TWOSTOP")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
first_stop = datetime.now(timezone.utc) - timedelta(days=20)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
_stopped_trade(
|
||||||
|
ticker.id,
|
||||||
|
closed_at=first_stop,
|
||||||
|
gate_failed_at=first_stop + timedelta(days=1),
|
||||||
|
gate_requalified_at=first_stop + timedelta(days=2),
|
||||||
|
),
|
||||||
|
_stopped_trade(
|
||||||
|
ticker.id,
|
||||||
|
closed_at=first_stop + timedelta(days=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert ticker.id in await get_reentry_gate_locks(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_newer_non_stop_exit_supersedes_historical_stop(session):
|
||||||
|
session.add(User(id=1, username="u", password_hash="x", role="user", has_access=True))
|
||||||
|
ticker = Ticker(symbol="LATEREXIT")
|
||||||
|
session.add(ticker)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
stopped_at = datetime.now(timezone.utc) - timedelta(days=20)
|
||||||
|
old_stop = _stopped_trade(ticker.id, closed_at=stopped_at)
|
||||||
|
later_manual_exit = _stopped_trade(
|
||||||
|
ticker.id,
|
||||||
|
closed_at=stopped_at + timedelta(days=10),
|
||||||
|
close_reason="manual",
|
||||||
|
)
|
||||||
|
session.add_all([old_stop, later_manual_exit])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert ticker.id not in await get_reentry_gate_locks(session)
|
||||||
|
|
||||||
|
observed_at = datetime.now(timezone.utc)
|
||||||
|
updated = await observe_reentry_gate_transitions(
|
||||||
|
session,
|
||||||
|
evaluated_ticker_ids={ticker.id},
|
||||||
|
qualified_ticker_ids=set(),
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == set()
|
||||||
|
assert old_stop.reentry_gate_failed_at is None
|
||||||
Reference in New Issue
Block a user