diff --git a/README.md b/README.md index 2666739..1c3164c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,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 (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** — recompute S/R zones, the 5-dimension scores and long/short setups (ATR stops, S/R targets) for every ticker, and attach each ticker's 12‑1 momentum percentile. -4. **Outcome Eval** — resolve setups that hit target/stop or expired (default 30 trading days) and close paper trades that hit a level. +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: hold 30 trading days with the initial stop — the backtest-validated exit). 5. **Market Regime** — recompute the regime index (breadth/trend). 6. **Regime Monitor** — observational early-warning snapshot (VIX, credit spreads via FRED); feeds nothing else. @@ -33,7 +33,7 @@ Fundamentals (weekly, early Monday) · Alerts (hourly, Telegram) · Backtest (we 1. **Composite score** — technical, S/R-quality, sentiment, fundamental and momentum sub-scores (0–100) combine into a weighted composite (weights configurable; missing dimensions re-normalize). 2. **Setups** — the scanner builds long/short setups with ATR stops and S/R targets, then adds a confidence score, conflict flags and a target reach-probability. -3. **Activation gate** — a setup *qualifies* only if it clears the R:R and confidence floors **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum). +3. **Activation gate** — a setup *qualifies* only if it clears the R:R floor **and** ranks in the top momentum percentile of the universe (the validated edge is long-only momentum; the confidence floor was ablated to zero effect and defaults off). 4. **Top pick** — the highest-momentum qualified setup; highlighted on the Dashboard and labelled on the ticker page. ## Strategy Status — What's Validated and What Isn't @@ -102,9 +102,9 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m - Fundamental data tracking (P/E, revenue growth, earnings surprise, market cap) - 5-dimension scoring engine (technical, S/R quality, sentiment, fundamental, momentum) with configurable weights - Risk:Reward scanner — long and short setups, ATR-based stops, S/R-based targets, configurable R:R threshold (default 1.5:1) -- Activation gate — qualifies setups on a momentum-percentile floor plus R:R/confidence (validated long-only edge); ranks the rest by expected value +- Activation gate — qualifies setups on a momentum-percentile floor plus an R:R floor (validated long-only edge) - Recommendation layer — directional confidence, conflict detection, per-target reach-probability -- Paper trading — take a setup, mark-to-market vs. latest close, auto-close on stop/target, realized track record + outcome evaluation +- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: hold 30 trading days with the initial stop; trailing / target-stop selectable), realized track record + outcome evaluation - Market-regime index + FRED early-warning monitor (VIX, credit spreads); weekly backtest + manual event study - Telegram alerts (e.g. regime-quadrant changes) - User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary diff --git a/alembic/versions/015_phase3_exit_and_gate.py b/alembic/versions/015_phase3_exit_and_gate.py new file mode 100644 index 0000000..af5da28 --- /dev/null +++ b/alembic/versions/015_phase3_exit_and_gate.py @@ -0,0 +1,50 @@ +"""Phase 3 strategy adoption: time-based exit + confidence floor removed. + +The July 2026 backtest (gate ablation graded under both exit models, plus the +capital-constrained portfolio simulation) concluded: + +- The best exit is hold-to-horizon: keep the initial ATR stop and exit at the + 30th trading day's close (+0.50R net/trade vs +0.13R for the S/R target + exit; simulated book +31.9% vs +23.7% CAGR at the same drawdown). The paper + trade exit-policy default is now ``time`` (30 trading days). +- The confidence floor adds nothing (identical net/trade with it removed, + under both exit models) while cutting ~25% of qualified trades. Its default + is now 0 (off). + +Stored rows for these two settings were written under the old semantics, so +they are cleared here and the new code defaults take effect. Re-tune in +Admin -> Activation / Paper-Trade Exit if desired. Note: this changes which +setups qualify and how paper trades close, so Track Record comparability +resets from this deploy. + +Revision ID: 015 +Revises: 014 +Create Date: 2026-07-02 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "015" +down_revision: Union[str, None] = "014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + sa.text( + "DELETE FROM system_settings " + "WHERE key IN ('activation_min_confidence', 'paper_exit_mode')" + ) + ) + + +def downgrade() -> None: + # One-way data reset: the old per-key values aren't recoverable. Code + # defaults apply until re-tuned, so there is nothing to restore. + pass diff --git a/app/routers/paper_trades.py b/app/routers/paper_trades.py index 869ff85..c84ec30 100644 --- a/app/routers/paper_trades.py +++ b/app/routers/paper_trades.py @@ -61,7 +61,9 @@ async def write_exit_policy( db: AsyncSession = Depends(get_db), ) -> APIEnvelope: """Change the auto-exit policy (admin).""" - data = await paper_trade_service.set_exit_policy(db, mode=body.mode, trailing_pct=body.trailing_pct) + data = await paper_trade_service.set_exit_policy( + db, mode=body.mode, trailing_pct=body.trailing_pct, hold_days=body.hold_days + ) return APIEnvelope(status="success", data=data) diff --git a/app/schemas/paper_trade.py b/app/schemas/paper_trade.py index 9b960d6..02ba810 100644 --- a/app/schemas/paper_trade.py +++ b/app/schemas/paper_trade.py @@ -22,8 +22,9 @@ class PaperTradeClose(BaseModel): class ExitPolicyUpdate(BaseModel): """Auto-exit policy for open paper trades.""" - mode: str | None = Field(default=None, pattern=r"^(trailing|target)$") + mode: str | None = Field(default=None, pattern=r"^(time|trailing|target)$") trailing_pct: float | None = Field(default=None, ge=0.5, le=90) + hold_days: int | None = Field(default=None, ge=2, le=250) class PaperTradeResponse(BaseModel): diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 49e06b2..700249b 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -39,10 +39,9 @@ SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"} # Track Record's qualified stats. The outcome evaluator deliberately ignores # these — every setup is evaluated so the gate itself can be validated. # -# The core test is expected value (in R): probability-weighted asymmetry, so a -# fat-but-improbable target and a likely-but-thin one are both rejected. R:R and -# confidence are floors; high-conviction / clean-read / target-probability are -# optional tighteners (off by default — turn on to be more selective). +# The core selection is cross-sectional 12-1 momentum (top percentile of the +# universe, long-only). R:R and confidence are floors; high-conviction / +# clean-read are optional tighteners (off by default). _ACTIVATION_FLOAT_KEYS: dict[str, str] = { "min_momentum_percentile": "activation_min_momentum_percentile", "min_rr": "activation_min_rr", @@ -56,7 +55,10 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = { ACTIVATION_DEFAULTS: dict[str, float | bool] = { "min_momentum_percentile": 80.0, "min_rr": 1.2, - "min_confidence": 55.0, + # 0 = off. The July 2026 gate ablation showed the confidence floor added + # nothing (identical net/trade with it removed, under both exit models) + # while cutting ~25% of qualified trades. + "min_confidence": 0.0, "require_high_conviction": False, "exclude_conflicts": False, # On by default: a NEUTRAL ("no clear setup") recommendation isn't an diff --git a/app/services/paper_trade_service.py b/app/services/paper_trade_service.py index 64485f3..46f6d0c 100644 --- a/app/services/paper_trade_service.py +++ b/app/services/paper_trade_service.py @@ -20,19 +20,26 @@ from app.services.outcome_service import ( evaluate_setup_against_bars, ) -# Exit policy for OPEN paper trades (auto-close). "trailing" rides a trailing stop -# (validated as the best exit in the backtest); "target" closes at the setup's -# stop/target. Stored in SystemSetting so it's tunable + transparent in the UI. +# Exit policy for OPEN paper trades (auto-close). "time" holds a fixed number of +# trading days with the initial stop and exits at that day's close — the exit the +# July 2026 backtest validated (the classic momentum hold-and-re-rank); "trailing" +# rides a trailing stop; "target" closes at the setup's stop/target. Stored in +# SystemSetting so it's tunable + transparent in the UI. KEY_EXIT_MODE = "paper_exit_mode" KEY_TRAILING_PCT = "paper_trailing_pct" -DEFAULT_EXIT_MODE = "trailing" +KEY_HOLD_DAYS = "paper_hold_days" +DEFAULT_EXIT_MODE = "time" DEFAULT_TRAILING_PCT = 12.0 +DEFAULT_HOLD_DAYS = 30 + +_VALID_EXIT_MODES = ("time", "trailing", "target") async def get_exit_policy(db: AsyncSession) -> dict: - """Active auto-exit policy: {'mode': 'trailing'|'target', 'trailing_pct': float}.""" + """Active auto-exit policy: + {'mode': 'time'|'trailing'|'target', 'trailing_pct': float, 'hold_days': int}.""" mode = (await settings_store.get_value(db, KEY_EXIT_MODE, DEFAULT_EXIT_MODE)).strip().lower() - if mode not in ("trailing", "target"): + if mode not in _VALID_EXIT_MODES: mode = DEFAULT_EXIT_MODE raw = await settings_store.get_value(db, KEY_TRAILING_PCT, str(DEFAULT_TRAILING_PCT)) try: @@ -40,22 +47,36 @@ async def get_exit_policy(db: AsyncSession) -> dict: except (TypeError, ValueError): pct = DEFAULT_TRAILING_PCT pct = max(0.5, min(90.0, pct)) - return {"mode": mode, "trailing_pct": pct} + raw_days = await settings_store.get_value(db, KEY_HOLD_DAYS, str(DEFAULT_HOLD_DAYS)) + try: + hold_days = int(float(raw_days)) + except (TypeError, ValueError): + hold_days = DEFAULT_HOLD_DAYS + hold_days = max(2, min(250, hold_days)) + return {"mode": mode, "trailing_pct": pct, "hold_days": hold_days} async def set_exit_policy( - db: AsyncSession, *, mode: str | None = None, trailing_pct: float | None = None + db: AsyncSession, + *, + mode: str | None = None, + trailing_pct: float | None = None, + hold_days: int | None = None, ) -> dict: """Persist the auto-exit policy (admin). Validates inputs.""" if mode is not None: mode = mode.strip().lower() - if mode not in ("trailing", "target"): - raise ValidationError("mode must be 'trailing' or 'target'") + if mode not in _VALID_EXIT_MODES: + raise ValidationError("mode must be 'time', 'trailing' or 'target'") await settings_store.upsert_setting(db, KEY_EXIT_MODE, mode) if trailing_pct is not None: if not 0.5 <= float(trailing_pct) <= 90.0: raise ValidationError("trailing_pct must be between 0.5 and 90") await settings_store.upsert_setting(db, KEY_TRAILING_PCT, str(float(trailing_pct))) + if hold_days is not None: + if not 2 <= int(hold_days) <= 250: + raise ValidationError("hold_days must be between 2 and 250") + await settings_store.upsert_setting(db, KEY_HOLD_DAYS, str(int(hold_days))) await db.commit() return await get_exit_policy(db) @@ -101,6 +122,23 @@ async def _max_high_after(db: AsyncSession, ticker_id: int, since: date) -> floa return float(v) if v is not None else None +def _time_close( + direction: str, init_stop: float, hold_days: int, rows: list[tuple] +) -> tuple[float, date, str] | None: + """Walk post-entry ``rows`` of (date, open, high, low, close); close at the + initial stop if hit (a gap through it fills at the open, matching the + backtest's fill model), else at the ``hold_days``-th bar's close ('time'). + None while neither has happened.""" + long = direction == "long" + for i, (d, open_, high, low, close) in enumerate(rows): + if (low <= init_stop) if long else (high >= init_stop): + fill = min(init_stop, open_) if long else max(init_stop, open_) + return float(fill), d, "stop" + if i + 1 >= hold_days: + return float(close), d, "time" + return None + + def _trailing_close( direction: str, entry: float, init_stop: float, trail_frac: float, bars: list[Bar] ) -> tuple[float, date, str] | None: @@ -297,12 +335,12 @@ async def close_trade( async def resolve_open_trades(db: AsyncSession) -> int: - """Auto-close open trades whose stop or target was hit in the daily bars. + """Auto-close open trades per the active exit policy, from the daily bars. - Walks the bars after each trade's open (same logic as the outcome evaluator). - Target hit → close at the target; stop (or an ambiguous same-bar touch) → - close at the stop. Trades that have hit neither stay open. Returns the count - closed. + Walks the bars after each trade's open. 'time' closes at the initial stop or + the hold_days-th close; 'trailing' at the trailing/initial stop; 'target' at + the setup's target or stop (same logic as the outcome evaluator). Trades that + have hit nothing stay open. Returns the count closed. """ result = await db.execute(select(PaperTrade).where(PaperTrade.status == "open")) open_trades = list(result.scalars().all()) @@ -312,22 +350,32 @@ async def resolve_open_trades(db: AsyncSession) -> int: policy = await get_exit_policy(db) mode = policy["mode"] trail_frac = policy["trailing_pct"] / 100.0 + hold_days = policy["hold_days"] closed = 0 for trade in open_trades: bars_result = await db.execute( - select(OHLCVRecord.date, OHLCVRecord.high, OHLCVRecord.low) + select( + OHLCVRecord.date, OHLCVRecord.open, OHLCVRecord.high, + OHLCVRecord.low, OHLCVRecord.close, + ) .where( OHLCVRecord.ticker_id == trade.ticker_id, OHLCVRecord.date > trade.opened_at.date(), ) .order_by(OHLCVRecord.date.asc()) ) - bars = [Bar(date=d, high=h, low=lo) for d, h, lo in bars_result.all()] + rows = bars_result.all() + bars = [Bar(date=d, high=h, low=lo) for d, _, h, lo, _ in rows] if not bars: continue - if mode == "trailing": + if mode == "time": + hit = _time_close(trade.direction, trade.stop_loss, hold_days, rows) + if hit is None: + continue # neither the stop nor the hold horizon reached yet + close_price, close_date, reason = hit + elif mode == "trailing": hit = _trailing_close(trade.direction, trade.entry_price, trade.stop_loss, trail_frac, bars) if hit is None: continue # neither the trailing nor the initial stop reached yet diff --git a/frontend/src/components/admin/ExitPolicySettings.tsx b/frontend/src/components/admin/ExitPolicySettings.tsx index 42e4d52..93eb87e 100644 --- a/frontend/src/components/admin/ExitPolicySettings.tsx +++ b/frontend/src/components/admin/ExitPolicySettings.tsx @@ -6,13 +6,15 @@ import { SkeletonCard } from '../ui/Skeleton'; export function ExitPolicySettings() { const { data, isLoading } = useExitPolicy(); const update = useUpdateExitPolicy(); - const [mode, setMode] = useState('trailing'); + const [mode, setMode] = useState('time'); const [pct, setPct] = useState(12); + const [holdDays, setHoldDays] = useState(30); useEffect(() => { if (data) { setMode(data.mode); setPct(data.trailing_pct); + setHoldDays(data.hold_days ?? 30); } }, [data]); @@ -24,12 +26,14 @@ export function ExitPolicySettings() {

Paper-Trade Exit

How open paper trades auto-close (in the nightly/intraday outcome job).{' '} - Trailing rides a trailing stop — the backtest's best exit, - it lets winners run; Target / stop closes at the setup's - target or stop. The setup's initial stop is always the floor. + Hold keeps the initial stop and exits at the Nth trading + day's close — the backtest-validated exit (classic momentum: hold ~a month, re-rank);{' '} + Trailing rides a trailing stop;{' '} + Target / stop closes at the setup's target or stop. + The setup's initial stop is always the floor.

-
+
+
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b223089..b3170c3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -211,14 +211,15 @@ export interface PaperTrade { benchmark_return_pct: number | null; alpha_pct: number | null; alpha_usd: number | null; - close_reason: 'trailing' | 'stop' | 'target' | 'manual' | null; + close_reason: 'time' | 'trailing' | 'stop' | 'target' | 'manual' | null; trailing_stop: number | null; trailing_distance_pct: number | null; } export interface ExitPolicy { - mode: 'trailing' | 'target'; + mode: 'time' | 'trailing' | 'target'; trailing_pct: number; + hold_days: number; } export interface BacktestBucket { diff --git a/tests/unit/test_activation_settings.py b/tests/unit/test_activation_settings.py index 5f7d1da..3c45d87 100644 --- a/tests/unit/test_activation_settings.py +++ b/tests/unit/test_activation_settings.py @@ -27,7 +27,7 @@ class TestActivationConfig: assert config == { "min_momentum_percentile": 80.0, "min_rr": 1.2, - "min_confidence": 55.0, + "min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing "require_high_conviction": False, "exclude_conflicts": False, "exclude_neutral": True, diff --git a/tests/unit/test_paper_trade_service.py b/tests/unit/test_paper_trade_service.py index ba854a0..87fd90f 100644 --- a/tests/unit/test_paper_trade_service.py +++ b/tests/unit/test_paper_trade_service.py @@ -205,9 +205,12 @@ class TestTrailingClose: async def test_exit_policy_defaults_and_round_trip(session): - assert await svc.get_exit_policy(session) == {"mode": "trailing", "trailing_pct": 12.0} - updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0) - assert updated == {"mode": "target", "trailing_pct": 15.0} + # Default: the backtest-validated hold-to-horizon exit. + assert await svc.get_exit_policy(session) == { + "mode": "time", "trailing_pct": 12.0, "hold_days": 30, + } + updated = await svc.set_exit_policy(session, mode="target", trailing_pct=15.0, hold_days=21) + assert updated == {"mode": "target", "trailing_pct": 15.0, "hold_days": 21} assert (await svc.get_exit_policy(session))["mode"] == "target" @@ -216,10 +219,64 @@ async def test_exit_policy_rejects_bad_input(session): await svc.set_exit_policy(session, mode="bogus") with pytest.raises(ValidationError): await svc.set_exit_policy(session, trailing_pct=200.0) + with pytest.raises(ValidationError): + await svc.set_exit_policy(session, hold_days=1) + + +def _r(d: date, open_: float, hi: float, lo: float, close: float) -> tuple: + return (d, open_, hi, lo, close) + + +class TestTimeClose: + def test_closes_at_hold_days_close(self): + rows = [ + _r(date(2026, 1, 2), 101, 103, 100, 102), + _r(date(2026, 1, 3), 102, 104, 101, 103), + _r(date(2026, 1, 4), 103, 106, 102, 105), + ] + assert svc._time_close("long", 95.0, 3, rows) == (105.0, date(2026, 1, 4), "time") + + def test_stop_before_horizon(self): + rows = [_r(date(2026, 1, 2), 100, 101, 94, 96)] + assert svc._time_close("long", 95.0, 30, rows) == (95.0, date(2026, 1, 2), "stop") + + def test_gap_through_stop_fills_at_open(self): + rows = [_r(date(2026, 1, 2), 92, 93, 90, 91)] + assert svc._time_close("long", 95.0, 30, rows) == (92.0, date(2026, 1, 2), "stop") + + def test_none_before_horizon(self): + rows = [_r(date(2026, 1, 2), 101, 103, 100, 102)] + assert svc._time_close("long", 95.0, 5, rows) is None + + +async def test_resolve_time_mode_closes_at_horizon(session): + await svc.set_exit_policy(session, mode="time", hold_days=2) + tid = await _seed(session, "AAA", close=100.0) + trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", + entry_price=100.0, shares=10, stop_loss=95.0, target=200.0) + await _add_bars(session, tid, [(103, 101), (105, 102)], start=date.today()) + assert await svc.resolve_open_trades(session) == 1 + await session.refresh(trade) + assert trade.status == "closed" + assert trade.close_reason == "time" + assert trade.close_price == pytest.approx((105 + 102) / 2) # day-2 close (= bar mid) + + +async def test_resolve_time_mode_stop_still_governs(session): + await svc.set_exit_policy(session, mode="time", hold_days=30) + tid = await _seed(session, "AAA", close=100.0) + trade = await svc.create_trade(session, 1, symbol="AAA", direction="long", + entry_price=100.0, shares=10, stop_loss=95.0, target=200.0) + await _add_bars(session, tid, [(101, 94)], start=date.today()) # low pierces the stop + assert await svc.resolve_open_trades(session) == 1 + await session.refresh(trade) + assert trade.close_reason == "stop" + assert trade.close_price == pytest.approx(95.0) async def test_resolve_trailing_closes_with_reason(session): - tid = await _seed(session, "AAA", close=100.0) # default policy: trailing 12% + await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0) + tid = await _seed(session, "AAA", close=100.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) await _add_bars(session, tid, [(120, 110), (130, 100)], start=date.today()) # run up, pull back assert await svc.resolve_open_trades(session) == 1 @@ -236,6 +293,7 @@ async def test_manual_close_sets_reason(session): async def test_list_open_exposes_trailing_stop(session): + await svc.set_exit_policy(session, mode="trailing", trailing_pct=12.0) tid = await _seed(session, "AAA", close=120.0) await _add_open_trade(session, tid, "long", entry=100.0, shares=10, days_ago=10) await _add_bars(session, tid, [(125, 118)], start=date.today()) # peak 125