11export const FORENSIC_HORIZONS = Object . freeze ( [ 3 , 6 , 12 , 24 , 42 ] ) ;
2+ export const FORENSIC_ATR_THRESHOLDS = Object . freeze ( [ 0.25 , 0.5 , 1 ] ) ;
23const COMMISSION = 0.001 ;
34const 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+
4164export 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