/** * What actually closes a trade. * * The setup's `target` is NOT an exit under the production policy: it is a * screening artifact — the headline Gate Target Ladder proposal, used to * compute the R:R and * probability that admit the setup through the activation gate. The live exit * (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing` * closes on the initial stop, a trailing stop, or the max hold. * * This module derives the real plan so the UI can show it instead of implying * a take-profit that will never fire. See docs/research/sr-levels-and-exits.md. */ import type { ExitPolicy, TradeSetup } from './types'; /** * Stop width used when the scanner builds a setup: stop = entry ∓ 1.5 × ATR * (`rr_scanner_service.scan_symbol`, and `backtest_service.ATR_MULTIPLIER`). * Lets us recover ATR from a setup without another round trip: * ATR = |entry − stop| / 1.5 * Guarded by test_prod_strategy_parity.py so a backend change can't silently * desync this. */ export const SETUP_STOP_ATR_MULTIPLIER = 1.5; export interface ExitPlan { mode: ExitPolicy['mode']; /** Does the setup's target actually close the trade? Only when mode === 'target'. */ honorsTarget: boolean; /** Distance from entry to the initial stop, i.e. 1R per share. */ riskPerShare: number; initialStop: number; /** Trailing-stop width in price, once the trail is active (atr_trailing only). */ trailWidth: number | null; /** * Price the trade must reach before the trailing stop rises above the initial * stop and takes over. Below this, the initial stop is what's protecting you. */ trailTakesOverAt: number | null; /** Trail width expressed in R — the intuitive "how much give-back". */ trailWidthR: number | null; maxHoldDays: number; headline: string; } /** * Derive the real exit plan for a setup under the live policy. * Returns null when the setup has no usable risk distance. */ export function deriveExitPlan(setup: TradeSetup, policy?: ExitPolicy): ExitPlan | null { const isLong = setup.direction === 'long'; const riskPerShare = Math.abs(setup.entry_price - setup.stop_loss); if (!(riskPerShare > 0)) return null; // Fall back to the shipped defaults when the policy hasn't loaded yet, so the // card never renders a blank or (worse) a target-based plan. const mode = policy?.mode ?? 'atr_trailing'; const maxHoldDays = policy?.hold_days ?? 30; const atrMultiplier = policy?.atr_multiplier ?? 3; const atr = riskPerShare / SETUP_STOP_ATR_MULTIPLIER; if (mode === 'atr_trailing') { const trailWidth = atrMultiplier * atr; // The trail only bites once it clears the initial stop: // highestClose − trailWidth > stop ⇔ highestClose > entry ± (trailWidth − R) const takeoverOffset = trailWidth - riskPerShare; const trailTakesOverAt = isLong ? setup.entry_price + takeoverOffset : setup.entry_price - takeoverOffset; return { mode, honorsTarget: false, riskPerShare, initialStop: setup.stop_loss, trailWidth, trailTakesOverAt, trailWidthR: trailWidth / riskPerShare, maxHoldDays, headline: `${atrMultiplier}× ATR trailing stop · max ${maxHoldDays} trading days`, }; } if (mode === 'trailing') { const trailWidth = (setup.entry_price * (policy?.trailing_pct ?? 12)) / 100; return { mode, honorsTarget: false, riskPerShare, initialStop: setup.stop_loss, trailWidth, trailTakesOverAt: null, trailWidthR: trailWidth / riskPerShare, maxHoldDays, headline: `${policy?.trailing_pct ?? 12}% trailing stop · max ${maxHoldDays} trading days`, }; } if (mode === 'target') { return { mode, honorsTarget: true, riskPerShare, initialStop: setup.stop_loss, trailWidth: null, trailTakesOverAt: null, trailWidthR: null, maxHoldDays, headline: 'Take profit at the selected level, or exit at the stop', }; } // 'time' return { mode, honorsTarget: false, riskPerShare, initialStop: setup.stop_loss, trailWidth: null, trailTakesOverAt: null, trailWidthR: null, maxHoldDays, headline: `Hold to the stop or ${maxHoldDays} trading days — no target, no trail`, }; } /** How far price has run from the scan entry, in R. Sign is direction-aware. */ export function driftInR(setup: TradeSetup, currentPrice: number): number | null { const risk = Math.abs(setup.entry_price - setup.stop_loss); if (!(risk > 0)) return null; const moved = setup.direction === 'long' ? currentPrice - setup.entry_price : setup.entry_price - currentPrice; return moved / risk; }