Files
signal-platform/frontend/src/lib/exitPlan.ts
T

136 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}