Compare commits

...
15 Commits
Author SHA1 Message Date
dennisthiessen c9c6967c9c chore: consolidate post-stop research artifacts
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m18s
Deploy / deploy (push) Successful in 39s
2026-07-17 20:46:21 +02:00
dennisthiessen d13c54e3c7 fix: grandfather pre-cutover stop episodes 2026-07-17 20:28:42 +02:00
dennisthiessen d858475ddb docs: document post-stop gate reset results 2026-07-17 19:58:40 +02:00
dennisthiessen 5155d00d9e feat: require gate reset before post-stop reentry 2026-07-17 19:30:40 +02:00
Dennis Thiessen 1a6f82bf6d done 2026-07-17 17:42:13 +02:00
dennisthiessen 5385f46064 feat: test shorter post-stop reentry guards 2026-07-17 17:37:03 +02:00
Dennis Thiessen cf294a7d2b done 2026-07-17 17:23:27 +02:00
dennisthiessen bbc7383d3a feat: compare legacy and live ranking universes 2026-07-17 17:07:35 +02:00
dennisthiessen 9800114fc4 fix: align daily matrix ranking universe 2026-07-17 16:39:43 +02:00
Dennis Thiessen 27bc8a6631 done 2026-07-17 16:30:13 +02:00
dennisthiessen 0cd9ee7689 feat: add daily reentry policy matrix 2026-07-17 16:11:18 +02:00
Dennis Thiessen 13f57b2525 done 2026-07-17 15:22:58 +02:00
dennisthiessen 65a462271c feat: add selectable daily backtest cadence 2026-07-17 14:41:24 +02:00
dennisthiessen bc50ba9136 fix: harden post-stop reentry lockdown 2026-07-17 14:17:57 +02:00
dennisthiessen 1e9f2dc4fb feat: add five-session post-stop reentry lockdown 2026-07-17 13:21:06 +02:00
35 changed files with 178776 additions and 84 deletions
+3
View File
@@ -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
+49 -9
View File
@@ -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 121 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 121 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 25 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")
+10
View File
@@ -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
)
+1
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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"
),
+1
View File
@@ -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):
+1
View File
@@ -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
+7 -2
View File
@@ -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
+1
View File
@@ -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)]
+539 -31
View File
@@ -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,8 +1715,43 @@ def _simulate_portfolio(
if pos["stop"] > pos["initial_stop"] + 1e-9
else "stop"
)
_close_trade(sym, min(pos["stop"], bar.open), reason)
continue
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")
continue
@@ -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
+5
View File
@@ -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,
+71 -9
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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.
+196
View File
@@ -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.10.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.
+6 -1
View File
@@ -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&amp;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 ?? '';
+4
View File
@@ -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)}`;
}
+15 -1
View File
@@ -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;
+16
View File
@@ -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
+180
View File
@@ -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())
+18 -4
View File
@@ -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()
+861
View File
@@ -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())
+438
View File
@@ -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
+200
View File
@@ -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)
+100
View File
@@ -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)
+68
View File
@@ -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.
+11
View File
@@ -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}
+159
View File
@@ -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