fix: harden post-stop reentry lockdown
This commit is contained in:
@@ -66,14 +66,19 @@ function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
||||
return { pct, r, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* The only state with no tradeable setup left: price has gone through the stop.
|
||||
* Returns null when there's no live price.
|
||||
*/
|
||||
type NotActionableState =
|
||||
| { kind: 'lockdown'; remainingSessions: number }
|
||||
| { kind: 'invalidated' }
|
||||
| null;
|
||||
|
||||
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 (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
||||
return { invalidated: true };
|
||||
return { kind: 'invalidated' } satisfies NotActionableState;
|
||||
}
|
||||
|
||||
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 honorsTarget = exitPlan?.honorsTarget ?? false;
|
||||
|
||||
// Only price through the stop leaves no tradeable setup.
|
||||
const notActionable = notActionableState(setup, currentPrice) != null;
|
||||
|
||||
const createTrade = useCreatePaperTrade();
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
||||
@@ -268,7 +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();
|
||||
return (
|
||||
<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">
|
||||
{preferredInactive ? (
|
||||
<span className="text-sm font-semibold text-gray-400">
|
||||
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop)</span>
|
||||
{preferredInactive.kind === '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>
|
||||
) : (() => {
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
// Live R:R from current price — drops setups whose price has already run
|
||||
// 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).
|
||||
*/
|
||||
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) {
|
||||
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;
|
||||
strategy_rank?: number | null;
|
||||
volatility_percentile?: number | null;
|
||||
reentry_lockdown_remaining_sessions?: number | null;
|
||||
context_as_of?: TradeSetupContextAsOf | null;
|
||||
recommendation_summary?: RecommendationSummary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user