Skip to content

Commit b385cf6

Browse files
committed
fix: make forensic excursions conservative and add ATR opportunity thresholds
1 parent d1e0b9e commit b385cf6

1 file changed

Lines changed: 133 additions & 16 deletions

File tree

research/regime-trend-v1/forensic-tools.mjs

Lines changed: 133 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const FORENSIC_HORIZONS = Object.freeze([3, 6, 12, 24, 42]);
2+
export const FORENSIC_ATR_THRESHOLDS = Object.freeze([0.25, 0.5, 1]);
23
const COMMISSION = 0.001;
34
const SLIPPAGE = 0.0005;
45

@@ -38,6 +39,28 @@ export function classifyCounterfactual(longReturn, shortReturn) {
3839
return "NO_TRADE";
3940
}
4041

42+
export function classifyAtrOpportunity(longNetAtr, shortNetAtr, thresholdAtr) {
43+
if (!Number.isFinite(thresholdAtr) || thresholdAtr <= 0) {
44+
throw new Error("ATR opportunity threshold must be positive");
45+
}
46+
const longQualifies = longNetAtr >= thresholdAtr;
47+
const shortQualifies = shortNetAtr >= thresholdAtr;
48+
if (longQualifies && shortQualifies) {
49+
throw new Error("Both long and short cannot reach the same positive ATR threshold");
50+
}
51+
if (shortQualifies) return "SHORT_REVERSAL";
52+
if (longQualifies) return "LONG_RECOVERY";
53+
return "NO_TRADE";
54+
}
55+
56+
function maxOrFallback(values, fallback) {
57+
return values.length > 0 ? Math.max(...values) : fallback;
58+
}
59+
60+
function minOrFallback(values, fallback) {
61+
return values.length > 0 ? Math.min(...values) : fallback;
62+
}
63+
4164
export function analyzeTradePath(trade, candles) {
4265
const byTimestamp = new Map(candles.map((candle, index) => [candle.timestamp, index]));
4366
const entryIndex = byTimestamp.get(trade.entry_timestamp);
@@ -46,28 +69,53 @@ export function analyzeTradePath(trade, candles) {
4669
throw new Error(`Trade timestamps not found for ${trade.symbol}`);
4770
}
4871

49-
const path = candles.slice(entryIndex, exitIndex + 1);
50-
const highest = Math.max(...path.map((candle) => candle.high));
51-
const lowest = Math.min(...path.map((candle) => candle.low));
52-
const mfe = highest - trade.entry_fill;
53-
const mae = trade.entry_fill - lowest;
72+
const optimisticPath = candles.slice(entryIndex, exitIndex + 1);
73+
const conservativePreExitPath = candles.slice(entryIndex, exitIndex);
74+
75+
const optimisticHighest = maxOrFallback(
76+
optimisticPath.map((candle) => candle.high),
77+
trade.entry_fill
78+
);
79+
const conservativeHighest = maxOrFallback(
80+
conservativePreExitPath.map((candle) => candle.high),
81+
trade.entry_fill
82+
);
83+
const conservativeLowest = minOrFallback(
84+
[
85+
...conservativePreExitPath.map((candle) => candle.low),
86+
Number.isFinite(trade.raw_exit_reference) ? trade.raw_exit_reference : trade.exit_fill
87+
].filter(Number.isFinite),
88+
trade.entry_fill
89+
);
90+
91+
const mfeUpperBound = Math.max(0, optimisticHighest - trade.entry_fill);
92+
const mfe = Math.max(0, conservativeHighest - trade.entry_fill);
93+
const mae = Math.max(0, trade.entry_fill - conservativeLowest);
94+
const mfeAtrUpperBound = mfeUpperBound / trade.entry_atr;
5495
const mfeAtr = mfe / trade.entry_atr;
5596
const maeAtr = mae / trade.entry_atr;
5697
const captureRatio = mfe > 0 ? trade.net_pnl / (mfe / trade.entry_fill) : null;
5798

5899
return {
59100
entry_index: entryIndex,
60101
exit_index: exitIndex,
102+
mfe_upper_bound: mfeUpperBound,
61103
mfe,
62104
mae,
105+
mfe_atr_upper_bound: mfeAtrUpperBound,
63106
mfe_atr: mfeAtr,
64107
mae_atr: maeAtr,
65108
capture_ratio: captureRatio,
66109
gave_back_favorable_excursion: trade.net_pnl < 0 && mfeAtr >= 1
67110
};
68111
}
69112

70-
export function analyzePostExit(trade, candles, horizons = FORENSIC_HORIZONS) {
113+
export function analyzePostExit(
114+
trade,
115+
candles,
116+
horizons = FORENSIC_HORIZONS,
117+
thresholds = FORENSIC_ATR_THRESHOLDS
118+
) {
71119
const byTimestamp = new Map(candles.map((candle, index) => [candle.timestamp, index]));
72120
const exitIndex = byTimestamp.get(trade.exit_timestamp);
73121
if (exitIndex === undefined) throw new Error(`Exit timestamp not found for ${trade.symbol}`);
@@ -87,25 +135,83 @@ export function analyzePostExit(trade, candles, horizons = FORENSIC_HORIZONS) {
87135
const horizonCandle = candles[horizonIndex];
88136
const longReturn = netLongReturn(entryCandle.open, horizonCandle.open);
89137
const shortReturn = netShortReturn(entryCandle.open, horizonCandle.open);
90-
const window = candles.slice(counterfactualEntryIndex, horizonIndex + 1);
91-
const lowest = Math.min(...window.map((candle) => candle.low));
92-
const highest = Math.max(...window.map((candle) => candle.high));
138+
const excursionWindow = candles.slice(counterfactualEntryIndex, horizonIndex);
139+
const lowest = minOrFallback(excursionWindow.map((candle) => candle.low), entryCandle.open);
140+
const highest = maxOrFallback(excursionWindow.map((candle) => candle.high), entryCandle.open);
141+
const downwardExcursionAtr = Math.max(0, entryCandle.open - lowest) / trade.entry_atr;
142+
const upwardExcursionAtr = Math.max(0, highest - entryCandle.open) / trade.entry_atr;
143+
const longNetAtr = longReturn * entryCandle.open / trade.entry_atr;
144+
const shortNetAtr = shortReturn * entryCandle.open / trade.entry_atr;
145+
146+
const thresholdClassifications = Object.fromEntries(
147+
thresholds.map((threshold) => [
148+
String(threshold),
149+
classifyAtrOpportunity(longNetAtr, shortNetAtr, threshold)
150+
])
151+
);
93152

94153
results[horizon] = {
95154
entry_timestamp: entryCandle.timestamp,
96155
exit_timestamp: horizonCandle.timestamp,
97156
long_net_return: longReturn,
98157
short_net_return: shortReturn,
158+
long_net_atr: longNetAtr,
159+
short_net_atr: shortNetAtr,
99160
classification: classifyCounterfactual(longReturn, shortReturn),
100-
downward_excursion_atr: (entryCandle.open - lowest) / trade.entry_atr,
101-
upward_excursion_atr: (highest - entryCandle.open) / trade.entry_atr
161+
threshold_classifications: thresholdClassifications,
162+
downward_excursion_atr: downwardExcursionAtr,
163+
upward_excursion_atr: upwardExcursionAtr,
164+
long_reward_to_adverse_excursion:
165+
longNetAtr > 0 && downwardExcursionAtr > 0 ? longNetAtr / downwardExcursionAtr : null,
166+
short_reward_to_adverse_excursion:
167+
shortNetAtr > 0 && upwardExcursionAtr > 0 ? shortNetAtr / upwardExcursionAtr : null
102168
};
103169
}
104170

105171
return results;
106172
}
107173

108-
export function summarizeForensicRows(rows, horizons = FORENSIC_HORIZONS) {
174+
function summarizeThreshold(eligible, threshold) {
175+
const key = String(threshold);
176+
const counts = { SHORT_REVERSAL: 0, LONG_RECOVERY: 0, NO_TRADE: 0 };
177+
for (const item of eligible) counts[item.threshold_classifications[key]] += 1;
178+
179+
const shortRows = eligible.filter(
180+
(item) => item.threshold_classifications[key] === "SHORT_REVERSAL"
181+
);
182+
const longRows = eligible.filter(
183+
(item) => item.threshold_classifications[key] === "LONG_RECOVERY"
184+
);
185+
186+
return {
187+
threshold_atr: threshold,
188+
eligible: eligible.length,
189+
short_reversal_count: counts.SHORT_REVERSAL,
190+
short_reversal_rate: eligible.length ? counts.SHORT_REVERSAL / eligible.length : null,
191+
long_recovery_count: counts.LONG_RECOVERY,
192+
long_recovery_rate: eligible.length ? counts.LONG_RECOVERY / eligible.length : null,
193+
no_trade_count: counts.NO_TRADE,
194+
no_trade_rate: eligible.length ? counts.NO_TRADE / eligible.length : null,
195+
median_short_adverse_excursion_atr: median(
196+
shortRows.map((item) => item.upward_excursion_atr)
197+
),
198+
median_long_adverse_excursion_atr: median(
199+
longRows.map((item) => item.downward_excursion_atr)
200+
),
201+
median_short_reward_to_adverse_excursion: median(
202+
shortRows.map((item) => item.short_reward_to_adverse_excursion).filter(Number.isFinite)
203+
),
204+
median_long_reward_to_adverse_excursion: median(
205+
longRows.map((item) => item.long_reward_to_adverse_excursion).filter(Number.isFinite)
206+
)
207+
};
208+
}
209+
210+
export function summarizeForensicRows(
211+
rows,
212+
horizons = FORENSIC_HORIZONS,
213+
thresholds = FORENSIC_ATR_THRESHOLDS
214+
) {
109215
const summary = {};
110216
for (const horizon of horizons) {
111217
const eligible = rows.map((row) => row.counterfactuals[horizon]).filter(Boolean);
@@ -119,10 +225,21 @@ export function summarizeForensicRows(rows, horizons = FORENSIC_HORIZONS) {
119225
long_recovery_rate: eligible.length ? counts.LONG_RECOVERY / eligible.length : null,
120226
no_trade_count: counts.NO_TRADE,
121227
no_trade_rate: eligible.length ? counts.NO_TRADE / eligible.length : null,
122-
average_short_net_return: eligible.length ? eligible.reduce((sum, item) => sum + item.short_net_return, 0) / eligible.length : null,
123-
average_long_net_return: eligible.length ? eligible.reduce((sum, item) => sum + item.long_net_return, 0) / eligible.length : null,
124-
median_downward_excursion_atr: median(eligible.map((item) => item.downward_excursion_atr)),
125-
median_upward_excursion_atr: median(eligible.map((item) => item.upward_excursion_atr))
228+
average_short_net_return: eligible.length
229+
? eligible.reduce((sum, item) => sum + item.short_net_return, 0) / eligible.length
230+
: null,
231+
average_long_net_return: eligible.length
232+
? eligible.reduce((sum, item) => sum + item.long_net_return, 0) / eligible.length
233+
: null,
234+
median_downward_excursion_atr: median(
235+
eligible.map((item) => item.downward_excursion_atr)
236+
),
237+
median_upward_excursion_atr: median(
238+
eligible.map((item) => item.upward_excursion_atr)
239+
),
240+
thresholds: Object.fromEntries(
241+
thresholds.map((threshold) => [String(threshold), summarizeThreshold(eligible, threshold)])
242+
)
126243
};
127244
}
128245
return summary;

0 commit comments

Comments
 (0)