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
|
||||
# production baseline in the README. The snapshot DBs they run against are not.
|
||||
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:
|
||||
|
||||
> **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.
|
||||
|
||||
**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.
|
||||
|
||||
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"]
|
||||
|
||||
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 --> 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>"]
|
||||
|
||||
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 OPEN fill:#1e4d2b,color:#fff
|
||||
style E4 fill:#2a2a2a,color:#888
|
||||
style E1 fill:#4a1f1f,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
|
||||
|
||||
@@ -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.
|
||||
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).
|
||||
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.
|
||||
@@ -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 |
|
||||
| **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. |
|
||||
| **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`) |
|
||||
@@ -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.
|
||||
|
||||
### 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` |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
couple of threads for Windows, the shell, and browser/UI work while still using
|
||||
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 |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `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 |
|
||||
|
||||
@@ -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)
|
||||
# How the trade was closed: "time" | "trailing" | "stop" | "target" | "manual".
|
||||
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,
|
||||
job_name,
|
||||
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)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ async def list_trade_setups(
|
||||
recommended_action=recommended_action,
|
||||
live_recommendation=True,
|
||||
exclude_open_trade_tickers=True,
|
||||
exclude_reentry_gate_locked_tickers=True,
|
||||
)
|
||||
|
||||
data = []
|
||||
@@ -98,6 +99,7 @@ async def get_ticker_trade_setups(
|
||||
db,
|
||||
symbol=symbol,
|
||||
live_recommendation=True,
|
||||
include_reentry_gate_lock=True,
|
||||
)
|
||||
data = []
|
||||
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.backtest_service import (
|
||||
BACKTEST_TARGET_MODELS,
|
||||
DEFAULT_BACKTEST_CADENCE,
|
||||
PRODUCTION_GTL_TARGET_MODEL,
|
||||
run_and_store as run_backtest_and_store,
|
||||
validate_backtest_cadence,
|
||||
validate_backtest_target_model,
|
||||
)
|
||||
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}
|
||||
_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:
|
||||
"""Select the model for the next manual backtest run only.
|
||||
def queue_backtest_options(
|
||||
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
|
||||
selected = validate_backtest_target_model(
|
||||
global _next_backtest_target_model, _next_backtest_cadence
|
||||
selected_model = validate_backtest_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
|
||||
|
||||
|
||||
def _consume_backtest_target_model() -> str:
|
||||
global _next_backtest_target_model
|
||||
selected = _next_backtest_target_model
|
||||
_next_backtest_target_model = PRODUCTION_GTL_TARGET_MODEL
|
||||
"""Compatibility wrapper consuming all queued one-run options."""
|
||||
selected, _ = _consume_backtest_options()
|
||||
return selected
|
||||
|
||||
|
||||
@@ -1028,12 +1052,13 @@ async def compute_regime_monitor() -> None:
|
||||
async def run_backtest_job() -> None:
|
||||
"""Replay the price-derived engine over history and cache the report."""
|
||||
job_name = "backtest"
|
||||
target_model = _consume_backtest_target_model()
|
||||
target_model, cadence = _consume_backtest_options()
|
||||
_log_event(
|
||||
logging.INFO,
|
||||
"job_start",
|
||||
job=job_name,
|
||||
target_model=target_model,
|
||||
cadence=cadence,
|
||||
)
|
||||
_runtime_start(job_name)
|
||||
|
||||
@@ -1051,6 +1076,7 @@ async def run_backtest_job() -> None:
|
||||
db,
|
||||
_on_progress,
|
||||
target_model=target_model,
|
||||
cadence=cadence,
|
||||
)
|
||||
|
||||
_runtime_finish(
|
||||
@@ -1058,6 +1084,7 @@ async def run_backtest_job() -> None:
|
||||
processed=report.get("tickers", 0), total=report.get("tickers", 0),
|
||||
message=(
|
||||
f"{BACKTEST_TARGET_MODELS[target_model]}: "
|
||||
f"{cadence} cadence, "
|
||||
f"{report.get('candidates', 0)} setups, "
|
||||
f"{report.get('qualified', 0)} qualified"
|
||||
),
|
||||
|
||||
@@ -46,6 +46,7 @@ class JobToggle(BaseModel):
|
||||
class JobTriggerRequest(BaseModel):
|
||||
"""Optional parameters for a one-time manual job run."""
|
||||
target_model: Literal["production_gtl", "structural_sr"] | None = None
|
||||
cadence: Literal["weekly", "daily"] | None = None
|
||||
|
||||
|
||||
class RecommendationConfigUpdate(BaseModel):
|
||||
|
||||
@@ -59,5 +59,6 @@ class TradeSetupResponse(BaseModel):
|
||||
momentum_percentile: float | None = None
|
||||
strategy_rank: float | None = None
|
||||
volatility_percentile: float | None = None
|
||||
reentry_gate_reset_required: bool = False
|
||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||
|
||||
@@ -607,6 +607,7 @@ async def trigger_job(
|
||||
job_name: str,
|
||||
*,
|
||||
target_model: str | None = None,
|
||||
cadence: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""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))}")
|
||||
if target_model is not None and job_name != "backtest":
|
||||
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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
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
|
||||
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"}
|
||||
if target_model is not None:
|
||||
result["target_model"] = target_model
|
||||
if cadence is not None:
|
||||
result["cadence"] = cadence
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||
db,
|
||||
live_recommendation=True,
|
||||
exclude_open_trade_tickers=True,
|
||||
exclude_reentry_gate_locked_tickers=True,
|
||||
)
|
||||
config = await get_activation_config(db)
|
||||
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
|
||||
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
|
||||
after D to record the realized outcome. The report contains:
|
||||
|
||||
@@ -99,7 +99,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
HORIZON = 30 # trading days to resolve an outcome (matches the evaluator)
|
||||
ATR_MULTIPLIER = 1.5
|
||||
@@ -156,6 +166,30 @@ def validate_backtest_target_model(value: str) -> str:
|
||||
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)
|
||||
#
|
||||
@@ -455,14 +489,17 @@ def _replay_ticker(
|
||||
activation: dict,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> 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] = []
|
||||
n = len(records)
|
||||
if n < MIN_LOOKBACK + HORIZON:
|
||||
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]
|
||||
forward = records[i + 1 :]
|
||||
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,
|
||||
"date": records[i].date.isoformat(),
|
||||
"iso_week": (iso[0], iso[1]),
|
||||
"ranking_period": _ranking_period(records[i].date, cadence),
|
||||
"direction": s["direction"],
|
||||
"entry": s["entry"],
|
||||
"stop": s["stop"],
|
||||
@@ -967,6 +1005,7 @@ def _replay_and_signals(
|
||||
activation: dict,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""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),
|
||||
@@ -986,11 +1025,117 @@ def _replay_and_signals(
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
),
|
||||
_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:
|
||||
"""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."""
|
||||
@@ -1057,14 +1202,17 @@ def _assign_signal_percentiles(
|
||||
value_key: str,
|
||||
percentile_key: str,
|
||||
) -> 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
|
||||
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:
|
||||
if c.get(value_key) is not None:
|
||||
by_week[c["iso_week"]].append(c)
|
||||
for group in by_week.values():
|
||||
# Hand-built/research candidates predating the cadence flag retain
|
||||
# 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])
|
||||
n = len(ordered)
|
||||
for rank, c in enumerate(ordered):
|
||||
@@ -1074,9 +1222,9 @@ def _assign_signal_percentiles(
|
||||
|
||||
|
||||
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
|
||||
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``."""
|
||||
_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:
|
||||
"""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)
|
||||
for c in candidates:
|
||||
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(
|
||||
candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
@@ -1293,9 +1507,18 @@ def _simulate_portfolio(
|
||||
max_positions: int = SIM_MAX_POSITIONS,
|
||||
risk_per_trade: float = SIM_RISK_PER_TRADE,
|
||||
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,
|
||||
end_date: date | None = None,
|
||||
include_curve: bool = False,
|
||||
include_trades: bool = False,
|
||||
) -> dict | None:
|
||||
"""Replay the qualified setups as ONE capital-constrained book and report
|
||||
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
|
||||
whichever comes first. Stops fill at the worse of stop or open (gaps
|
||||
modeled); positions still open at the end are closed at their last mark.
|
||||
Returns None when there is nothing to trade.
|
||||
``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:
|
||||
def _default_qualified(c: dict) -> bool:
|
||||
return bool(c.get("qualified"))
|
||||
@@ -1362,6 +1596,14 @@ def _simulate_portfolio(
|
||||
curve: list[tuple[int, float]] = []
|
||||
trades: list[dict] = []
|
||||
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] = {}
|
||||
atr_cache: dict[tuple[str, int], float | None] = {}
|
||||
|
||||
@@ -1426,24 +1668,37 @@ def _simulate_portfolio(
|
||||
atr_cache[key] = None
|
||||
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
|
||||
pos = positions.pop(sym)
|
||||
proceeds = pos["shares"] * fill
|
||||
cost = proceeds * COST_PER_SIDE
|
||||
cost = proceeds * cost_rate
|
||||
cash += proceeds - cost
|
||||
risk = pos["entry"] - pos["initial_stop"]
|
||||
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"],
|
||||
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
|
||||
"hold": pos["bars_held"],
|
||||
"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:
|
||||
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)
|
||||
for sym in list(positions):
|
||||
pos = positions[sym]
|
||||
@@ -1460,7 +1715,42 @@ def _simulate_portfolio(
|
||||
if pos["stop"] > pos["initial_stop"] + 1e-9
|
||||
else "stop"
|
||||
)
|
||||
_close_trade(sym, min(pos["stop"], bar.open), reason)
|
||||
survived_refresh = False
|
||||
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"]:
|
||||
_close_trade(sym, pos["target"], "target")
|
||||
@@ -1493,8 +1783,31 @@ def _simulate_portfolio(
|
||||
|
||||
# 2) entries at today's close, best momentum first
|
||||
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(
|
||||
entries_by_ord.get(o, ()),
|
||||
fixed_todays + reentry_todays,
|
||||
key=lambda c: c.get(ranking_key) or 0.0,
|
||||
reverse=True,
|
||||
)
|
||||
@@ -1502,6 +1815,9 @@ def _simulate_portfolio(
|
||||
sym = c["symbol"]
|
||||
if sym in positions:
|
||||
continue
|
||||
if calendar_index < cooldown_until_index.get(sym, -1):
|
||||
skipped_cooldown += 1
|
||||
continue
|
||||
if len(positions) >= max_positions:
|
||||
skipped_full += 1
|
||||
continue
|
||||
@@ -1512,15 +1828,29 @@ def _simulate_portfolio(
|
||||
shares = min(
|
||||
(equity * risk_per_trade) / risk_ps,
|
||||
(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
|
||||
continue
|
||||
entry_cost = shares * entry * COST_PER_SIDE
|
||||
entry_cost = shares * entry * cost_rate
|
||||
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] = {
|
||||
"shares": shares,
|
||||
"entry": entry,
|
||||
"entry_ord": o,
|
||||
"initial_stop": stop,
|
||||
"stop": stop,
|
||||
"target": float(c["target"]) if c.get("target") else None,
|
||||
@@ -1528,6 +1858,12 @@ def _simulate_portfolio(
|
||||
"bars_held": 0,
|
||||
"last_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()
|
||||
|
||||
@@ -1633,6 +1969,7 @@ def _simulate_portfolio(
|
||||
|
||||
result = {
|
||||
"starting_capital": SIM_STARTING_CAPITAL,
|
||||
"cost_per_side_pct": round(cost_rate * 100.0, 3),
|
||||
"final_equity": round(final_equity, 2),
|
||||
"total_return_pct": round(total_return_pct, 1),
|
||||
"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
|
||||
if benchmark_payload is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -2018,19 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
||||
"exit_policy": "hold",
|
||||
},
|
||||
{
|
||||
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail",
|
||||
"strategy": "production_live_immediate",
|
||||
"label": "Live setup + 3x ATR trail (immediate re-entry)",
|
||||
"description": (
|
||||
"The live strategy: production activation gate and Admin exit policy "
|
||||
"as currently configured, 80/20 residual/high-vol rank."
|
||||
"Exact live activation, ordering, and Admin exit policy, with only "
|
||||
"the post-stop gate reset disabled as the comparison baseline."
|
||||
),
|
||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||
"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 live qualification flag (runtime Admin activation settings) and the
|
||||
# live Admin exit policy, instead of the frozen research-variant gate.
|
||||
"use_live_config": True,
|
||||
"is_production": True,
|
||||
"comparison_arm": "live_gate_reset",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2133,6 +2522,7 @@ def _min_rr_sweep(
|
||||
threshold: float,
|
||||
hold_days: int,
|
||||
live_exit_policy: dict | None = None,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> dict:
|
||||
"""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"])
|
||||
row_hold_days = hold_days
|
||||
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:
|
||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||
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"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
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,
|
||||
)
|
||||
if sim is None:
|
||||
@@ -2212,6 +2616,7 @@ def _min_rr_sweep(
|
||||
"live_qualified_setups": live_qualified,
|
||||
"reproduces_production_gate": reproduces,
|
||||
"exit_policy": exit_policy,
|
||||
"reentry_policy": reentry_policy,
|
||||
"entries_from": sweep_start.isoformat() if sweep_start else None,
|
||||
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
|
||||
"rows": rows,
|
||||
@@ -2245,6 +2650,7 @@ def _holdout_evaluation(
|
||||
hold_days: int,
|
||||
split: date,
|
||||
live_exit_policy: dict | None = None,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> dict:
|
||||
"""The production strategy simulated on entries BEFORE the split (train) and
|
||||
on entries ON/AFTER it (test), as separate books.
|
||||
@@ -2267,6 +2673,7 @@ def _holdout_evaluation(
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
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:
|
||||
exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
|
||||
@@ -2279,6 +2686,20 @@ def _holdout_evaluation(
|
||||
None if strategy.get("use_live_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] = []
|
||||
for window, start, end in (
|
||||
@@ -2292,10 +2713,11 @@ def _holdout_evaluation(
|
||||
exit_policy,
|
||||
row_hold_days,
|
||||
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"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
include_curve=True,
|
||||
@@ -2307,6 +2729,7 @@ def _holdout_evaluation(
|
||||
return {
|
||||
"split_date": split.isoformat(),
|
||||
"strategy": strategy["strategy"],
|
||||
"reentry_policy": reentry_policy,
|
||||
"rows": rows,
|
||||
"note": (
|
||||
"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,
|
||||
hold_days: int,
|
||||
live_exit_policy: dict | None = None,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> dict:
|
||||
latest_ord = max((max(cols[0]) for cols in prices.values() if cols[0]), default=None)
|
||||
rows: list[dict] = []
|
||||
@@ -2339,6 +2763,7 @@ def _portfolio_monitor(
|
||||
# policy. The overlay opts into this deliberately so only ordering
|
||||
# changes relative to the production row.
|
||||
use_live = bool(strategy.get("use_live_config"))
|
||||
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
@@ -2354,6 +2779,17 @@ def _portfolio_monitor(
|
||||
None if use_live
|
||||
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:
|
||||
start = _lookback_start(latest_ord, lookback["days"])
|
||||
sim = _simulate_portfolio(
|
||||
@@ -2367,6 +2803,7 @@ def _portfolio_monitor(
|
||||
max_positions=int(entry_cfg["max_positions"]),
|
||||
risk_per_trade=float(entry_cfg["risk_per_trade"]),
|
||||
atr_trail_multiplier=trail_multiplier,
|
||||
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||
start_date=start,
|
||||
include_curve=True,
|
||||
)
|
||||
@@ -2377,10 +2814,12 @@ def _portfolio_monitor(
|
||||
"label": strategy["label"],
|
||||
"description": strategy["description"],
|
||||
"is_production": bool(strategy.get("is_production")),
|
||||
"comparison_arm": strategy.get("comparison_arm"),
|
||||
"entry_variant": strategy["entry_variant"],
|
||||
"ranking_key": ranking_key,
|
||||
"exit_policy": exit_policy,
|
||||
"live_exit_mode": live_exit_mode,
|
||||
"reentry_policy": reentry_policy,
|
||||
"lookback": lookback["lookback"],
|
||||
"lookback_label": lookback["label"],
|
||||
**sim,
|
||||
@@ -2393,6 +2832,8 @@ def _portfolio_monitor(
|
||||
"label": s["label"],
|
||||
"description": s["description"],
|
||||
"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
|
||||
],
|
||||
@@ -2405,7 +2846,48 @@ def _portfolio_monitor(
|
||||
"Portfolio monitor runs supported named strategies across cached lookbacks. "
|
||||
"The structural overlay appears only in its explicit research arm and changes "
|
||||
"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:
|
||||
headline = (
|
||||
"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 (
|
||||
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,
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> dict:
|
||||
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
|
||||
target_model = validate_backtest_target_model(target_model)
|
||||
cadence = validate_backtest_cadence(cadence)
|
||||
config = await get_recommendation_config(db)
|
||||
activation = await get_activation_config(db)
|
||||
|
||||
@@ -2798,7 +3283,9 @@ async def run_backtest(
|
||||
total = len(tickers)
|
||||
|
||||
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))
|
||||
|
||||
# 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,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
))
|
||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||
if isinstance(result, Exception):
|
||||
@@ -2877,6 +3365,7 @@ async def run_backtest(
|
||||
_replay_and_signals, ticker.symbol, columns, config, activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||
@@ -2966,17 +3455,19 @@ async def run_backtest(
|
||||
portfolio_monitor_report = _portfolio_monitor(
|
||||
candidates, price_columns, spy_closes, hold_horizon,
|
||||
live_exit_policy=live_exit_policy,
|
||||
cadence=cadence,
|
||||
)
|
||||
split = _holdout_split()
|
||||
if split is not None:
|
||||
holdout_report = _holdout_evaluation(
|
||||
candidates, price_columns, spy_closes, hold_horizon, split,
|
||||
live_exit_policy=live_exit_policy,
|
||||
cadence=cadence,
|
||||
)
|
||||
if _min_rr_sweep_enabled():
|
||||
min_rr_sweep_report = _min_rr_sweep(
|
||||
candidates, price_columns, spy_closes, activation, current_min_pct,
|
||||
hold_horizon, live_exit_policy=live_exit_policy,
|
||||
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Portfolio simulation failed")
|
||||
@@ -2987,13 +3478,19 @@ async def run_backtest(
|
||||
"candidates": len(candidates),
|
||||
"qualified": len(qualified),
|
||||
"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,
|
||||
"min_lookback": MIN_LOOKBACK,
|
||||
"cost_per_side_pct": round(COST_PER_SIDE * 100, 3),
|
||||
"target_model": target_model,
|
||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
@@ -3055,6 +3552,11 @@ async def run_backtest(
|
||||
),
|
||||
},
|
||||
"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,
|
||||
"min_rr_sweep": min_rr_sweep_report,
|
||||
"target_model_diagnostics": _target_model_diagnostics(
|
||||
@@ -3090,9 +3592,15 @@ async def run_and_store(
|
||||
progress_cb: Callable[[int, int, str], None] | None = None,
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
) -> dict:
|
||||
"""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))
|
||||
return report
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.services.outcome_service import (
|
||||
Bar,
|
||||
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
|
||||
# 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")
|
||||
|
||||
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(
|
||||
user_id=user_id,
|
||||
ticker_id=ticker.id,
|
||||
|
||||
@@ -28,7 +28,12 @@ from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||
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.trade_policy import (
|
||||
get_reentry_gate_locks,
|
||||
observe_reentry_gate_transitions,
|
||||
)
|
||||
from app.services.recommendation_service import (
|
||||
_risk_level_from_conflicts,
|
||||
build_recommendation_snapshot,
|
||||
@@ -699,12 +704,24 @@ async def scan_all_tickers(
|
||||
``progress_callback(processed, total, current_symbol)`` is invoked as each
|
||||
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
|
||||
"""
|
||||
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
|
||||
# objects held across them, and touching an expired attribute afterwards
|
||||
# Plain ids/strings, not Ticker instances: the rollbacks below expire any
|
||||
# ORM objects held across them, and touching an expired attribute afterwards
|
||||
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
symbols = list(result.scalars().all())
|
||||
total = len(symbols)
|
||||
result = await db.execute(select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol))
|
||||
ticker_rows = [(int(ticker_id), symbol) for ticker_id, symbol in result.all()]
|
||||
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
|
||||
# activation gate percentile and the promoted production ordering score.
|
||||
@@ -720,7 +737,10 @@ async def scan_all_tickers(
|
||||
ranks = {}
|
||||
|
||||
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:
|
||||
progress_callback(index, total, symbol)
|
||||
# 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,
|
||||
)
|
||||
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:
|
||||
await db.rollback()
|
||||
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:
|
||||
progress_callback(total, total, "")
|
||||
|
||||
@@ -771,6 +814,8 @@ async def get_trade_setups(
|
||||
symbol: str | None = None,
|
||||
live_recommendation: bool = False,
|
||||
exclude_open_trade_tickers: bool = False,
|
||||
exclude_reentry_gate_locked_tickers: bool = False,
|
||||
include_reentry_gate_lock: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get latest stored trade setups, optionally filtered.
|
||||
|
||||
@@ -794,15 +839,23 @@ async def get_trade_setups(
|
||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||
if recommended_action is not None and not live_recommendation:
|
||||
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:
|
||||
open_trade_result = await db.execute(
|
||||
select(PaperTrade.ticker_id)
|
||||
.where(PaperTrade.status == "open")
|
||||
.distinct()
|
||||
)
|
||||
open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()}
|
||||
if open_ticker_ids:
|
||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids))
|
||||
excluded_ticker_ids.update(
|
||||
ticker_id for ticker_id, in open_trade_result.all()
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -855,6 +908,15 @@ async def get_trade_setups(
|
||||
),
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
cross-sectional momentum book** — buy the top quintile by beta-adjusted 12-1
|
||||
momentum, tilt toward higher volatility, hold ≤ 10 names, cut at 1.5× ATR, then
|
||||
trail at 3× ATR for up to 30 trading days. Everything else in the app (composite
|
||||
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
|
||||
**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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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';
|
||||
message: string;
|
||||
target_model?: BacktestTargetModel;
|
||||
cadence?: BacktestCadence;
|
||||
}
|
||||
|
||||
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
|
||||
export type BacktestCadence = 'weekly' | 'daily';
|
||||
|
||||
export function listJobs() {
|
||||
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);
|
||||
}
|
||||
|
||||
export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
|
||||
export function triggerJob(
|
||||
jobName: string,
|
||||
options?: { target_model?: BacktestTargetModel; cadence?: BacktestCadence },
|
||||
) {
|
||||
return apiClient
|
||||
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
|
||||
.then((r) => r.data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import type { BacktestTargetModel } from '../../api/admin';
|
||||
import type { BacktestCadence, BacktestTargetModel } from '../../api/admin';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
@@ -145,6 +145,7 @@ export function BacktestPanel() {
|
||||
const [selectedStrategy, setSelectedStrategy] = useState('');
|
||||
const [selectedLookback, setSelectedLookback] = useState('');
|
||||
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
|
||||
const [cadence, setCadence] = useState<BacktestCadence>('weekly');
|
||||
|
||||
const monitor = report?.portfolio_monitor ?? null;
|
||||
const activeStrategy =
|
||||
@@ -161,11 +162,11 @@ export function BacktestPanel() {
|
||||
);
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
|
||||
mutationFn: () => triggerJob('backtest', { target_model: targetModel, cadence }),
|
||||
onSuccess: (res) => {
|
||||
if (res.status === 'triggered') {
|
||||
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);
|
||||
} else {
|
||||
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">
|
||||
<Disclosure summary="How this is measured">
|
||||
<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
|
||||
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,
|
||||
@@ -238,6 +239,56 @@ export function BacktestPanel() {
|
||||
</span>
|
||||
</label>
|
||||
</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">
|
||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||
</Button>
|
||||
@@ -257,7 +308,8 @@ export function BacktestPanel() {
|
||||
<>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
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 && (
|
||||
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
||||
)}
|
||||
@@ -321,6 +373,9 @@ export function BacktestPanel() {
|
||||
<p className="text-[11px] text-gray-500">
|
||||
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)}
|
||||
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||
<> · Re-entry after gate failure and fresh qualification</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||
|
||||
@@ -66,14 +66,18 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
||||
return { pct, r, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* The only state with no tradeable setup left: price has gone through the stop.
|
||||
* Returns null when there's no live price.
|
||||
*/
|
||||
type NotActionableState =
|
||||
| { kind: 'gate-reset' }
|
||||
| { kind: 'invalidated' }
|
||||
| null;
|
||||
|
||||
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
||||
if (setup.reentry_gate_reset_required) {
|
||||
return { kind: 'gate-reset' } satisfies NotActionableState;
|
||||
}
|
||||
if (currentPrice == null) return null;
|
||||
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||
return { invalidated: true };
|
||||
return { kind: 'invalidated' } satisfies NotActionableState;
|
||||
}
|
||||
|
||||
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 honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||
|
||||
// Only price through the stop leaves no tradeable setup.
|
||||
const notActionable = notActionableState(setup, currentPrice) != null;
|
||||
|
||||
const createTrade = useCreatePaperTrade();
|
||||
const [taking, setTaking] = useState(false);
|
||||
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();
|
||||
return (
|
||||
<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">
|
||||
{preferredInactive ? (
|
||||
<span className="text-sm font-semibold text-gray-400">
|
||||
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — 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>
|
||||
) : (() => {
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
// Live R:R from current price — drops setups whose price has already run
|
||||
// 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).
|
||||
*/
|
||||
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) {
|
||||
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;
|
||||
strategy_rank?: number | null;
|
||||
volatility_percentile?: number | null;
|
||||
reentry_gate_reset_required?: boolean;
|
||||
context_as_of?: TradeSetupContextAsOf | null;
|
||||
recommendation_summary?: RecommendationSummary;
|
||||
}
|
||||
@@ -355,15 +356,24 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
|
||||
label: string;
|
||||
description: string;
|
||||
is_production: boolean;
|
||||
comparison_arm?: 'live_immediate' | 'live_gate_reset' | null;
|
||||
entry_variant: string;
|
||||
exit_policy: string;
|
||||
reentry_policy?: 'immediate' | 'gate_reset';
|
||||
lookback: string;
|
||||
lookback_label: string;
|
||||
}
|
||||
|
||||
export interface BacktestPortfolioMonitor {
|
||||
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 }[];
|
||||
runs: BacktestPortfolioMonitorRun[];
|
||||
note?: string;
|
||||
@@ -396,12 +406,16 @@ export interface BacktestReport {
|
||||
qualified: number;
|
||||
params: {
|
||||
step_days: number;
|
||||
step_sessions?: number;
|
||||
entry_cadence?: 'weekly' | 'daily';
|
||||
signal_eval_cadence?: 'weekly';
|
||||
horizon_days: number;
|
||||
min_lookback: number;
|
||||
cost_per_side_pct?: number;
|
||||
target_model?: 'production_gtl' | 'structural_sr';
|
||||
target_model_label?: string;
|
||||
is_production_target_model?: boolean;
|
||||
production_reentry_policy?: 'gate_reset';
|
||||
};
|
||||
overall_qualified: 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
|
||||
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(
|
||||
"--out",
|
||||
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(
|
||||
"--workers",
|
||||
@@ -55,6 +58,15 @@ def _parse_args() -> argparse.Namespace:
|
||||
"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(
|
||||
"--holdout-split",
|
||||
default=None,
|
||||
@@ -63,9 +75,9 @@ def _parse_args() -> argparse.Namespace:
|
||||
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")
|
||||
return Path("reports") / f"backtest-{stamp}.json"
|
||||
return Path("reports") / f"backtest-{cadence}-{stamp}.json"
|
||||
|
||||
|
||||
def _pct(value: Any) -> str:
|
||||
@@ -93,6 +105,7 @@ def _print_summary(report: dict) -> None:
|
||||
|
||||
print("")
|
||||
print("Backtest summary")
|
||||
print(f" entry cadence: {(report.get('params') or {}).get('entry_cadence', 'weekly')}")
|
||||
print(f" candidates: {report.get('candidates')}")
|
||||
print(f" qualified: {report.get('qualified')}")
|
||||
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:
|
||||
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)
|
||||
|
||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||
@@ -208,6 +221,7 @@ async def _main() -> None:
|
||||
db,
|
||||
progress_cb=progress,
|
||||
target_model=args.target_model,
|
||||
cadence=args.cadence,
|
||||
)
|
||||
finally:
|
||||
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)
|
||||
assert sim is not None
|
||||
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:
|
||||
# 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)
|
||||
@@ -556,6 +557,29 @@ class TestSimulatePortfolio:
|
||||
{"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):
|
||||
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
|
||||
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||
@@ -575,6 +599,298 @@ class TestSimulatePortfolio:
|
||||
assert sim["trades"] == 1
|
||||
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):
|
||||
closes = [100.0] * 56 + [90.0, 91.0]
|
||||
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 "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"])
|
||||
|
||||
|
||||
@@ -736,6 +1053,15 @@ def test_backtest_target_model_is_small_and_validated():
|
||||
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():
|
||||
return [
|
||||
SimpleNamespace(
|
||||
@@ -829,6 +1155,110 @@ def test_replay_ticker_candidates_carry_gate_fields():
|
||||
assert c.get("action") is not None
|
||||
assert "risk_level" in c
|
||||
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:
|
||||
@@ -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"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
|
||||
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"]
|
||||
|
||||
# ablation baseline reproduces the qualified set exactly, and every row
|
||||
# carries the hold-to-horizon grading alongside the target model
|
||||
ablation = {r["variant"]: r for r in report["gate_ablation"]}
|
||||
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"]:
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
await _seed(session, "AAA", close=112.0)
|
||||
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"]
|
||||
|
||||
|
||||
@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:
|
||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||
(bullish sentiment, composite 96) that yields live confidence 97.
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import pytest
|
||||
|
||||
from app.scheduler import (
|
||||
_consume_backtest_options,
|
||||
_consume_backtest_target_model,
|
||||
_parse_frequency,
|
||||
_resume_tickers,
|
||||
_last_successful,
|
||||
configure_scheduler,
|
||||
queue_backtest_options,
|
||||
queue_backtest_target_model,
|
||||
scheduler,
|
||||
)
|
||||
@@ -24,6 +26,15 @@ def test_manual_backtest_target_model_rejects_removed_research_arms():
|
||||
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:
|
||||
def test_hourly(self):
|
||||
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