feat: require gate reset before post-stop reentry
This commit is contained in:
@@ -282,7 +282,7 @@ async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||
db,
|
||||
live_recommendation=True,
|
||||
exclude_open_trade_tickers=True,
|
||||
exclude_reentry_lockdown_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)]
|
||||
|
||||
@@ -94,7 +94,6 @@ from app.services.scoring_service import (
|
||||
compute_technical_from_arrays,
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -103,6 +102,7 @@ KEY_REPORT = "backtest_report"
|
||||
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,
|
||||
@@ -1429,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],
|
||||
@@ -2325,35 +2391,35 @@ PORTFOLIO_MONITOR_STRATEGIES: tuple[dict, ...] = (
|
||||
"exit_policy": "hold",
|
||||
},
|
||||
{
|
||||
"strategy": "production_live_no_lockdown",
|
||||
"label": "Live setup + 3x ATR trail (no re-entry lockdown)",
|
||||
"strategy": "production_live_immediate",
|
||||
"label": "Live setup + 3x ATR trail (immediate re-entry)",
|
||||
"description": (
|
||||
"Exact live activation, ordering, and Admin exit policy, with only "
|
||||
"the post-stop re-entry lockdown disabled as the comparison baseline."
|
||||
"the post-stop gate reset disabled as the comparison baseline."
|
||||
),
|
||||
"entry_variant": "residual80_highvol_blend80_20_fixed10",
|
||||
"exit_policy": "atr_trail3",
|
||||
"reentry_lockdown_sessions": 0,
|
||||
"reentry_policy": "immediate",
|
||||
"use_live_config": True,
|
||||
"comparison_arm": "live_no_lockdown",
|
||||
"comparison_arm": "live_immediate",
|
||||
},
|
||||
{
|
||||
"strategy": PRODUCTION_PORTFOLIO_STRATEGY,
|
||||
"label": "Production: residual/high-vol 80/20 + 3x ATR trail + 5-session lockdown",
|
||||
"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 a "
|
||||
"five-session re-entry lockdown after an initial-stop exit."
|
||||
"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_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
||||
"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_lockdown_5",
|
||||
"comparison_arm": "live_gate_reset",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2456,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.
|
||||
|
||||
@@ -2472,9 +2539,7 @@ def _min_rr_sweep(
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
reentry_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
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"
|
||||
@@ -2514,7 +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,
|
||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||
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:
|
||||
@@ -2539,7 +2616,7 @@ def _min_rr_sweep(
|
||||
"live_qualified_setups": live_qualified,
|
||||
"reproduces_production_gate": reproduces,
|
||||
"exit_policy": exit_policy,
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"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,
|
||||
@@ -2573,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.
|
||||
@@ -2595,9 +2673,7 @@ def _holdout_evaluation(
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
reentry_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
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"
|
||||
@@ -2610,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 (
|
||||
@@ -2623,11 +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,
|
||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
include_curve=True,
|
||||
@@ -2639,7 +2729,7 @@ def _holdout_evaluation(
|
||||
return {
|
||||
"split_date": split.isoformat(),
|
||||
"strategy": strategy["strategy"],
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"reentry_policy": reentry_policy,
|
||||
"rows": rows,
|
||||
"note": (
|
||||
"Train = entries before the split; test = entries on/after it. The two "
|
||||
@@ -2655,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] = []
|
||||
@@ -2672,9 +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_lockdown_sessions = int(
|
||||
strategy.get("reentry_lockdown_sessions", 0)
|
||||
)
|
||||
reentry_policy = str(strategy.get("reentry_policy", "immediate"))
|
||||
exit_policy = str(strategy["exit_policy"])
|
||||
row_hold_days = hold_days
|
||||
trail_multiplier = ATR_TRAIL_MULTIPLIER
|
||||
@@ -2690,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(
|
||||
@@ -2703,7 +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,
|
||||
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||
post_stop_reentry_fn=post_stop_reentry_fn,
|
||||
start_date=start,
|
||||
include_curve=True,
|
||||
)
|
||||
@@ -2719,7 +2819,7 @@ def _portfolio_monitor(
|
||||
"ranking_key": ranking_key,
|
||||
"exit_policy": exit_policy,
|
||||
"live_exit_mode": live_exit_mode,
|
||||
"reentry_lockdown_sessions": reentry_lockdown_sessions,
|
||||
"reentry_policy": reentry_policy,
|
||||
"lookback": lookback["lookback"],
|
||||
"lookback_label": lookback["label"],
|
||||
**sim,
|
||||
@@ -2733,9 +2833,7 @@ def _portfolio_monitor(
|
||||
"description": s["description"],
|
||||
"is_production": bool(s.get("is_production")),
|
||||
"comparison_arm": s.get("comparison_arm"),
|
||||
"reentry_lockdown_sessions": int(
|
||||
s.get("reentry_lockdown_sessions", 0)
|
||||
),
|
||||
"reentry_policy": str(s.get("reentry_policy", "immediate")),
|
||||
}
|
||||
for s in strategies
|
||||
],
|
||||
@@ -2749,7 +2847,7 @@ def _portfolio_monitor(
|
||||
"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. The production row applies the "
|
||||
"same five-session post-initial-stop re-entry lockdown as the live setup list."
|
||||
"same post-initial-stop gate-reset rule as the live setup list."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2758,7 +2856,7 @@ def _production_cadence_comparison(
|
||||
monitor: dict | None,
|
||||
cadence: str,
|
||||
) -> dict | None:
|
||||
"""Compact full-history live/no-lockdown vs live/5-session comparison."""
|
||||
"""Compact full-history live/immediate vs live/gate-reset comparison."""
|
||||
if not monitor:
|
||||
return None
|
||||
arms: list[dict] = []
|
||||
@@ -2773,23 +2871,23 @@ def _production_cadence_comparison(
|
||||
}
|
||||
arm_name = (
|
||||
"prod_live_setup"
|
||||
if comparison_arm == "live_no_lockdown"
|
||||
else "cooldown_5"
|
||||
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: int(row.get("reentry_lockdown_sessions", 0)))
|
||||
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 five-session post-stop "
|
||||
"re-entry lockdown changes."
|
||||
"fees, and candidate cadence. Only the post-stop gate-reset rule "
|
||||
"changes."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3002,8 +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, 30-trading-day max hold, and 5-session "
|
||||
"re-entry lockdown after an initial stop."
|
||||
"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
|
||||
@@ -3357,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")
|
||||
@@ -3390,7 +3490,7 @@ async def run_backtest(
|
||||
"target_model": target_model,
|
||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||
"production_reentry_lockdown_sessions": REENTRY_LOCKDOWN_SESSIONS,
|
||||
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
|
||||
},
|
||||
"activation": activation,
|
||||
"overall_qualified": _bucket_stats(qualified),
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.services.outcome_service import (
|
||||
Bar,
|
||||
evaluate_setup_against_bars,
|
||||
)
|
||||
from app.services.trade_policy import get_reentry_lockdowns
|
||||
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
|
||||
@@ -319,12 +319,9 @@ async def create_trade(
|
||||
raise ValidationError("shares and entry_price must be positive")
|
||||
|
||||
ticker = await _get_ticker(db, symbol)
|
||||
remaining_sessions = (await get_reentry_lockdowns(db)).get(ticker.id)
|
||||
if remaining_sessions is not None:
|
||||
suffix = "session" if remaining_sessions == 1 else "sessions"
|
||||
if ticker.id in await get_reentry_gate_locks(db):
|
||||
raise ValidationError(
|
||||
f"{ticker.symbol} is in a post-stop re-entry lockdown: "
|
||||
f"{remaining_sessions} market {suffix} remaining"
|
||||
f"{ticker.symbol} requires a post-stop gate reset before re-entry"
|
||||
)
|
||||
trade = PaperTrade(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -28,8 +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_lockdowns
|
||||
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,
|
||||
@@ -700,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.
|
||||
@@ -721,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.
|
||||
@@ -754,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, "")
|
||||
|
||||
@@ -772,8 +814,8 @@ async def get_trade_setups(
|
||||
symbol: str | None = None,
|
||||
live_recommendation: bool = False,
|
||||
exclude_open_trade_tickers: bool = False,
|
||||
exclude_reentry_lockdown_tickers: bool = False,
|
||||
include_reentry_lockdown: bool = False,
|
||||
exclude_reentry_gate_locked_tickers: bool = False,
|
||||
include_reentry_gate_lock: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Get latest stored trade setups, optionally filtered.
|
||||
|
||||
@@ -798,7 +840,7 @@ async def get_trade_setups(
|
||||
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_lockdowns: dict[int, int] = {}
|
||||
reentry_gate_locks: dict[int, datetime] = {}
|
||||
if exclude_open_trade_tickers:
|
||||
open_trade_result = await db.execute(
|
||||
select(PaperTrade.ticker_id)
|
||||
@@ -808,10 +850,10 @@ async def get_trade_setups(
|
||||
excluded_ticker_ids.update(
|
||||
ticker_id for ticker_id, in open_trade_result.all()
|
||||
)
|
||||
if exclude_reentry_lockdown_tickers or include_reentry_lockdown:
|
||||
reentry_lockdowns = await get_reentry_lockdowns(db)
|
||||
if exclude_reentry_lockdown_tickers:
|
||||
excluded_ticker_ids.update(reentry_lockdowns)
|
||||
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))
|
||||
|
||||
@@ -866,14 +908,14 @@ async def get_trade_setups(
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if include_reentry_lockdown:
|
||||
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_lockdown_remaining_sessions"] = (
|
||||
reentry_lockdowns.get(ticker_id) if ticker_id is not None else None
|
||||
row["reentry_gate_reset_required"] = (
|
||||
ticker_id in reentry_gate_locks if ticker_id is not None else False
|
||||
)
|
||||
return rows_out
|
||||
|
||||
|
||||
@@ -1,120 +1,93 @@
|
||||
"""Shared live/backtest trading-policy constants and availability checks."""
|
||||
"""Shared live trading-policy state and availability checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.benchmark_price import BenchmarkPrice
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.paper_trade import PaperTrade
|
||||
from app.services.benchmark_service import BENCHMARK_SYMBOL
|
||||
|
||||
# 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_lockdowns(
|
||||
async def _latest_initial_stop_trades(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
as_of: date | None = None,
|
||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
||||
) -> dict[int, int]:
|
||||
"""Return ``{ticker_id: remaining_sessions}`` for active lockdowns.
|
||||
|
||||
SPY is the canonical calendar for the platform's US-equity universe. When
|
||||
the stored benchmark history does not reach an older stop, only that
|
||||
ticker's own OHLCV dates are used as a conservative fallback. Unrelated
|
||||
ticker dates can therefore never shorten a lockdown.
|
||||
"""
|
||||
sessions = max(0, int(sessions))
|
||||
if sessions == 0:
|
||||
return {}
|
||||
|
||||
session_cutoff = as_of or datetime.now(timezone.utc).date()
|
||||
stop_result = await db.execute(
|
||||
select(
|
||||
PaperTrade.ticker_id,
|
||||
func.max(PaperTrade.closed_at).label("last_stop_at"),
|
||||
)
|
||||
closed_before: datetime | None = None,
|
||||
) -> dict[int, PaperTrade]:
|
||||
"""Return the most recent initial-stop trade for each ticker."""
|
||||
stmt = (
|
||||
select(PaperTrade)
|
||||
.where(
|
||||
PaperTrade.status == "closed",
|
||||
PaperTrade.close_reason == "stop",
|
||||
PaperTrade.closed_at.is_not(None),
|
||||
)
|
||||
.group_by(PaperTrade.ticker_id)
|
||||
.order_by(
|
||||
PaperTrade.ticker_id.asc(),
|
||||
PaperTrade.closed_at.desc(),
|
||||
PaperTrade.id.desc(),
|
||||
)
|
||||
)
|
||||
stop_dates = {
|
||||
ticker_id: stopped_at.date()
|
||||
for ticker_id, stopped_at in stop_result.all()
|
||||
if stopped_at is not None and stopped_at.date() <= session_cutoff
|
||||
if closed_before is not None:
|
||||
stmt = stmt.where(PaperTrade.closed_at <= closed_before)
|
||||
result = await db.execute(stmt)
|
||||
latest: dict[int, PaperTrade] = {}
|
||||
for trade in result.scalars():
|
||||
latest.setdefault(trade.ticker_id, trade)
|
||||
return latest
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
if not stop_dates:
|
||||
return {}
|
||||
|
||||
benchmark_result = await db.execute(
|
||||
select(BenchmarkPrice.date)
|
||||
.where(
|
||||
BenchmarkPrice.symbol == BENCHMARK_SYMBOL,
|
||||
BenchmarkPrice.date <= session_cutoff,
|
||||
)
|
||||
.order_by(BenchmarkPrice.date.asc())
|
||||
)
|
||||
benchmark_dates = [row[0] for row in benchmark_result.all()]
|
||||
|
||||
lockdowns: dict[int, int] = {}
|
||||
fallback_stops: dict[int, date] = {}
|
||||
first_benchmark_date = benchmark_dates[0] if benchmark_dates else None
|
||||
for ticker_id, stop_date in stop_dates.items():
|
||||
completed = sum(day > stop_date for day in benchmark_dates)
|
||||
if completed >= sessions:
|
||||
continue
|
||||
if first_benchmark_date is not None and first_benchmark_date <= stop_date:
|
||||
lockdowns[ticker_id] = sessions - completed
|
||||
else:
|
||||
# The benchmark table starts after this stop (or is empty), so it
|
||||
# cannot prove how many sessions elapsed. Resolve only this ticker
|
||||
# against its own bars instead of using universe-wide dates.
|
||||
fallback_stops[ticker_id] = stop_date
|
||||
|
||||
if fallback_stops:
|
||||
own_session_result = await db.execute(
|
||||
select(OHLCVRecord.ticker_id, OHLCVRecord.date)
|
||||
.where(
|
||||
OHLCVRecord.ticker_id.in_(fallback_stops),
|
||||
OHLCVRecord.date > min(fallback_stops.values()),
|
||||
OHLCVRecord.date <= session_cutoff,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
own_dates: dict[int, set[date]] = defaultdict(set)
|
||||
for ticker_id, market_date in own_session_result.all():
|
||||
own_dates[ticker_id].add(market_date)
|
||||
for ticker_id, stop_date in fallback_stops.items():
|
||||
completed = sum(day > stop_date for day in own_dates[ticker_id])
|
||||
if completed < sessions:
|
||||
lockdowns[ticker_id] = sessions - completed
|
||||
|
||||
return lockdowns
|
||||
|
||||
|
||||
async def get_reentry_lockdown_ticker_ids(
|
||||
async def observe_reentry_gate_transitions(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
as_of: date | None = None,
|
||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
||||
evaluated_ticker_ids: Iterable[int],
|
||||
qualified_ticker_ids: Iterable[int],
|
||||
observed_at: datetime | None = None,
|
||||
) -> set[int]:
|
||||
"""Compatibility wrapper for callers that only need blocked ticker ids."""
|
||||
return set(
|
||||
await get_reentry_lockdowns(
|
||||
db,
|
||||
as_of=as_of,
|
||||
sessions=sessions,
|
||||
)
|
||||
)
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user