feat: add five-session post-stop reentry lockdown

This commit is contained in:
2026-07-17 13:21:06 +02:00
parent f714782fa4
commit 1e9f2dc4fb
14 changed files with 36919 additions and 14 deletions
+2
View File
@@ -36,6 +36,7 @@ async def list_trade_setups(
recommended_action=recommended_action, recommended_action=recommended_action,
live_recommendation=True, live_recommendation=True,
exclude_open_trade_tickers=True, exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True,
) )
data = [] data = []
@@ -98,6 +99,7 @@ async def get_ticker_trade_setups(
db, db,
symbol=symbol, symbol=symbol,
live_recommendation=True, live_recommendation=True,
exclude_reentry_lockdown_tickers=True,
) )
data = [] data = []
for row in rows: for row in rows:
+1
View File
@@ -282,6 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
db, db,
live_recommendation=True, live_recommendation=True,
exclude_open_trade_tickers=True, exclude_open_trade_tickers=True,
exclude_reentry_lockdown_tickers=True,
) )
config = await get_activation_config(db) config = await get_activation_config(db)
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)] return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
+242 -9
View File
@@ -94,6 +94,7 @@ from app.services.scoring_service import (
compute_technical_from_arrays, compute_technical_from_arrays,
) )
from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels from app.services.sr_service import detect_gate_target_ladder, detect_sr_levels
from app.services.trade_policy import REENTRY_LOCKDOWN_SESSIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -991,6 +992,68 @@ def _replay_and_signals(
) )
def _replay_candidates_for_period(
symbol: str,
columns: tuple,
config: dict,
activation: dict,
benchmark_closes: dict[date, float] | None,
start_date: date,
) -> 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``.
"""
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
)
]
candidates: list[dict] = []
for i in range(MIN_LOOKBACK - 1, len(bars) - HORIZON, STEP_DAYS):
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()
for setup in _window_setups(window, config, activation):
if setup["direction"] != "long":
continue
candidates.append({
"symbol": symbol,
"date": bars[i].date.isoformat(),
"iso_week": (iso[0], iso[1]),
"direction": "long",
"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"],
})
return candidates
def _backtest_worker_count() -> int: def _backtest_worker_count() -> int:
"""How many worker processes to replay tickers across. Capped to cpu_count-1 """How many worker processes to replay tickers across. Capped to cpu_count-1
so a core stays free for the web server; 1 means sequential.""" so a core stays free for the web server; 1 means sequential."""
@@ -1293,9 +1356,17 @@ def _simulate_portfolio(
max_positions: int = SIM_MAX_POSITIONS, max_positions: int = SIM_MAX_POSITIONS,
risk_per_trade: float = SIM_RISK_PER_TRADE, risk_per_trade: float = SIM_RISK_PER_TRADE,
atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER, atr_trail_multiplier: float = ATR_TRAIL_MULTIPLIER,
reentry_cooldown_days: int = 0,
initial_stop_refresh_fn: (
Callable[[str, int, float, dict, Any], float | None] | None
) = None,
post_stop_reentry_fn: (
Callable[[str, int, dict, Any], dict | None] | None
) = None,
start_date: date | None = None, start_date: date | None = None,
end_date: date | None = None, end_date: date | None = None,
include_curve: bool = False, include_curve: bool = False,
include_trades: bool = False,
) -> dict | None: ) -> dict | None:
"""Replay the qualified setups as ONE capital-constrained book and report """Replay the qualified setups as ONE capital-constrained book and report
portfolio economics from the daily equity curve (return, CAGR, drawdown, portfolio economics from the daily equity curve (return, CAGR, drawdown,
@@ -1309,7 +1380,14 @@ def _simulate_portfolio(
runs the ATR trail *and* the S/R take-profit together — the trade ends at runs the ATR trail *and* the S/R take-profit together — the trade ends at
whichever comes first. Stops fill at the worse of stop or open (gaps whichever comes first. Stops fill at the worse of stop or open (gaps
modeled); positions still open at the end are closed at their last mark. modeled); positions still open at the end are closed at their last mark.
Returns None when there is nothing to trade. ``reentry_cooldown_days`` 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.
""" """
if qualified_fn is None: if qualified_fn is None:
def _default_qualified(c: dict) -> bool: def _default_qualified(c: dict) -> bool:
@@ -1362,6 +1440,14 @@ def _simulate_portfolio(
curve: list[tuple[int, float]] = [] curve: list[tuple[int, float]] = []
trades: list[dict] = [] trades: list[dict] = []
skipped_full = 0 skipped_full = 0
skipped_cooldown = 0
cooldown_until_index: dict[str, int] = {}
stop_refresh_attempts = 0
stop_refreshes = 0
stop_refresh_same_bar_hits = 0
post_stop_states: dict[str, dict] = {}
post_stop_events = 0
reentry_events: list[dict] = []
technical_cache: dict[tuple[str, int], float | None] = {} technical_cache: dict[tuple[str, int], float | None] = {}
atr_cache: dict[tuple[str, int], float | None] = {} atr_cache: dict[tuple[str, int], float | None] = {}
@@ -1426,7 +1512,7 @@ def _simulate_portfolio(
atr_cache[key] = None atr_cache[key] = None
return atr_cache[key] return atr_cache[key]
def _close_trade(sym: str, fill: float, reason: str) -> None: def _close_trade(sym: str, fill: float, reason: str) -> dict:
nonlocal cash nonlocal cash
pos = positions.pop(sym) pos = positions.pop(sym)
proceeds = pos["shares"] * fill proceeds = pos["shares"] * fill
@@ -1434,16 +1520,29 @@ def _simulate_portfolio(
cash += proceeds - cost cash += proceeds - cost
risk = pos["entry"] - pos["initial_stop"] risk = pos["entry"] - pos["initial_stop"]
trades.append({ trades.append({
"symbol": sym,
"entry_ord": pos["entry_ord"],
"exit_ord": o,
"entry": pos["entry"],
"initial_stop": pos["initial_stop"],
"active_stop": pos["stop"],
"fill": fill,
"pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"], "pnl": proceeds - pos["shares"] * pos["entry"] - cost - pos["entry_cost"],
"r": (fill - pos["entry"]) / risk if risk > 0 else 0.0, "r": (fill - pos["entry"]) / risk if risk > 0 else 0.0,
"hold": pos["bars_held"], "hold": pos["bars_held"],
"reason": reason, "reason": reason,
"stop_refreshes": pos["stop_refreshes"],
"is_reentry": pos["is_reentry"],
"reentry_wait_sessions": pos["reentry_wait_sessions"],
"transaction_cost": pos["entry_cost"] + cost,
}) })
return pos
def _marked_equity() -> float: def _marked_equity() -> float:
return cash + sum(p["shares"] * p["last_close"] for p in positions.values()) return cash + sum(p["shares"] * p["last_close"] for p in positions.values())
for o in calendar: cooldown_days = max(0, int(reentry_cooldown_days))
for calendar_index, o in enumerate(calendar):
# 1) exits on today's bars (stop intraday, target intraday, time at close) # 1) exits on today's bars (stop intraday, target intraday, time at close)
for sym in list(positions): for sym in list(positions):
pos = positions[sym] pos = positions[sym]
@@ -1460,7 +1559,41 @@ def _simulate_portfolio(
if pos["stop"] > pos["initial_stop"] + 1e-9 if pos["stop"] > pos["initial_stop"] + 1e-9
else "stop" else "stop"
) )
_close_trade(sym, min(pos["stop"], bar.open), reason) survived_refresh = False
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_days:
cooldown_until_index[sym] = calendar_index + cooldown_days
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"]),
"gate_went_unqualified": False,
}
continue continue
if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]: if exit_policy in ("target", "atr_trail3_target") and pos["target"] and bar.high >= pos["target"]:
_close_trade(sym, pos["target"], "target") _close_trade(sym, pos["target"], "target")
@@ -1493,8 +1626,29 @@ def _simulate_portfolio(
# 2) entries at today's close, best momentum first # 2) entries at today's close, best momentum first
equity = _marked_equity() equity = _marked_equity()
fixed_todays = list(entries_by_ord.get(o, ()))
reentry_todays: list[dict] = []
if post_stop_reentry_fn is not None:
fixed_todays = [
candidate
for candidate in fixed_todays
if candidate["symbol"] not in post_stop_states
]
for sym, state in list(post_stop_states.items()):
bar = _bar(sym, o)
if bar is None:
continue
state["sessions_since_stop"] = (
calendar_index - state["stop_calendar_index"]
)
candidate = post_stop_reentry_fn(sym, o, state, bar)
if candidate is None:
continue
tagged = dict(candidate)
tagged["_post_stop_reentry"] = True
reentry_todays.append(tagged)
todays = sorted( todays = sorted(
entries_by_ord.get(o, ()), fixed_todays + reentry_todays,
key=lambda c: c.get(ranking_key) or 0.0, key=lambda c: c.get(ranking_key) or 0.0,
reverse=True, reverse=True,
) )
@@ -1502,6 +1656,9 @@ def _simulate_portfolio(
sym = c["symbol"] sym = c["symbol"]
if sym in positions: if sym in positions:
continue continue
if calendar_index < cooldown_until_index.get(sym, -1):
skipped_cooldown += 1
continue
if len(positions) >= max_positions: if len(positions) >= max_positions:
skipped_full += 1 skipped_full += 1
continue continue
@@ -1518,9 +1675,23 @@ def _simulate_portfolio(
continue continue
entry_cost = shares * entry * COST_PER_SIDE entry_cost = shares * entry * COST_PER_SIDE
cash -= shares * entry + entry_cost cash -= shares * entry + entry_cost
is_reentry = bool(c.get("_post_stop_reentry"))
reentry_wait_sessions: int | None = None
if is_reentry:
state = post_stop_states.pop(sym, None)
if state is not None:
reentry_wait_sessions = int(state["sessions_since_stop"])
reentry_events.append({
"symbol": sym,
"stop_ord": state["stop_ord"],
"reentry_ord": o,
"wait_sessions": reentry_wait_sessions,
"reason": c.get("_reentry_reason"),
})
positions[sym] = { positions[sym] = {
"shares": shares, "shares": shares,
"entry": entry, "entry": entry,
"entry_ord": o,
"initial_stop": stop, "initial_stop": stop,
"stop": stop, "stop": stop,
"target": float(c["target"]) if c.get("target") else None, "target": float(c["target"]) if c.get("target") else None,
@@ -1528,6 +1699,9 @@ def _simulate_portfolio(
"bars_held": 0, "bars_held": 0,
"last_close": entry, "last_close": entry,
"highest_close": entry, "highest_close": entry,
"stop_refreshes": 0,
"is_reentry": is_reentry,
"reentry_wait_sessions": reentry_wait_sessions,
} }
equity = _marked_equity() equity = _marked_equity()
@@ -1659,6 +1833,42 @@ def _simulate_portfolio(
result["equity_curve"] = curve_payload result["equity_curve"] = curve_payload
if benchmark_payload is not None: if benchmark_payload is not None:
result["benchmark_curve"] = benchmark_payload result["benchmark_curve"] = benchmark_payload
if cooldown_days:
result["reentry_cooldown_days"] = cooldown_days
result["skipped_cooldown"] = skipped_cooldown
if initial_stop_refresh_fn is not None:
result["stop_refresh_attempts"] = stop_refresh_attempts
result["stop_refreshes"] = stop_refreshes
result["stop_refresh_same_bar_hits"] = stop_refresh_same_bar_hits
if post_stop_reentry_fn is not None:
result["post_stop_events"] = post_stop_events
result["post_stop_reentries"] = len(reentry_events)
result["post_stop_states_open_at_end"] = len(post_stop_states)
result["reentry_events"] = [
{
**{
key: value
for key, value in event.items()
if key not in {"stop_ord", "reentry_ord"}
},
"stop_date": date.fromordinal(event["stop_ord"]).isoformat(),
"reentry_date": date.fromordinal(event["reentry_ord"]).isoformat(),
}
for event in reentry_events
]
if include_trades:
result["trade_details"] = [
{
**{
key: value
for key, value in trade.items()
if key not in {"entry_ord", "exit_ord"}
},
"entry_date": date.fromordinal(trade["entry_ord"]).isoformat(),
"exit_date": date.fromordinal(trade["exit_ord"]).isoformat(),
}
for trade in trades
]
return result return result
@@ -2019,13 +2229,15 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
}, },
{ {
"strategy": PRODUCTION_PORTFOLIO_STRATEGY, "strategy": PRODUCTION_PORTFOLIO_STRATEGY,
"label": "Production: residual/high-vol 80/20 + 3x ATR trail", "label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
"description": ( "description": (
"The live strategy: production activation gate and Admin exit policy " "The live strategy: production activation gate and Admin exit policy "
"as currently configured, 80/20 residual/high-vol rank." "as currently configured, 80/20 residual/high-vol rank, and a "
"five-session re-entry lockdown after an initial-stop exit."
), ),
"entry_variant": "residual80_highvol_blend80_20_fixed10", "entry_variant": "residual80_highvol_blend80_20_fixed10",
"exit_policy": "atr_trail3", "exit_policy": "atr_trail3",
"reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
# The production row replays what the platform actually does right now: # The production row replays what the platform actually does right now:
# the live qualification flag (runtime Admin activation settings) and the # the live qualification flag (runtime Admin activation settings) and the
# live Admin exit policy, instead of the frozen research-variant gate. # live Admin exit policy, instead of the frozen research-variant gate.
@@ -2149,6 +2361,9 @@ def _min_rr_sweep(
exit_policy = str(strategy["exit_policy"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None: if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get( exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
@@ -2188,6 +2403,7 @@ def _min_rr_sweep(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions,
start_date=sweep_start, start_date=sweep_start,
) )
if sim is None: if sim is None:
@@ -2212,6 +2428,7 @@ def _min_rr_sweep(
"live_qualified_setups": live_qualified, "live_qualified_setups": live_qualified,
"reproduces_production_gate": reproduces, "reproduces_production_gate": reproduces,
"exit_policy": exit_policy, "exit_policy": exit_policy,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"entries_from": sweep_start.isoformat() if sweep_start else None, "entries_from": sweep_start.isoformat() if sweep_start else None,
"window": "out-of-sample (test)" if sweep_start else "full history (in-sample)", "window": "out-of-sample (test)" if sweep_start else "full history (in-sample)",
"rows": rows, "rows": rows,
@@ -2267,6 +2484,9 @@ def _holdout_evaluation(
exit_policy = str(strategy["exit_policy"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
if strategy.get("use_live_config") and live_exit_policy is not None: if strategy.get("use_live_config") and live_exit_policy is not None:
exit_policy = LIVE_EXIT_MODE_TO_SIM.get( exit_policy = LIVE_EXIT_MODE_TO_SIM.get(
str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3" str(live_exit_policy.get("mode", "atr_trailing")), "atr_trail3"
@@ -2296,6 +2516,7 @@ def _holdout_evaluation(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions,
start_date=start, start_date=start,
end_date=end, end_date=end,
include_curve=True, include_curve=True,
@@ -2307,6 +2528,7 @@ def _holdout_evaluation(
return { return {
"split_date": split.isoformat(), "split_date": split.isoformat(),
"strategy": strategy["strategy"], "strategy": strategy["strategy"],
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"rows": rows, "rows": rows,
"note": ( "note": (
"Train = entries before the split; test = entries on/after it. The two " "Train = entries before the split; test = entries on/after it. The two "
@@ -2339,6 +2561,9 @@ def _portfolio_monitor(
# policy. The overlay opts into this deliberately so only ordering # policy. The overlay opts into this deliberately so only ordering
# changes relative to the production row. # changes relative to the production row.
use_live = bool(strategy.get("use_live_config")) use_live = bool(strategy.get("use_live_config"))
reentry_lockdown_sessions = int(
strategy.get("reentry_lockdown_sessions", 0)
)
exit_policy = str(strategy["exit_policy"]) exit_policy = str(strategy["exit_policy"])
row_hold_days = hold_days row_hold_days = hold_days
trail_multiplier = ATR_TRAIL_MULTIPLIER trail_multiplier = ATR_TRAIL_MULTIPLIER
@@ -2367,6 +2592,7 @@ def _portfolio_monitor(
max_positions=int(entry_cfg["max_positions"]), max_positions=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]), risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail_multiplier, atr_trail_multiplier=trail_multiplier,
reentry_cooldown_days=reentry_lockdown_sessions,
start_date=start, start_date=start,
include_curve=True, include_curve=True,
) )
@@ -2381,6 +2607,7 @@ def _portfolio_monitor(
"ranking_key": ranking_key, "ranking_key": ranking_key,
"exit_policy": exit_policy, "exit_policy": exit_policy,
"live_exit_mode": live_exit_mode, "live_exit_mode": live_exit_mode,
"reentry_lockdown_sessions": reentry_lockdown_sessions,
"lookback": lookback["lookback"], "lookback": lookback["lookback"],
"lookback_label": lookback["label"], "lookback_label": lookback["label"],
**sim, **sim,
@@ -2393,6 +2620,9 @@ def _portfolio_monitor(
"label": s["label"], "label": s["label"],
"description": s["description"], "description": s["description"],
"is_production": bool(s.get("is_production")), "is_production": bool(s.get("is_production")),
"reentry_lockdown_sessions": int(
s.get("reentry_lockdown_sessions", 0)
),
} }
for s in strategies for s in strategies
], ],
@@ -2405,7 +2635,8 @@ def _portfolio_monitor(
"Portfolio monitor runs supported named strategies across cached lookbacks. " "Portfolio monitor runs supported named strategies across cached lookbacks. "
"The structural overlay appears only in its explicit research arm and changes " "The structural overlay appears only in its explicit research arm and changes "
"ordering, not production qualification. Local snapshot backtests remain the " "ordering, not production qualification. Local snapshot backtests remain the "
"research surface for broad variant sweeps." "research surface for broad variant sweeps. The production row applies the "
"same five-session post-initial-stop re-entry lockdown as the live setup list."
), ),
} }
@@ -2618,7 +2849,8 @@ def _build_recommendation(report: dict) -> dict:
if production_row is not None: if production_row is not None:
headline = ( headline = (
"Production baseline: residual/high-vol 80/20 entry rank with a " "Production baseline: residual/high-vol 80/20 entry rank with a "
"3x ATR trailing exit and 30-trading-day max hold." "3x ATR trailing exit, 30-trading-day max hold, and 5-session "
"re-entry lockdown after an initial stop."
) )
if ( if (
production_row.get("cagr_pct") is not None production_row.get("cagr_pct") is not None
@@ -2994,6 +3226,7 @@ async def run_backtest(
"target_model": target_model, "target_model": target_model,
"target_model_label": BACKTEST_TARGET_MODELS[target_model], "target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL, "is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
}, },
"activation": activation, "activation": activation,
"overall_qualified": _bucket_stats(qualified), "overall_qualified": _bucket_stats(qualified),
+10 -3
View File
@@ -29,6 +29,7 @@ from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.sr_service import detect_gate_target_ladder from app.services.sr_service import detect_gate_target_ladder
from app.services.trade_policy import get_reentry_lockdown_ticker_ids
from app.services.recommendation_service import ( from app.services.recommendation_service import (
_risk_level_from_conflicts, _risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
@@ -771,6 +772,7 @@ async def get_trade_setups(
symbol: str | None = None, symbol: str | None = None,
live_recommendation: bool = False, live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False, exclude_open_trade_tickers: bool = False,
exclude_reentry_lockdown_tickers: bool = False,
) -> list[dict]: ) -> list[dict]:
"""Get latest stored trade setups, optionally filtered. """Get latest stored trade setups, optionally filtered.
@@ -794,15 +796,20 @@ async def get_trade_setups(
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence) stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
if recommended_action is not None and not live_recommendation: if recommended_action is not None and not live_recommendation:
stmt = stmt.where(TradeSetup.recommended_action == recommended_action) stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
excluded_ticker_ids: set[int] = set()
if exclude_open_trade_tickers: if exclude_open_trade_tickers:
open_trade_result = await db.execute( open_trade_result = await db.execute(
select(PaperTrade.ticker_id) select(PaperTrade.ticker_id)
.where(PaperTrade.status == "open") .where(PaperTrade.status == "open")
.distinct() .distinct()
) )
open_ticker_ids = {ticker_id for ticker_id, in open_trade_result.all()} excluded_ticker_ids.update(
if open_ticker_ids: ticker_id for ticker_id, in open_trade_result.all()
stmt = stmt.where(~TradeSetup.ticker_id.in_(open_ticker_ids)) )
if exclude_reentry_lockdown_tickers:
excluded_ticker_ids.update(await get_reentry_lockdown_ticker_ids(db))
if excluded_ticker_ids:
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc()) stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
+68
View File
@@ -0,0 +1,68 @@
"""Shared live/backtest trading-policy constants and availability checks."""
from __future__ import annotations
from datetime import date, datetime, time, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
# A ticker stopped at its initial stop may qualify again immediately, but the
# July 2026 event study showed that waiting five market sessions materially
# improved the production book. The stop session is wait_session=0; the first
# permitted re-entry is wait_session=5, provided the normal gate still passes.
REENTRY_LOCKDOWN_SESSIONS = 5
async def get_reentry_lockdown_ticker_ids(
db: AsyncSession,
*,
as_of: date | None = None,
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
) -> set[int]:
"""Ticker ids still inside the post-initial-stop market-session lockdown.
The market calendar is derived from stored OHLCV dates, not calendar days.
A stop on session D is released once five later stored sessions exist. Only
an initial-stop close (``close_reason == "stop"``) starts the lockdown;
trailing, target, time, and manual exits do not.
"""
sessions = max(0, int(sessions))
if sessions == 0:
return set()
session_cutoff = as_of or datetime.now(timezone.utc).date()
session_result = await db.execute(
select(OHLCVRecord.date)
.where(OHLCVRecord.date <= session_cutoff)
.distinct()
.order_by(OHLCVRecord.date.desc())
.limit(sessions)
)
recent_sessions = [row[0] for row in session_result.all()]
if not recent_sessions:
return set()
# Stops on or after the oldest of the latest N sessions have fewer than N
# later completed sessions. Once that oldest session rolls forward, the
# corresponding stop automatically leaves the result set.
lockdown_threshold = min(recent_sessions)
threshold_start = datetime.combine(
lockdown_threshold,
time.min,
tzinfo=timezone.utc,
)
result = await db.execute(
select(PaperTrade.ticker_id)
.where(
PaperTrade.status == "closed",
PaperTrade.close_reason == "stop",
PaperTrade.closed_at.is_not(None),
PaperTrade.closed_at >= threshold_start,
)
.distinct()
)
return {ticker_id for ticker_id, in result.all()}
@@ -321,6 +321,9 @@ export function BacktestPanel() {
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '} Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
{fmtR(monitorRun.worst_trade_r)} · Avg P&amp;L per trade {fmtMoney(monitorRun.avg_trade_pnl)} {fmtR(monitorRun.worst_trade_r)} · Avg P&amp;L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
{monitorRun.reentry_lockdown_sessions ? (
<> · Re-entry lockdown {monitorRun.reentry_lockdown_sessions} market sessions after initial stop</>
) : null}
</p> </p>
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && ( {monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
+9 -1
View File
@@ -357,13 +357,20 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
is_production: boolean; is_production: boolean;
entry_variant: string; entry_variant: string;
exit_policy: string; exit_policy: string;
reentry_lockdown_sessions?: number;
lookback: string; lookback: string;
lookback_label: string; lookback_label: string;
} }
export interface BacktestPortfolioMonitor { export interface BacktestPortfolioMonitor {
production_strategy: string; production_strategy: string;
strategies: { strategy: string; label: string; description: string; is_production: boolean }[]; strategies: {
strategy: string;
label: string;
description: string;
is_production: boolean;
reentry_lockdown_sessions?: number;
}[];
lookbacks: { lookback: string; label: string }[]; lookbacks: { lookback: string; label: string }[];
runs: BacktestPortfolioMonitorRun[]; runs: BacktestPortfolioMonitorRun[];
note?: string; note?: string;
@@ -402,6 +409,7 @@ export interface BacktestReport {
target_model?: 'production_gtl' | 'structural_sr'; target_model?: 'production_gtl' | 'structural_sr';
target_model_label?: string; target_model_label?: string;
is_production_target_model?: boolean; is_production_target_model?: boolean;
production_reentry_lockdown_sessions?: number;
}; };
overall_qualified: BacktestBucket; overall_qualified: BacktestBucket;
overall_all: BacktestBucket; overall_all: BacktestBucket;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
"""Targeted offline study of a gate-conditioned initial-stop refresh.
The study replays production entries only for the requested period. Whenever
an initial stop is touched, it rebuilds that ticker's setup using bars through
the previous close and recomputes the production momentum gate across the whole
historical universe. If the gate still passes and the new setup has a lower
valid stop, the simulator adopts it and checks it against the same day's low.
This is causal: no value from the stop day's eventual close is used to cancel
an intraday stop. The snapshot is read-only and no live settings are changed.
"""
from __future__ import annotations
import argparse
import asyncio
import bisect
import json
import multiprocessing
import os
import sys
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import date, datetime
from pathlib import Path
from types import SimpleNamespace
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))
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")
parser.add_argument("--start-date", default="2024-07-01")
parser.add_argument("--workers", type=int, default=6)
parser.add_argument("--out", default=None)
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"gate-protected-stop-{stamp}.json"
class GateStopRefresher:
"""Point-in-time gate and replacement-stop calculator for stop events."""
def __init__(
self,
prices: dict[str, tuple],
recommendation_config: dict,
activation: dict,
benchmark_closes: dict[date, float],
) -> None:
from app.services import backtest_service as bt
self.bt = bt
self.prices = prices
self.recommendation_config = recommendation_config
self.activation = activation
self.benchmark_closes = benchmark_closes
self.threshold = float(activation.get("min_momentum_percentile", 80.0))
self.dates = {
symbol: [date.fromordinal(value) for value in columns[0]]
for symbol, columns in prices.items()
}
self.index_of = {
symbol: {value: index for index, value in enumerate(columns[0])}
for symbol, columns in prices.items()
}
self.percentile_cache: dict[int, dict[str, float]] = {}
self.setup_cache: dict[tuple[str, int], dict | None] = {}
self.events: list[dict[str, Any]] = []
def _momentum_percentiles(self, asof_ord: int) -> dict[str, float]:
cached = self.percentile_cache.get(asof_ord)
if cached is not None:
return cached
values: dict[str, float] = {}
for symbol, columns in self.prices.items():
idx = bisect.bisect_right(columns[0], asof_ord) - 1
if idx < 252:
continue
closes = columns[4]
value = self.bt._residual_momentum_12_1(
self.dates[symbol], closes, idx, self.benchmark_closes
)
if value is None and closes[idx - 252] > 0:
value = closes[idx - 21] / closes[idx - 252] - 1.0
if value is not None:
values[symbol] = float(value)
ordered = sorted(values, key=lambda symbol: values[symbol])
denominator = len(ordered) - 1
percentiles = {
symbol: (rank / denominator * 100.0) if denominator > 0 else 100.0
for rank, symbol in enumerate(ordered)
}
self.percentile_cache[asof_ord] = percentiles
return percentiles
def _long_setup(self, symbol: str, asof_idx: int) -> dict | None:
columns = self.prices[symbol]
asof_ord = columns[0][asof_idx]
key = (symbol, asof_ord)
if key in self.setup_cache:
return self.setup_cache[key]
records = [
SimpleNamespace(
date=date.fromordinal(o),
open=op,
high=high,
low=low,
close=close,
volume=volume,
)
for o, op, high, low, close, volume in zip(
columns[0][: asof_idx + 1],
columns[1][: asof_idx + 1],
columns[2][: asof_idx + 1],
columns[3][: asof_idx + 1],
columns[4][: asof_idx + 1],
columns[5][: asof_idx + 1],
)
]
setups = self.bt._window_setups(
records, self.recommendation_config, self.activation
)
setup = next((row for row in setups if row["direction"] == "long"), None)
self.setup_cache[key] = setup
return setup
def __call__(
self,
symbol: str,
stop_ord: int,
active_stop: float,
position: dict,
bar: Any,
) -> float | None:
columns = self.prices[symbol]
stop_idx = self.index_of[symbol].get(stop_ord)
if stop_idx is None:
stop_idx = bisect.bisect_left(columns[0], stop_ord)
asof_idx = stop_idx - 1
if asof_idx < self.bt.MIN_LOOKBACK - 1:
return None
asof_ord = columns[0][asof_idx]
setup = self._long_setup(symbol, asof_idx)
momentum_pct = self._momentum_percentiles(asof_ord).get(symbol)
gate_passed = bool(
setup is not None
and self.bt._momentum_qualifies(
{
"meets_core": setup["meets_core"],
"direction": "long",
self.bt.PRODUCTION_PERCENTILE_KEY: momentum_pct,
},
self.threshold,
)
)
new_stop = float(setup["stop"]) if gate_passed and setup is not None else None
lower_stop = bool(new_stop is not None and new_stop < active_stop - 1e-9)
original_risk = float(position["entry"] - position["initial_stop"])
replacement_risk_r = (
(float(position["entry"]) - new_stop) / original_risk
if lower_stop and original_risk > 0 and new_stop is not None
else None
)
self.events.append({
"symbol": symbol,
"stop_date": date.fromordinal(stop_ord).isoformat(),
"gate_asof_date": date.fromordinal(asof_ord).isoformat(),
"momentum_percentile": round(momentum_pct, 2)
if momentum_pct is not None
else None,
"gate_core_passed": bool(setup and setup["meets_core"]),
"gate_passed": gate_passed,
"active_stop": round(active_stop, 4),
"replacement_stop": round(new_stop, 4) if new_stop is not None else None,
"lower_stop": lower_stop,
"same_bar_survives": bool(lower_stop and bar.low > new_stop),
"replacement_risk_r": round(replacement_risk_r, 3)
if replacement_risk_r is not None
else None,
})
return new_stop
def _arm(label: str, sim: dict) -> dict:
trade_details = sim.pop("trade_details", None)
row = {"arm": label, **sim}
if trade_details is not None:
row["trade_details"] = trade_details
return row
def _rescued_trade_summary(trades: list[dict]) -> dict:
rescued = [trade for trade in trades if trade.get("stop_refreshes", 0) > 0]
rs = [float(trade["r"]) for trade in rescued]
return {
"trades": len(rescued),
"wins": sum(value > 0 for value in rs),
"win_rate": round(sum(value > 0 for value in rs) / len(rs) * 100.0, 1)
if rs
else None,
"avg_r": round(sum(rs) / len(rs), 3) if rs else None,
"total_r": round(sum(rs), 2) if rs else None,
"worst_r": round(min(rs), 2) if rs else None,
"best_r": round(max(rs), 2) if rs else None,
"exit_reasons": dict(Counter(trade["reason"] for trade in rescued)),
}
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
try:
start_date = date.fromisoformat(args.start_date)
except ValueError as exc:
raise SystemExit("--start-date must use YYYY-MM-DD") from exc
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
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(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 engine.dispose()
candidates: list[dict] = []
workers = max(1, min(int(args.workers), multiprocessing.cpu_count() - 1))
context = 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,
start_date,
): symbol
for symbol, columns in prices.items()
}
for index, future in enumerate(as_completed(futures), 1):
candidates.extend(future.result())
if not args.quiet and index % 25 == 0:
print(f"replayed tickers: {index}/{len(futures)}", flush=True)
bt._assign_momentum_percentiles(candidates)
bt._assign_residual_momentum_percentiles(candidates)
bt._assign_low_volatility_percentiles(candidates)
bt._assign_activation_momentum_percentiles(candidates)
bt._assign_residual_high_vol_blend(candidates)
threshold = float(activation.get("min_momentum_percentile", 80.0))
for candidate in candidates:
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
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")
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)
)
sim_kwargs = {
"qualified_fn": None,
"ranking_key": str(
entry_config.get("ranking_key") or entry_config["percentile_key"]
),
"max_positions": int(entry_config["max_positions"]),
"risk_per_trade": float(entry_config["risk_per_trade"]),
"atr_trail_multiplier": trail_multiplier,
"start_date": start_date,
}
baseline = bt._simulate_portfolio(
candidates, prices, benchmark_closes, exit_policy, hold_days, **sim_kwargs
)
cooldown_5 = bt._simulate_portfolio(
candidates,
prices,
benchmark_closes,
exit_policy,
hold_days,
reentry_cooldown_days=5,
**sim_kwargs,
)
cooldown_10 = bt._simulate_portfolio(
candidates,
prices,
benchmark_closes,
exit_policy,
hold_days,
reentry_cooldown_days=10,
**sim_kwargs,
)
refresher = GateStopRefresher(
prices, recommendation_config, activation, benchmark_closes
)
gate_protected = bt._simulate_portfolio(
candidates,
prices,
benchmark_closes,
exit_policy,
hold_days,
initial_stop_refresh_fn=refresher,
include_trades=True,
**sim_kwargs,
)
if any(row is None for row in (baseline, cooldown_5, cooldown_10, gate_protected)):
raise RuntimeError("A study arm produced no trades")
gate_trades = list(gate_protected.get("trade_details") or [])
event_counts = Counter()
for event in refresher.events:
event_counts["stop_touches"] += 1
if event["gate_passed"]:
event_counts["gate_passed"] += 1
if event["lower_stop"]:
event_counts["lower_stop"] += 1
if event["same_bar_survives"]:
event_counts["same_bar_survives"] += 1
report = {
"generated_at": datetime.now().astimezone().isoformat(),
"snapshot": str(snapshot.resolve()),
"period_start": start_date.isoformat(),
"tickers": len(prices),
"entry_candidates": len(candidates),
"qualified_candidates": sum(bool(row["qualified"]) for row in candidates),
"params": {
"entry_cadence_days": bt.STEP_DAYS,
"setup_stop_atr_multiplier": bt.ATR_MULTIPLIER,
"exit_policy": exit_policy,
"exit_atr_multiplier": trail_multiplier,
"hold_days": hold_days,
"momentum_percentile_floor": threshold,
"gate_refresh_information_cutoff": "previous close",
},
"arms": [
_arm("baseline", baseline),
_arm("cooldown_5", cooldown_5),
_arm("cooldown_10", cooldown_10),
_arm("gate_protected_stop", gate_protected),
],
"gate_stop_events": {
**dict(event_counts),
"unique_symbols": len({event["symbol"] for event in refresher.events}),
"rescued_trade_outcomes": _rescued_trade_summary(gate_trades),
"events": refresher.events,
},
"note": (
"The gate-protected arm recalculates the gate at an initial-stop touch "
"using only data available through the previous close. It accepts only "
"a lower stop from a newly valid long setup and checks that replacement "
"against the same bar. It does not cancel stops using the later same-day close."
),
}
output = Path(args.out) if args.out else _default_output_path()
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 arm in report["arms"]:
print(
f"{arm['arm']}: Sharpe {arm['sharpe']}, CAGR {arm['cagr_pct']}%, "
f"DD {arm['max_drawdown_pct']}%, trades {arm['trades']}"
)
print(f"gate stop events: {dict(event_counts)}")
print(f"rescued outcomes: {report['gate_stop_events']['rescued_trade_outcomes']}")
if __name__ == "__main__":
asyncio.run(_main())
+541
View File
@@ -0,0 +1,541 @@
"""Offline event study for stateful post-stop re-entry policies.
Initial entries keep the validated weekly production cadence. After an initial
stop, the affected ticker is evaluated on every subsequent daily close. This
isolates the exact churn problem without changing the rest of the portfolio.
All arms retain the hard stop, production position sizing, 3x ATR trail, and
round-trip transaction costs.
"""
from __future__ import annotations
import argparse
import asyncio
import bisect
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 types import SimpleNamespace
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))
RECLAIM_ATR_BUFFER = 0.25
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")
parser.add_argument("--start-date", default="2024-07-01")
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 for the expensive weekly candidate replay.",
)
parser.add_argument("--quiet", action="store_true")
parser.add_argument(
"--cooldowns",
type=int,
nargs="+",
default=None,
help=(
"Run an immediate baseline plus the given cooldown lengths instead "
"of the gate-reset policy study (for example: 3 5 7 10)."
),
)
return parser.parse_args()
def _default_output_path() -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("reports") / f"post-stop-reentry-{stamp}.json"
class DailySetupEngine:
"""Point-in-time daily setup and universe-rank cache."""
def __init__(
self,
prices: dict[str, tuple],
recommendation_config: dict,
activation: dict,
benchmark_closes: dict[date, float],
) -> None:
from app.services import backtest_service as bt
self.bt = bt
self.prices = prices
self.recommendation_config = recommendation_config
self.activation = activation
self.benchmark_closes = benchmark_closes
self.threshold = float(activation.get("min_momentum_percentile", 80.0))
self.dates = {
symbol: [date.fromordinal(value) for value in columns[0]]
for symbol, columns in prices.items()
}
self.index_of = {
symbol: {value: index for index, value in enumerate(columns[0])}
for symbol, columns in prices.items()
}
self.rank_cache: dict[int, dict[str, tuple[float, float]]] = {}
self.candidate_cache: dict[tuple[str, int], dict | None] = {}
self.atr_cache: dict[tuple[str, int], float | None] = {}
@staticmethod
def _percentiles(values: dict[str, float]) -> dict[str, float]:
ordered = sorted(values, key=lambda symbol: values[symbol])
denominator = len(ordered) - 1
return {
symbol: (rank / denominator * 100.0) if denominator > 0 else 100.0
for rank, symbol in enumerate(ordered)
}
def _ranks(self, asof_ord: int) -> dict[str, tuple[float, float]]:
cached = self.rank_cache.get(asof_ord)
if cached is not None:
return cached
momentum_values: dict[str, float] = {}
volatility_values: dict[str, float] = {}
for symbol, columns in self.prices.items():
idx = bisect.bisect_right(columns[0], asof_ord) - 1
if idx < 0:
continue
closes = columns[4]
if idx >= 252:
momentum = self.bt._residual_momentum_12_1(
self.dates[symbol], closes, idx, self.benchmark_closes
)
if momentum is None and closes[idx - 252] > 0:
momentum = closes[idx - 21] / closes[idx - 252] - 1.0
if momentum is not None:
momentum_values[symbol] = float(momentum)
volatility = self.bt._realized_vol_6m(closes, idx)
if volatility is not None:
volatility_values[symbol] = float(volatility)
momentum_pct = self._percentiles(momentum_values)
volatility_pct = self._percentiles(volatility_values)
ranks = {
symbol: (momentum_pct[symbol], volatility_pct.get(symbol, 0.0))
for symbol in momentum_pct
}
self.rank_cache[asof_ord] = ranks
return ranks
def atr(self, symbol: str, asof_ord: int) -> float | None:
key = (symbol, asof_ord)
if key in self.atr_cache:
return self.atr_cache[key]
columns = self.prices[symbol]
idx = self.index_of[symbol].get(asof_ord)
if idx is None:
idx = bisect.bisect_right(columns[0], asof_ord) - 1
if idx < 0:
self.atr_cache[key] = None
return None
try:
value = self.bt.compute_atr(
columns[2][: idx + 1],
columns[3][: idx + 1],
columns[4][: idx + 1],
)["atr"]
result = float(value) if value and value > 0 else None
except Exception:
result = None
self.atr_cache[key] = result
return result
def candidate(self, symbol: str, asof_ord: int) -> dict | None:
key = (symbol, asof_ord)
if key in self.candidate_cache:
cached = self.candidate_cache[key]
return dict(cached) if cached is not None else None
columns = self.prices[symbol]
idx = self.index_of[symbol].get(asof_ord)
if idx is None or idx < self.bt.MIN_LOOKBACK - 1:
self.candidate_cache[key] = None
return None
records = [
SimpleNamespace(
date=date.fromordinal(o),
open=op,
high=high,
low=low,
close=close,
volume=volume,
)
for o, op, high, low, close, volume in zip(
columns[0][: idx + 1],
columns[1][: idx + 1],
columns[2][: idx + 1],
columns[3][: idx + 1],
columns[4][: idx + 1],
columns[5][: idx + 1],
)
]
setups = self.bt._window_setups(
records, self.recommendation_config, self.activation
)
setup = next((row for row in setups if row["direction"] == "long"), None)
rank = self._ranks(asof_ord).get(symbol)
gate_passed = bool(
setup is not None
and rank is not None
and self.bt._momentum_qualifies(
{
"meets_core": setup["meets_core"],
"direction": "long",
self.bt.PRODUCTION_PERCENTILE_KEY: rank[0],
},
self.threshold,
)
)
if not gate_passed or setup is None or rank is None:
self.candidate_cache[key] = None
return None
strategy_rank = (
rank[0] * self.bt.STRATEGY_RANK_MOMENTUM_WEIGHT
+ rank[1] * (1.0 - self.bt.STRATEGY_RANK_MOMENTUM_WEIGHT)
)
candidate = {
"symbol": symbol,
"date": date.fromordinal(asof_ord).isoformat(),
"direction": "long",
"entry": float(setup["entry"]),
"stop": float(setup["stop"]),
"target": float(setup["target"]),
"qualified": True,
self.bt.PRODUCTION_PERCENTILE_KEY: rank[0],
self.bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: strategy_rank,
}
self.candidate_cache[key] = candidate
return dict(candidate)
class ReentryPolicy:
def __init__(self, name: str, engine: DailySetupEngine) -> None:
self.name = name
self.engine = engine
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
if "reentry_trigger" not in state:
stop_atr = self.engine.atr(symbol, state["stop_ord"])
state["reentry_trigger"] = (
state["stop_day_high"] + RECLAIM_ATR_BUFFER * stop_atr
if stop_atr is not None
else state["stop_day_high"]
)
candidate = self.engine.candidate(symbol, asof_ord)
if candidate is None:
state["gate_went_unqualified"] = True
return None
self.gate_passes += 1
reason: str | None = None
sessions = int(state["sessions_since_stop"])
if self.name == "immediate":
reason = "gate_still_or_again_qualified"
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 == "gate_reset_or_reclaim":
if state["gate_went_unqualified"]:
reason = "gate_failed_then_requalified"
elif (
bar.close > state["reentry_trigger"]
and float(candidate["stop"]) > state["previous_stop"]
):
reason = "price_reclaim_with_improved_stop"
else:
raise ValueError(f"Unknown re-entry policy: {self.name}")
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_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_day_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
),
}
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
try:
start_date = date.fromisoformat(args.start_date)
except ValueError as exc:
raise SystemExit("--start-date must use YYYY-MM-DD") from exc
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
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(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 engine.dispose()
cache_path = Path(args.candidate_cache) if args.candidate_cache else None
snapshot_stat = snapshot.stat()
cache_key = {
"snapshot": str(snapshot.resolve()),
"snapshot_size": snapshot_stat.st_size,
"snapshot_mtime_ns": snapshot_stat.st_mtime_ns,
"start_date": start_date.isoformat(),
}
candidates: list[dict]
if cache_path is not None and cache_path.exists():
with cache_path.open("rb") as handle:
cached_replay = pickle.load(handle) # noqa: S301 - trusted local cache
if cached_replay.get("key") != cache_key:
raise SystemExit(f"Candidate cache does not match this run: {cache_path}")
candidates = list(cached_replay["candidates"])
if not args.quiet:
print(f"loaded candidate cache: {cache_path}", flush=True)
else:
candidates = []
workers = max(1, min(int(args.workers), multiprocessing.cpu_count() - 1))
context = 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,
start_date,
): symbol
for symbol, columns in prices.items()
}
for index, future in enumerate(as_completed(futures), 1):
candidates.extend(future.result())
if not args.quiet and index % 25 == 0:
print(f"replayed tickers: {index}/{len(futures)}", flush=True)
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, "candidates": candidates},
handle,
protocol=pickle.HIGHEST_PROTOCOL,
)
if not args.quiet:
print(f"wrote candidate cache: {cache_path}", flush=True)
bt._assign_momentum_percentiles(candidates)
bt._assign_residual_momentum_percentiles(candidates)
bt._assign_low_volatility_percentiles(candidates)
bt._assign_activation_momentum_percentiles(candidates)
bt._assign_residual_high_vol_blend(candidates)
threshold = float(activation.get("min_momentum_percentile", 80.0))
for candidate in candidates:
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
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")
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)
)
sim_kwargs = {
"ranking_key": str(
entry_config.get("ranking_key") or entry_config["percentile_key"]
),
"max_positions": int(entry_config["max_positions"]),
"risk_per_trade": float(entry_config["risk_per_trade"]),
"atr_trail_multiplier": trail_multiplier,
"start_date": start_date,
"include_trades": True,
}
daily_engine = DailySetupEngine(
prices, recommendation_config, activation, benchmark_closes
)
if args.cooldowns is None:
policy_names = (
"immediate",
"cooldown_5",
"gate_reset",
"gate_reset_or_reclaim",
)
else:
cooldowns = sorted(set(args.cooldowns))
if any(value < 1 for value in cooldowns):
raise SystemExit("--cooldowns values must be positive integers")
policy_names = ("immediate", *(f"cooldown_{value}" for value in cooldowns))
arms: list[dict] = []
for policy_name in policy_names:
policy = ReentryPolicy(policy_name, daily_engine)
sim = bt._simulate_portfolio(
candidates,
prices,
benchmark_closes,
exit_policy,
hold_days,
post_stop_reentry_fn=policy,
**sim_kwargs,
)
if sim is None:
raise RuntimeError(f"Policy {policy_name} produced no trades")
trades = list(sim.pop("trade_details"))
arms.append({
"arm": policy_name,
**sim,
"turnover": _trade_summary(trades),
"policy": policy.summary(),
"trade_details": trades,
})
output = Path(args.out) if args.out else _default_output_path()
report = {
"generated_at": datetime.now().astimezone().isoformat(),
"snapshot": str(snapshot.resolve()),
"period_start": start_date.isoformat(),
"tickers": len(prices),
"entry_candidates": len(candidates),
"qualified_candidates": sum(bool(row["qualified"]) for row in candidates),
"params": {
"initial_entry_cadence_days": bt.STEP_DAYS,
"post_stop_evaluation_cadence_days": 1,
"setup_stop_atr_multiplier": bt.ATR_MULTIPLIER,
"exit_policy": exit_policy,
"exit_atr_multiplier": trail_multiplier,
"hold_days": hold_days,
"cost_per_side_pct": bt.COST_PER_SIDE * 100.0,
"momentum_percentile_floor": threshold,
"reclaim_atr_buffer": RECLAIM_ATR_BUFFER,
"cooldown_sessions": cooldowns if args.cooldowns is not None else None,
},
"arms": arms,
"note": (
"Initial opportunities retain the validated weekly replay cadence. "
"Only tickers stopped at their initial stop switch to daily evaluation, "
"which isolates next-day/same-episode re-entry churn. A cooldown of N "
"sessions permits the first re-entry at wait_sessions=N. Gate reset "
"requires at least one unqualified daily close before requalification. "
"The reclaim arm alternatively accepts a close above stop-day high + "
"0.25 ATR only when the new setup stop is above the prior stop."
),
}
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 arm in arms:
turnover = arm["turnover"]
print(
f"{arm['arm']}: Sharpe {arm['sharpe']}, CAGR {arm['cagr_pct']}%, "
f"DD {arm['max_drawdown_pct']}%, trades {arm['trades']}, "
f"reentries {turnover['reentry_trades']}, fees ${turnover['transaction_cost']}"
)
if __name__ == "__main__":
asyncio.run(_main())
+169
View File
@@ -575,6 +575,170 @@ class TestSimulatePortfolio:
assert sim["trades"] == 1 assert sim["trades"] == 1
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 100) / 5 assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 100) / 5
def test_initial_stop_cooldown_blocks_immediate_reentry(self):
closes = [100.0, 94.0, 96.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
candidates = [
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
_sim_cand("AAA", self.ORD + 1, entry=94.0, stop=89.0, target=110.0),
]
baseline = bt._simulate_portfolio(candidates, prices, None, "hold", 30)
cooldown = bt._simulate_portfolio(
candidates,
prices,
None,
"hold",
30,
reentry_cooldown_days=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_days"] == 5
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
def fake_simulator(*_args, **kwargs):
return {
"trades": 0,
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_days", 0),
}
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"]
]
comparison_rows = [
row for row in monitor["runs"] if not row["is_production"]
]
assert production_rows
assert all(
row["reentry_lockdown_sessions"] == bt.REENTRY_LOCKDOWN_SESSIONS
and row["applied_reentry_lockdown"] == bt.REENTRY_LOCKDOWN_SESSIONS
for row in production_rows
)
assert comparison_rows
assert all(
row["reentry_lockdown_sessions"] == 0
and row["applied_reentry_lockdown"] == 0
for row in comparison_rows
)
def test_initial_stop_can_refresh_lower_and_survive_same_bar(self):
closes = [100.0, 94.0, 96.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
candidate = _sim_cand(
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
)
sim = bt._simulate_portfolio(
[candidate],
prices,
None,
"hold",
2,
initial_stop_refresh_fn=lambda *_: 90.0,
include_trades=True,
)
assert sim is not None
assert sim["stop_refresh_attempts"] == 1
assert sim["stop_refreshes"] == 1
assert sim["stop_refresh_same_bar_hits"] == 0
assert sim["exit_reasons"] == {"time": 1}
assert sim["trade_details"][0]["stop_refreshes"] == 1
def test_refreshed_stop_is_checked_against_same_bar(self):
ords = list(range(self.ORD, self.ORD + 2))
prices = {
"AAA": (
ords,
[100.0, 94.0],
[101.0, 96.0],
[99.0, 89.0],
[100.0, 94.0],
[1, 1],
)
}
candidate = _sim_cand(
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
)
sim = bt._simulate_portfolio(
[candidate],
prices,
None,
"hold",
30,
initial_stop_refresh_fn=lambda *_: 90.0,
)
assert sim is not None
assert sim["stop_refresh_same_bar_hits"] == 1
assert sim["worst_trade_r"] == pytest.approx(-2.0)
def test_post_stop_state_suppresses_same_episode_candidate(self):
closes = [100.0, 94.0, 96.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
candidates = [
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
_sim_cand("AAA", self.ORD + 1, entry=94.0, stop=89.0, target=110.0),
]
sim = bt._simulate_portfolio(
candidates,
prices,
None,
"hold",
30,
post_stop_reentry_fn=lambda *_: None,
)
assert sim is not None
assert sim["trades"] == 1
assert sim["post_stop_events"] == 1
assert sim["post_stop_reentries"] == 0
assert sim["post_stop_states_open_at_end"] == 1
def test_post_stop_callback_can_reenter_same_day(self):
closes = [100.0, 94.0, 96.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
initial = _sim_cand(
"AAA", self.ORD, entry=100.0, stop=95.0, target=120.0
)
def immediate_reentry(sym, current_ord, _state, bar):
return _sim_cand(
sym,
current_ord,
entry=bar.close,
stop=bar.close - 5.0,
target=bar.close + 15.0,
)
sim = bt._simulate_portfolio(
[initial],
prices,
None,
"hold",
30,
post_stop_reentry_fn=immediate_reentry,
include_trades=True,
)
assert sim is not None
assert sim["trades"] == 2
assert sim["post_stop_reentries"] == 1
assert sim["reentry_events"][0]["wait_sessions"] == 0
assert sim["trade_details"][1]["is_reentry"] is True
assert sim["trade_details"][1]["reentry_wait_sessions"] == 0
def test_sma50_policy_exits_on_close_break(self): def test_sma50_policy_exits_on_close_break(self):
closes = [100.0] * 56 + [90.0, 91.0] closes = [100.0] * 56 + [90.0, 91.0]
prices = {"AAA": _sim_prices(self.ORD, closes)} prices = {"AAA": _sim_prices(self.ORD, closes)}
@@ -722,6 +886,7 @@ def test_build_recommendation_prefers_production_monitor_headline():
}) })
assert rec["headline"] is not None assert rec["headline"] is not None
assert "3x ATR trailing exit" in rec["headline"] assert "3x ATR trailing exit" in rec["headline"]
assert "5-session re-entry lockdown" in rec["headline"]
assert any(item["topic"] == "production" for item in rec["items"]) assert any(item["topic"] == "production" for item in rec["items"])
@@ -870,6 +1035,10 @@ async def test_run_backtest_smoke(session):
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100) assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
assert report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL assert report["params"]["target_model"] == bt.PRODUCTION_GTL_TARGET_MODEL
assert report["params"]["is_production_target_model"] is True assert report["params"]["is_production_target_model"] is True
assert (
report["params"]["production_reentry_lockdown_sessions"]
== bt.REENTRY_LOCKDOWN_SESSIONS
)
assert "net_avg_r" in report["overall_all"] assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row # ablation baseline reproduces the qualified set exactly, and every row
@@ -607,6 +607,99 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
assert [row["symbol"] for row in ticker_rows] == ["OPENQ"] assert [row["symbol"] for row in ticker_rows] == ["OPENQ"]
@pytest.mark.asyncio
async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
db_session: AsyncSession,
):
now = datetime.now(timezone.utc)
today = now.date()
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()
# Six synthetic stored market sessions D0..D5. A stop on D0 has five
# later sessions and is released; a stop on D1 has only four and is not.
market_sessions = [today - timedelta(days=offset) for offset in range(5, -1, -1)]
for market_date in market_sessions:
db_session.add(
OHLCVRecord(
ticker_id=blocked.id,
date=market_date,
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000,
)
)
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, closed_on: date, reason: str) -> 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=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,
)
db_session.add_all(
[
closed_trade(blocked, market_sessions[1], "stop"),
closed_trade(released, market_sessions[0], "stop"),
closed_trade(trailing, market_sessions[-1], "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_lockdown_tickers=True,
)
}
assert "STOP4" not in available_symbols
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup: async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context """Stored setup frozen at scan time (conf 82, neutral) vs. current context
(bullish sentiment, composite 96) that yields live confidence 97. (bullish sentiment, composite 96) that yields live confidence 97.