fix: harden post-stop reentry lockdown
This commit is contained in:
@@ -99,7 +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,
|
include_reentry_lockdown=True,
|
||||||
)
|
)
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -59,5 +59,6 @@ class TradeSetupResponse(BaseModel):
|
|||||||
momentum_percentile: float | None = None
|
momentum_percentile: float | None = None
|
||||||
strategy_rank: float | None = None
|
strategy_rank: float | None = None
|
||||||
volatility_percentile: float | None = None
|
volatility_percentile: float | None = None
|
||||||
|
reentry_lockdown_remaining_sessions: int | None = None
|
||||||
context_as_of: TradeSetupContextAsOfResponse | None = None
|
context_as_of: TradeSetupContextAsOfResponse | None = None
|
||||||
recommendation_summary: RecommendationSummaryResponse | None = None
|
recommendation_summary: RecommendationSummaryResponse | None = None
|
||||||
|
|||||||
@@ -1356,7 +1356,7 @@ 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,
|
reentry_cooldown_sessions: int = 0,
|
||||||
initial_stop_refresh_fn: (
|
initial_stop_refresh_fn: (
|
||||||
Callable[[str, int, float, dict, Any], float | None] | None
|
Callable[[str, int, float, dict, Any], float | None] | None
|
||||||
) = None,
|
) = None,
|
||||||
@@ -1380,7 +1380,7 @@ 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.
|
||||||
``reentry_cooldown_days`` blocks a ticker for that many market sessions
|
``reentry_cooldown_sessions`` blocks a ticker for that many market sessions
|
||||||
after an initial-stop loss. Profitable trailing-stop exits do not trigger
|
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
|
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
|
stop when the active initial stop is touched; the replacement is still
|
||||||
@@ -1541,7 +1541,7 @@ def _simulate_portfolio(
|
|||||||
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())
|
||||||
|
|
||||||
cooldown_days = max(0, int(reentry_cooldown_days))
|
cooldown_sessions = max(0, int(reentry_cooldown_sessions))
|
||||||
for calendar_index, o in enumerate(calendar):
|
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):
|
||||||
@@ -1579,8 +1579,8 @@ def _simulate_portfolio(
|
|||||||
if not survived_refresh:
|
if not survived_refresh:
|
||||||
fill = min(pos["stop"], bar.open)
|
fill = min(pos["stop"], bar.open)
|
||||||
closed_pos = _close_trade(sym, fill, reason)
|
closed_pos = _close_trade(sym, fill, reason)
|
||||||
if reason == "stop" and cooldown_days:
|
if reason == "stop" and cooldown_sessions:
|
||||||
cooldown_until_index[sym] = calendar_index + cooldown_days
|
cooldown_until_index[sym] = calendar_index + cooldown_sessions
|
||||||
if reason == "stop" and post_stop_reentry_fn is not None:
|
if reason == "stop" and post_stop_reentry_fn is not None:
|
||||||
post_stop_events += 1
|
post_stop_events += 1
|
||||||
post_stop_states[sym] = {
|
post_stop_states[sym] = {
|
||||||
@@ -1833,8 +1833,8 @@ 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:
|
if cooldown_sessions:
|
||||||
result["reentry_cooldown_days"] = cooldown_days
|
result["reentry_cooldown_sessions"] = cooldown_sessions
|
||||||
result["skipped_cooldown"] = skipped_cooldown
|
result["skipped_cooldown"] = skipped_cooldown
|
||||||
if initial_stop_refresh_fn is not None:
|
if initial_stop_refresh_fn is not None:
|
||||||
result["stop_refresh_attempts"] = stop_refresh_attempts
|
result["stop_refresh_attempts"] = stop_refresh_attempts
|
||||||
@@ -2403,7 +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,
|
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||||
start_date=sweep_start,
|
start_date=sweep_start,
|
||||||
)
|
)
|
||||||
if sim is None:
|
if sim is None:
|
||||||
@@ -2516,7 +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,
|
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
@@ -2592,7 +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,
|
reentry_cooldown_sessions=reentry_lockdown_sessions,
|
||||||
start_date=start,
|
start_date=start,
|
||||||
include_curve=True,
|
include_curve=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.services.outcome_service import (
|
|||||||
Bar,
|
Bar,
|
||||||
evaluate_setup_against_bars,
|
evaluate_setup_against_bars,
|
||||||
)
|
)
|
||||||
|
from app.services.trade_policy import get_reentry_lockdowns
|
||||||
|
|
||||||
# Exit policy for OPEN paper trades (auto-close). Production defaults to the
|
# 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
|
# July 2026 promoted strategy: initial stop + 3x ATR trailing stop, with a max
|
||||||
@@ -318,6 +319,13 @@ async def create_trade(
|
|||||||
raise ValidationError("shares and entry_price must be positive")
|
raise ValidationError("shares and entry_price must be positive")
|
||||||
|
|
||||||
ticker = await _get_ticker(db, symbol)
|
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"
|
||||||
|
raise ValidationError(
|
||||||
|
f"{ticker.symbol} is in a post-stop re-entry lockdown: "
|
||||||
|
f"{remaining_sessions} market {suffix} remaining"
|
||||||
|
)
|
||||||
trade = PaperTrade(
|
trade = PaperTrade(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
ticker_id=ticker.id,
|
ticker_id=ticker.id,
|
||||||
|
|||||||
@@ -29,7 +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.trade_policy import get_reentry_lockdowns
|
||||||
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,
|
||||||
@@ -773,6 +773,7 @@ async def get_trade_setups(
|
|||||||
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,
|
exclude_reentry_lockdown_tickers: bool = False,
|
||||||
|
include_reentry_lockdown: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered.
|
"""Get latest stored trade setups, optionally filtered.
|
||||||
|
|
||||||
@@ -797,6 +798,7 @@ async def get_trade_setups(
|
|||||||
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()
|
excluded_ticker_ids: set[int] = set()
|
||||||
|
reentry_lockdowns: dict[int, int] = {}
|
||||||
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)
|
||||||
@@ -806,8 +808,10 @@ async def get_trade_setups(
|
|||||||
excluded_ticker_ids.update(
|
excluded_ticker_ids.update(
|
||||||
ticker_id for ticker_id, in open_trade_result.all()
|
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:
|
if exclude_reentry_lockdown_tickers:
|
||||||
excluded_ticker_ids.update(await get_reentry_lockdown_ticker_ids(db))
|
excluded_ticker_ids.update(reentry_lockdowns)
|
||||||
if excluded_ticker_ids:
|
if excluded_ticker_ids:
|
||||||
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
stmt = stmt.where(~TradeSetup.ticker_id.in_(excluded_ticker_ids))
|
||||||
|
|
||||||
@@ -862,6 +866,15 @@ async def get_trade_setups(
|
|||||||
),
|
),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
if include_reentry_lockdown:
|
||||||
|
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
|
||||||
|
)
|
||||||
return rows_out
|
return rows_out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime, time, timezone
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.paper_trade import PaperTrade
|
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
|
# A ticker stopped at its initial stop may qualify again immediately, but the
|
||||||
# July 2026 event study showed that waiting five market sessions materially
|
# July 2026 event study showed that waiting five market sessions materially
|
||||||
@@ -17,52 +20,101 @@ from app.models.paper_trade import PaperTrade
|
|||||||
REENTRY_LOCKDOWN_SESSIONS = 5
|
REENTRY_LOCKDOWN_SESSIONS = 5
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reentry_lockdowns(
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
PaperTrade.status == "closed",
|
||||||
|
PaperTrade.close_reason == "stop",
|
||||||
|
PaperTrade.closed_at.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(PaperTrade.ticker_id)
|
||||||
|
)
|
||||||
|
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 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 get_reentry_lockdown_ticker_ids(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
as_of: date | None = None,
|
as_of: date | None = None,
|
||||||
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
sessions: int = REENTRY_LOCKDOWN_SESSIONS,
|
||||||
) -> set[int]:
|
) -> set[int]:
|
||||||
"""Ticker ids still inside the post-initial-stop market-session lockdown.
|
"""Compatibility wrapper for callers that only need blocked ticker ids."""
|
||||||
|
return set(
|
||||||
The market calendar is derived from stored OHLCV dates, not calendar days.
|
await get_reentry_lockdowns(
|
||||||
A stop on session D is released once five later stored sessions exist. Only
|
db,
|
||||||
an initial-stop close (``close_reason == "stop"``) starts the lockdown;
|
as_of=as_of,
|
||||||
trailing, target, time, and manual exits do not.
|
sessions=sessions,
|
||||||
"""
|
|
||||||
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()}
|
|
||||||
|
|||||||
@@ -66,14 +66,19 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
|||||||
return { pct, r, status };
|
return { pct, r, status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type NotActionableState =
|
||||||
* The only state with no tradeable setup left: price has gone through the stop.
|
| { kind: 'lockdown'; remainingSessions: number }
|
||||||
* Returns null when there's no live price.
|
| { kind: 'invalidated' }
|
||||||
*/
|
| null;
|
||||||
|
|
||||||
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
||||||
|
const remainingSessions = setup.reentry_lockdown_remaining_sessions ?? 0;
|
||||||
|
if (remainingSessions > 0) {
|
||||||
|
return { kind: 'lockdown', remainingSessions } satisfies NotActionableState;
|
||||||
|
}
|
||||||
if (currentPrice == null) return null;
|
if (currentPrice == null) return null;
|
||||||
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||||
return { invalidated: true };
|
return { kind: 'invalidated' } satisfies NotActionableState;
|
||||||
}
|
}
|
||||||
|
|
||||||
function riskClass(risk: TradeSetup['risk_level']) {
|
function riskClass(risk: TradeSetup['risk_level']) {
|
||||||
@@ -218,9 +223,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
||||||
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||||
|
|
||||||
// Only price through the stop leaves no tradeable setup.
|
|
||||||
const notActionable = notActionableState(setup, currentPrice) != null;
|
|
||||||
|
|
||||||
const createTrade = useCreatePaperTrade();
|
const createTrade = useCreatePaperTrade();
|
||||||
const [taking, setTaking] = useState(false);
|
const [taking, setTaking] = useState(false);
|
||||||
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
||||||
@@ -268,7 +270,27 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (notActionable) {
|
const inactiveState = notActionableState(setup, currentPrice);
|
||||||
|
if (inactiveState?.kind === 'lockdown') {
|
||||||
|
const remaining = inactiveState.remainingSessions;
|
||||||
|
return (
|
||||||
|
<div data-direction={setup.direction} className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<DirTag direction={setup.direction} />
|
||||||
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-amber-300">post-stop lockdown</span>
|
||||||
|
<span className="num ml-auto text-xs text-gray-500">
|
||||||
|
{remaining} market session{remaining === 1 ? '' : 's'} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
||||||
|
This setup remains visible for context but cannot be marked as taken. Once the lockdown expires,
|
||||||
|
the scanner recalculates the normal gate before it can become actionable again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactiveState?.kind === 'invalidated') {
|
||||||
const dir = setup.direction.toUpperCase();
|
const dir = setup.direction.toUpperCase();
|
||||||
return (
|
return (
|
||||||
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
|
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
|
||||||
@@ -617,7 +639,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{preferredInactive ? (
|
{preferredInactive ? (
|
||||||
<span className="text-sm font-semibold text-gray-400">
|
<span className="text-sm font-semibold text-gray-400">
|
||||||
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop)</span>
|
{preferredInactive.kind === 'lockdown' ? (
|
||||||
|
<>
|
||||||
|
Re-entry paused{' '}
|
||||||
|
<span className="font-normal text-gray-500">
|
||||||
|
({preferredInactive.remainingSessions} market session{preferredInactive.remainingSessions === 1 ? '' : 's'} remaining after stop)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
No current setup{' '}
|
||||||
|
<span className="font-normal text-gray-500">
|
||||||
|
(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const reasoning = summary?.reasoning ?? '';
|
const reasoning = summary?.reasoning ?? '';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function liveRiskReward(setup: TradeSetup, currentPrice: number): number
|
|||||||
* app/services/qualification.py — keep the two in sync.
|
* app/services/qualification.py — keep the two in sync.
|
||||||
*/
|
*/
|
||||||
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
|
||||||
|
if ((setup.reentry_lockdown_remaining_sessions ?? 0) > 0) return false;
|
||||||
if (setup.rr_ratio < config.min_rr) return false;
|
if (setup.rr_ratio < config.min_rr) return false;
|
||||||
// Live R:R from current price — drops setups whose price has already run
|
// Live R:R from current price — drops setups whose price has already run
|
||||||
// toward target (reward consumed) or through the stop.
|
// toward target (reward consumed) or through the stop.
|
||||||
@@ -79,6 +80,10 @@ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boo
|
|||||||
* qualifiesSetup rule-for-rule (keep the order in sync).
|
* qualifiesSetup rule-for-rule (keep the order in sync).
|
||||||
*/
|
*/
|
||||||
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): string | null {
|
||||||
|
const lockdownRemaining = setup.reentry_lockdown_remaining_sessions ?? 0;
|
||||||
|
if (lockdownRemaining > 0) {
|
||||||
|
return `post-stop lockdown · ${lockdownRemaining} session${lockdownRemaining === 1 ? '' : 's'} remaining`;
|
||||||
|
}
|
||||||
if (setup.rr_ratio < config.min_rr) {
|
if (setup.rr_ratio < config.min_rr) {
|
||||||
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
return `R:R ${setup.rr_ratio.toFixed(1)} below gate ${config.min_rr.toFixed(1)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export interface TradeSetup {
|
|||||||
momentum_percentile?: number | null;
|
momentum_percentile?: number | null;
|
||||||
strategy_rank?: number | null;
|
strategy_rank?: number | null;
|
||||||
volatility_percentile?: number | null;
|
volatility_percentile?: number | null;
|
||||||
|
reentry_lockdown_remaining_sessions?: number | null;
|
||||||
context_as_of?: TradeSetupContextAsOf | null;
|
context_as_of?: TradeSetupContextAsOf | null;
|
||||||
recommendation_summary?: RecommendationSummary;
|
recommendation_summary?: RecommendationSummary;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ async def _main() -> None:
|
|||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
exit_policy,
|
exit_policy,
|
||||||
hold_days,
|
hold_days,
|
||||||
reentry_cooldown_days=5,
|
reentry_cooldown_sessions=5,
|
||||||
**sim_kwargs,
|
**sim_kwargs,
|
||||||
)
|
)
|
||||||
cooldown_10 = bt._simulate_portfolio(
|
cooldown_10 = bt._simulate_portfolio(
|
||||||
@@ -339,7 +339,7 @@ async def _main() -> None:
|
|||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
exit_policy,
|
exit_policy,
|
||||||
hold_days,
|
hold_days,
|
||||||
reentry_cooldown_days=10,
|
reentry_cooldown_sessions=10,
|
||||||
**sim_kwargs,
|
**sim_kwargs,
|
||||||
)
|
)
|
||||||
refresher = GateStopRefresher(
|
refresher = GateStopRefresher(
|
||||||
|
|||||||
@@ -590,19 +590,47 @@ class TestSimulatePortfolio:
|
|||||||
None,
|
None,
|
||||||
"hold",
|
"hold",
|
||||||
30,
|
30,
|
||||||
reentry_cooldown_days=5,
|
reentry_cooldown_sessions=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert baseline is not None and baseline["trades"] == 2
|
assert baseline is not None and baseline["trades"] == 2
|
||||||
assert cooldown is not None and cooldown["trades"] == 1
|
assert cooldown is not None and cooldown["trades"] == 1
|
||||||
assert cooldown["skipped_cooldown"] == 1
|
assert cooldown["skipped_cooldown"] == 1
|
||||||
assert cooldown["reentry_cooldown_days"] == 5
|
assert cooldown["reentry_cooldown_sessions"] == 5
|
||||||
|
|
||||||
|
def test_initial_stop_cooldown_unlocks_exactly_after_session_five(self):
|
||||||
|
closes = [100.0, 94.0, 96.0, 96.0, 96.0, 96.0, 97.0, 98.0]
|
||||||
|
prices = {"AAA": _sim_prices(self.ORD, closes)}
|
||||||
|
candidates = [
|
||||||
|
_sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0),
|
||||||
|
# Four completed sessions since the stop: still locked.
|
||||||
|
_sim_cand("AAA", self.ORD + 5, entry=96.0, stop=90.0, target=115.0),
|
||||||
|
# Five completed sessions since the stop: first permitted re-entry.
|
||||||
|
_sim_cand("AAA", self.ORD + 6, entry=97.0, stop=90.0, target=118.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
30,
|
||||||
|
reentry_cooldown_sessions=5,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["trades"] == 2
|
||||||
|
assert sim["skipped_cooldown"] == 1
|
||||||
|
assert sim["trade_details"][1]["entry_date"] == date.fromordinal(
|
||||||
|
self.ORD + 6
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
def test_production_monitor_applies_live_reentry_lockdown(self, monkeypatch):
|
||||||
def fake_simulator(*_args, **kwargs):
|
def fake_simulator(*_args, **kwargs):
|
||||||
return {
|
return {
|
||||||
"trades": 0,
|
"trades": 0,
|
||||||
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_days", 0),
|
"applied_reentry_lockdown": kwargs.get("reentry_cooldown_sessions", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
monkeypatch.setattr(bt, "_simulate_portfolio", fake_simulator)
|
||||||
|
|||||||
@@ -48,6 +48,74 @@ async def test_create_and_list_open(session):
|
|||||||
assert row["current_price"] == 110.0 # marked to the latest close
|
assert row["current_price"] == 110.0 # marked to the latest close
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_trade_enforces_post_stop_lockdown_at_service_boundary(session):
|
||||||
|
blocked_id = await _seed(session, "LOCKQ", close=100.0)
|
||||||
|
released_id = await _seed(session, "FREEQ", close=100.0)
|
||||||
|
today = date.today()
|
||||||
|
market_sessions = [
|
||||||
|
today - timedelta(days=8),
|
||||||
|
today - timedelta(days=7),
|
||||||
|
today - timedelta(days=6),
|
||||||
|
today - timedelta(days=3),
|
||||||
|
today - timedelta(days=2),
|
||||||
|
today - timedelta(days=1),
|
||||||
|
]
|
||||||
|
for market_date in market_sessions:
|
||||||
|
session.add(BenchmarkPrice(symbol="SPY", date=market_date, close=400.0))
|
||||||
|
|
||||||
|
def stopped_trade(ticker_id: int, closed_on: date) -> 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="stop",
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
stopped_trade(blocked_id, market_sessions[1]),
|
||||||
|
stopped_trade(released_id, market_sessions[0]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="1 market session remaining"):
|
||||||
|
await svc.create_trade(
|
||||||
|
session,
|
||||||
|
1,
|
||||||
|
symbol="LOCKQ",
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
trade = await svc.create_trade(
|
||||||
|
session,
|
||||||
|
1,
|
||||||
|
symbol="FREEQ",
|
||||||
|
direction="long",
|
||||||
|
entry_price=100.0,
|
||||||
|
shares=10.0,
|
||||||
|
stop_loss=95.0,
|
||||||
|
target=115.0,
|
||||||
|
)
|
||||||
|
assert trade.ticker_id == released_id
|
||||||
|
|
||||||
|
|
||||||
async def test_close_uses_current_price(session):
|
async def test_close_uses_current_price(session):
|
||||||
await _seed(session, "AAA", close=112.0)
|
await _seed(session, "AAA", close=112.0)
|
||||||
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
trade = await svc.create_trade(session, 1, symbol="AAA", direction="long",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from hypothesis import given, settings, HealthCheck, strategies as st
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.benchmark_price import BenchmarkPrice
|
||||||
from app.models.ohlcv import OHLCVRecord
|
from app.models.ohlcv import OHLCVRecord
|
||||||
from app.models.paper_trade import PaperTrade
|
from app.models.paper_trade import PaperTrade
|
||||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||||
@@ -625,14 +626,30 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
db_session.add_all([blocked, released, trailing])
|
db_session.add_all([blocked, released, trailing])
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
|
|
||||||
# Six synthetic stored market sessions D0..D5. A stop on D0 has five
|
# Six SPY sessions D0..D5 form the canonical market calendar. A stop on
|
||||||
# later sessions and is released; a stop on D1 has only four and is not.
|
# D0 has five later sessions and is released; a stop on D1 has only four.
|
||||||
market_sessions = [today - timedelta(days=offset) for offset in range(5, -1, -1)]
|
market_sessions = [
|
||||||
|
today - timedelta(days=8),
|
||||||
|
today - timedelta(days=7),
|
||||||
|
today - timedelta(days=6),
|
||||||
|
today - timedelta(days=3),
|
||||||
|
today - timedelta(days=2),
|
||||||
|
today - timedelta(days=1),
|
||||||
|
]
|
||||||
for market_date in market_sessions:
|
for market_date in market_sessions:
|
||||||
|
db_session.add(
|
||||||
|
BenchmarkPrice(
|
||||||
|
symbol="SPY",
|
||||||
|
date=market_date,
|
||||||
|
close=400.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# A bar from an unrelated/scanner-specific calendar must not release the
|
||||||
|
# ticker one session early. The old universe-wide DISTINCT query did.
|
||||||
db_session.add(
|
db_session.add(
|
||||||
OHLCVRecord(
|
OHLCVRecord(
|
||||||
ticker_id=blocked.id,
|
ticker_id=blocked.id,
|
||||||
date=market_date,
|
date=today,
|
||||||
open=100.0,
|
open=100.0,
|
||||||
high=101.0,
|
high=101.0,
|
||||||
low=99.0,
|
low=99.0,
|
||||||
@@ -699,6 +716,14 @@ async def test_get_trade_setups_applies_five_session_initial_stop_lockdown(
|
|||||||
assert "STOP4" not in available_symbols
|
assert "STOP4" not in available_symbols
|
||||||
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
|
assert {"STOP5", "TRAILQ"}.issubset(available_symbols)
|
||||||
|
|
||||||
|
annotated = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="STOP4",
|
||||||
|
include_reentry_lockdown=True,
|
||||||
|
)
|
||||||
|
assert len(annotated) == 1
|
||||||
|
assert annotated[0]["reentry_lockdown_remaining_sessions"] == 1
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user