4040DEFAULT_START_DATE = "2018-01-01"
4141DEFAULT_MAX_PRICE_AGE_DAYS = 4
4242DEFAULT_ACTIVE_SIGNAL_DAYS = 10
43+ DEFAULT_REQUIRE_REBOUND_CONFIRMATION = True
44+ DEFAULT_CONFIRMATION_LOOKBACK_DAYS = 5
45+ DEFAULT_MIN_CONFIRMATION_TRADING_DAYS_AFTER_EVENT = 1
46+ DEFAULT_MIN_BENCHMARK_REBOUND_FROM_LOW = 0.015
47+ DEFAULT_MIN_ATTACK_REBOUND_FROM_LOW = 0.04
48+ DEFAULT_MIN_BENCHMARK_3D_RETURN = 0.0
4349HARD_DEFENSE_BREAK_BEAR_REGIONS = frozenset ({"iran_middle_east" })
4450
4551
@@ -80,6 +86,92 @@ def _active_recognized_events(
8086 return tuple (active )
8187
8288
89+ def _trading_day_distance (
90+ index : pd .DatetimeIndex ,
91+ * ,
92+ start_date : pd .Timestamp | None ,
93+ end_date : pd .Timestamp ,
94+ ) -> int | None :
95+ if start_date is None :
96+ return None
97+ try :
98+ start_pos = int (index .get_loc (pd .Timestamp (start_date ).normalize ()))
99+ end_pos = int (index .get_loc (pd .Timestamp (end_date ).normalize ()))
100+ except KeyError :
101+ return None
102+ return max (0 , end_pos - start_pos )
103+
104+
105+ def _build_rebound_confirmation (
106+ close : pd .DataFrame ,
107+ * ,
108+ signal_date : pd .Timestamp ,
109+ selected_event_signal_date : pd .Timestamp | None ,
110+ benchmark_symbol : str ,
111+ attack_symbol : str ,
112+ lookback_days : int ,
113+ min_trading_days_after_event : int ,
114+ min_benchmark_rebound_from_low : float ,
115+ min_attack_rebound_from_low : float ,
116+ min_benchmark_3d_return : float ,
117+ ) -> dict [str , Any ]:
118+ index = pd .DatetimeIndex (close .index ).sort_values ()
119+ trading_days_after_event = _trading_day_distance (
120+ index ,
121+ start_date = selected_event_signal_date ,
122+ end_date = signal_date ,
123+ )
124+ if signal_date not in index :
125+ return {
126+ "confirmed" : False ,
127+ "reason" : "signal date missing from price index" ,
128+ "trading_days_after_event" : trading_days_after_event ,
129+ }
130+
131+ signal_pos = int (index .get_loc (signal_date ))
132+ lookback_start = max (0 , signal_pos - max (1 , int (lookback_days )) + 1 )
133+ window_index = index [lookback_start : signal_pos + 1 ]
134+ benchmark = pd .to_numeric (close [benchmark_symbol ].reindex (window_index ), errors = "coerce" )
135+ attack = pd .to_numeric (close [attack_symbol ].reindex (window_index ), errors = "coerce" )
136+ benchmark_close = float (benchmark .iloc [- 1 ]) if benchmark .notna ().any () else float ("nan" )
137+ attack_close = float (attack .iloc [- 1 ]) if attack .notna ().any () else float ("nan" )
138+ benchmark_low = float (benchmark .min ()) if benchmark .notna ().any () else float ("nan" )
139+ attack_low = float (attack .min ()) if attack .notna ().any () else float ("nan" )
140+ benchmark_rebound_from_low = benchmark_close / benchmark_low - 1.0 if benchmark_low > 0 else float ("nan" )
141+ attack_rebound_from_low = attack_close / attack_low - 1.0 if attack_low > 0 else float ("nan" )
142+ if signal_pos >= 3 :
143+ benchmark_3d_base = float (close [benchmark_symbol ].iloc [signal_pos - 3 ])
144+ benchmark_3d_return = benchmark_close / benchmark_3d_base - 1.0 if benchmark_3d_base > 0 else float ("nan" )
145+ else :
146+ benchmark_3d_return = float ("nan" )
147+
148+ reasons : list [str ] = []
149+ if trading_days_after_event is None or trading_days_after_event < int (min_trading_days_after_event ):
150+ reasons .append ("waiting for post-event trading confirmation" )
151+ if pd .isna (benchmark_rebound_from_low ) or benchmark_rebound_from_low < float (min_benchmark_rebound_from_low ):
152+ reasons .append ("benchmark rebound from recent low below threshold" )
153+ if pd .isna (attack_rebound_from_low ) or attack_rebound_from_low < float (min_attack_rebound_from_low ):
154+ reasons .append ("attack rebound from recent low below threshold" )
155+ if pd .isna (benchmark_3d_return ) or benchmark_3d_return < float (min_benchmark_3d_return ):
156+ reasons .append ("benchmark 3d return below threshold" )
157+
158+ return {
159+ "confirmed" : not reasons ,
160+ "reason" : "; " .join (reasons ),
161+ "lookback_days" : int (lookback_days ),
162+ "trading_days_after_event" : trading_days_after_event ,
163+ "min_trading_days_after_event" : int (min_trading_days_after_event ),
164+ "benchmark_symbol" : benchmark_symbol ,
165+ "attack_symbol" : attack_symbol ,
166+ "benchmark_rebound_from_recent_low" : benchmark_rebound_from_low ,
167+ "attack_rebound_from_recent_low" : attack_rebound_from_low ,
168+ "benchmark_3d_return" : benchmark_3d_return ,
169+ "min_benchmark_rebound_from_low" : float (min_benchmark_rebound_from_low ),
170+ "min_attack_rebound_from_low" : float (min_attack_rebound_from_low ),
171+ "min_benchmark_3d_return" : float (min_benchmark_3d_return ),
172+ }
173+
174+
83175def build_taco_rebound_shadow_signal (
84176 price_history ,
85177 * ,
@@ -95,6 +187,12 @@ def build_taco_rebound_shadow_signal(
95187 crisis_guard_ma_days : int = DEFAULT_PRICE_CRISIS_GUARD_MA_DAYS ,
96188 crisis_guard_ma_slope_days : int = DEFAULT_PRICE_CRISIS_GUARD_MA_SLOPE_DAYS ,
97189 max_price_age_days : int = DEFAULT_MAX_PRICE_AGE_DAYS ,
190+ require_rebound_confirmation : bool = DEFAULT_REQUIRE_REBOUND_CONFIRMATION ,
191+ confirmation_lookback_days : int = DEFAULT_CONFIRMATION_LOOKBACK_DAYS ,
192+ min_confirmation_trading_days_after_event : int = DEFAULT_MIN_CONFIRMATION_TRADING_DAYS_AFTER_EVENT ,
193+ min_benchmark_rebound_from_low : float = DEFAULT_MIN_BENCHMARK_REBOUND_FROM_LOW ,
194+ min_attack_rebound_from_low : float = DEFAULT_MIN_ATTACK_REBOUND_FROM_LOW ,
195+ min_benchmark_3d_return : float = DEFAULT_MIN_BENCHMARK_3D_RETURN ,
98196) -> dict [str , Any ]:
99197 close = normalize_close (price_history )
100198 benchmark_symbol = str (benchmark_symbol ).strip ().upper ()
@@ -155,35 +253,66 @@ def build_taco_rebound_shadow_signal(
155253 selected_event = event
156254 selected_event_signal_date = event_signal_date
157255
158- rebound_context_active = bool (
256+ event_context_active = bool (
159257 selected_event is not None and selected_event .kind == EVENT_KIND_SOFTENING and not crisis_guard_active
160258 )
259+ rebound_confirmation = (
260+ _build_rebound_confirmation (
261+ close ,
262+ signal_date = signal_date ,
263+ selected_event_signal_date = selected_event_signal_date ,
264+ benchmark_symbol = benchmark_symbol ,
265+ attack_symbol = attack_symbol ,
266+ lookback_days = int (confirmation_lookback_days ),
267+ min_trading_days_after_event = int (min_confirmation_trading_days_after_event ),
268+ min_benchmark_rebound_from_low = float (min_benchmark_rebound_from_low ),
269+ min_attack_rebound_from_low = float (min_attack_rebound_from_low ),
270+ min_benchmark_3d_return = float (min_benchmark_3d_return ),
271+ )
272+ if event_context_active
273+ else {"confirmed" : False , "reason" : "no active softening/de-escalation event context" }
274+ )
275+ rebound_confirmed = bool (rebound_confirmation .get ("confirmed" )) or not bool (require_rebound_confirmation )
276+ rebound_context_active = bool (event_context_active and rebound_confirmed )
161277 manual_review_required = rebound_context_active
162278 canonical_route = ROUTE_TACO_REBOUND if manual_review_required else "no_action"
163279 suggested_action = ACTION_NOTIFY_MANUAL_REVIEW if manual_review_required else ACTION_NO_ACTION
164280 would_trade_if_enabled = False
165281 event_rebound_break_bear = bool (manual_review_required and _event_allows_hard_defense (selected_event ))
166282 suppression_reason = ""
167- notification_reason = "event rebound context active" if manual_review_required else ""
283+ notification_reason = ""
284+ if manual_review_required :
285+ notification_reason = (
286+ "event rebound context confirmed"
287+ if bool (require_rebound_confirmation )
288+ else "event rebound context active; rebound confirmation disabled"
289+ )
168290 if active_events and not manual_review_required :
169291 suggested_action = ACTION_WATCH_ONLY
170- suppression_reason = "active event is not a softening/de-escalation rebound context"
292+ if event_context_active and bool (require_rebound_confirmation ):
293+ suppression_reason = "rebound confirmation pending"
294+ else :
295+ suppression_reason = "active event is not a softening/de-escalation rebound context"
171296 if crisis_guard_active :
172297 canonical_route = "no_action"
173298 suggested_action = ACTION_WATCH_ONLY
174299 manual_review_required = False
175300 rebound_context_active = False
301+ event_context_active = False
176302 event_rebound_break_bear = False
177303 suppression_reason = "price crisis guard active"
178304 notification_reason = ""
305+ rebound_confirmation = {"confirmed" : False , "reason" : "price crisis guard active" }
179306 if kill_reasons :
180307 canonical_route = "no_action"
181308 suggested_action = ACTION_WATCH_ONLY
182309 manual_review_required = False
183310 rebound_context_active = False
311+ event_context_active = False
184312 event_rebound_break_bear = False
185313 suppression_reason = "; " .join (kill_reasons )
186314 notification_reason = ""
315+ rebound_confirmation = {"confirmed" : False , "reason" : suppression_reason }
187316
188317 generated_at = datetime .now (timezone .utc ).isoformat ()
189318 payload = {
@@ -196,6 +325,8 @@ def build_taco_rebound_shadow_signal(
196325 "manual_review_required" : manual_review_required ,
197326 "notification_reason" : notification_reason ,
198327 "rebound_context_active" : rebound_context_active ,
328+ "event_context_active" : event_context_active ,
329+ "rebound_confirmation" : rebound_confirmation ,
199330 "event_rebound_break_bear" : event_rebound_break_bear ,
200331 "would_trade_if_enabled" : would_trade_if_enabled ,
201332 "price_stress_scan_active" : scan_active ,
@@ -267,7 +398,9 @@ def write_taco_rebound_shadow_outputs(payload: Mapping[str, Any], output_dir: st
267398 "manual_review_required" : payload .get ("manual_review_required" ),
268399 "notification_reason" : payload .get ("notification_reason" ),
269400 "rebound_context_active" : payload .get ("rebound_context_active" ),
401+ "event_context_active" : payload .get ("event_context_active" ),
270402 "event_rebound_break_bear" : payload .get ("event_rebound_break_bear" ),
403+ ** flatten_for_csv (payload .get ("rebound_confirmation" , {})),
271404 ** flatten_for_csv (payload .get ("data_freshness" , {})),
272405 ** flatten_for_csv (payload .get ("selected_event" ) or {}),
273406 }
@@ -296,6 +429,24 @@ def build_parser() -> argparse.ArgumentParser:
296429 parser .add_argument ("--benchmark-symbol" , default = DEFAULT_BENCHMARK_SYMBOL )
297430 parser .add_argument ("--attack-symbol" , default = DEFAULT_ATTACK_SYMBOL )
298431 parser .add_argument ("--active-signal-days" , type = int , default = DEFAULT_ACTIVE_SIGNAL_DAYS )
432+ parser .add_argument (
433+ "--disable-rebound-confirmation" ,
434+ action = "store_true" ,
435+ help = "Notify on active softening/de-escalation context without post-event price confirmation." ,
436+ )
437+ parser .add_argument ("--confirmation-lookback-days" , type = int , default = DEFAULT_CONFIRMATION_LOOKBACK_DAYS )
438+ parser .add_argument (
439+ "--min-confirmation-trading-days-after-event" ,
440+ type = int ,
441+ default = DEFAULT_MIN_CONFIRMATION_TRADING_DAYS_AFTER_EVENT ,
442+ )
443+ parser .add_argument (
444+ "--min-benchmark-rebound-from-low" ,
445+ type = float ,
446+ default = DEFAULT_MIN_BENCHMARK_REBOUND_FROM_LOW ,
447+ )
448+ parser .add_argument ("--min-attack-rebound-from-low" , type = float , default = DEFAULT_MIN_ATTACK_REBOUND_FROM_LOW )
449+ parser .add_argument ("--min-benchmark-3d-return" , type = float , default = DEFAULT_MIN_BENCHMARK_3D_RETURN )
299450 parser .add_argument ("--output-dir" , default = DEFAULT_OUTPUT_DIR )
300451 return parser
301452
@@ -327,6 +478,12 @@ def main(argv: Sequence[str] | None = None) -> int:
327478 benchmark_symbol = args .benchmark_symbol ,
328479 attack_symbol = args .attack_symbol ,
329480 active_signal_days = args .active_signal_days ,
481+ require_rebound_confirmation = not args .disable_rebound_confirmation ,
482+ confirmation_lookback_days = args .confirmation_lookback_days ,
483+ min_confirmation_trading_days_after_event = args .min_confirmation_trading_days_after_event ,
484+ min_benchmark_rebound_from_low = args .min_benchmark_rebound_from_low ,
485+ min_attack_rebound_from_low = args .min_attack_rebound_from_low ,
486+ min_benchmark_3d_return = args .min_benchmark_3d_return ,
330487 )
331488 paths = write_taco_rebound_shadow_outputs (payload , args .output_dir )
332489 print (
0 commit comments