Skip to content

Commit 97e9cd7

Browse files
authored
Gate TACO alerts on rebound confirmation
Add post-event rebound confirmation gating for the TACO notification-only plugin.
1 parent 311a885 commit 97e9cd7

7 files changed

Lines changed: 205 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ send notifications; plugin research and signal generation live here.
2828
strategies. It writes shadow-mode artifacts and never calls brokers.
2929
- `taco_rebound_shadow`: TQQQ-only event-rebound context notifier. It writes
3030
manual-review artifacts and never recommends position size or changes
31-
allocations.
31+
allocations. Softening/de-escalation events stay watch-only until post-event
32+
price rebound confirmation passes, which reduces early bottom-fishing alerts.
3233
- TACO panic-rebound research and portfolio/overlay backtests also live here;
3334
snapshot pipeline repositories keep only compatibility entrypoints.
3435

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac
2424
## 插件
2525

2626
- `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。
27-
- `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。
27+
- `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。
2828
- TACO panic-rebound 研究、组合回测和 overlay 对比也归属本仓库;snapshot pipeline 仓库只保留兼容入口。
2929

3030
## 使用方式

docs/examples/strategy_plugins.example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ plugin = "taco_rebound_shadow"
2626
enabled = true
2727
# Notification-only TACO context. The artifact may trigger manual-review alerts,
2828
# but it never recommends position size or mutates allocations.
29+
# Manual-review alerts require post-event price rebound confirmation by default.
2930

3031
[strategy_plugins.inputs]
3132
prices = "data/output/taco_rebound_shadow/input/price_history.csv"

src/quant_strategy_plugins/strategy_plugin_runner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,19 @@ def _build_taco_rebound_kwargs(plugin_config: Mapping[str, Any]) -> dict[str, An
227227
}
228228
numeric_keys = {
229229
"crisis_guard_drawdown",
230+
"min_benchmark_rebound_from_low",
231+
"min_attack_rebound_from_low",
232+
"min_benchmark_3d_return",
230233
}
231234
integer_keys = {
232235
"active_signal_days",
233236
"crisis_guard_ma_days",
234237
"crisis_guard_ma_slope_days",
235238
"max_price_age_days",
239+
"confirmation_lookback_days",
240+
"min_confirmation_trading_days_after_event",
236241
}
237-
bool_keys = {"suppress_when_price_crisis_guard_active"}
242+
bool_keys = {"suppress_when_price_crisis_guard_active", "require_rebound_confirmation"}
238243
for key in string_keys:
239244
if key in plugin_config and plugin_config[key] is not None:
240245
kwargs[key] = str(plugin_config[key]).strip()

src/quant_strategy_plugins/taco_rebound_shadow_plugin.py

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
DEFAULT_START_DATE = "2018-01-01"
4141
DEFAULT_MAX_PRICE_AGE_DAYS = 4
4242
DEFAULT_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
4349
HARD_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+
83175
def 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(

tests/test_strategy_plugin_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def test_strategy_plugin_runner_runs_taco_rebound_notification_mount_for_tqqq(tm
334334
assert "route=taco_rebound action=notify_manual_review" in result["message"]
335335
latest = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8"))
336336
assert latest["manual_review_required"] is True
337+
assert latest["rebound_confirmation"]["confirmed"] is True
337338
assert latest["would_trade_if_enabled"] is False
338339
assert "sleeve_suggestion" not in latest
339340

tests/test_taco_rebound_shadow_plugin.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ def test_taco_rebound_shadow_routes_geopolitical_deescalation_to_manual_review_n
4747
assert payload["canonical_route"] == ROUTE_TACO_REBOUND
4848
assert payload["suggested_action"] == ACTION_NOTIFY_MANUAL_REVIEW
4949
assert payload["manual_review_required"] is True
50-
assert payload["notification_reason"] == "event rebound context active"
50+
assert payload["notification_reason"] == "event rebound context confirmed"
5151
assert payload["rebound_context_active"] is True
52+
assert payload["event_context_active"] is True
53+
assert payload["rebound_confirmation"]["confirmed"] is True
5254
assert payload["would_trade_if_enabled"] is False
5355
assert "sleeve_suggestion" not in payload
5456
assert "allow_hard_defense" not in payload
@@ -61,6 +63,36 @@ def test_taco_rebound_shadow_routes_geopolitical_deescalation_to_manual_review_n
6163
assert payload["execution_controls"]["hard_defense_override_signal_allowed"] is False
6264

6365

66+
def test_taco_rebound_shadow_waits_for_rebound_confirmation_before_manual_review() -> None:
67+
prices = _panic_rebound_prices()
68+
dates = pd.bdate_range("2026-03-20", periods=12)
69+
event = TradeWarEvent(
70+
event_id="iran-ceasefire",
71+
event_date=str(dates[3].date()),
72+
kind=EVENT_KIND_SOFTENING,
73+
region="iran_middle_east",
74+
title="Ceasefire talks",
75+
source="test",
76+
source_url="https://example.test/ceasefire",
77+
)
78+
79+
payload = build_taco_rebound_shadow_signal(
80+
prices,
81+
events=(event,),
82+
as_of=str(dates[3].date()),
83+
start_date=str(dates[0].date()),
84+
)
85+
86+
assert payload["canonical_route"] == "no_action"
87+
assert payload["suggested_action"] == "watch_only"
88+
assert payload["manual_review_required"] is False
89+
assert payload["event_context_active"] is True
90+
assert payload["rebound_context_active"] is False
91+
assert payload["rebound_confirmation"]["confirmed"] is False
92+
assert payload["suppression_reason"] == "rebound confirmation pending"
93+
assert "post-event trading confirmation" in payload["rebound_confirmation"]["reason"]
94+
95+
6496
def test_taco_rebound_shadow_writes_artifacts(tmp_path) -> None:
6597
prices = _panic_rebound_prices()
6698
dates = pd.bdate_range("2026-03-20", periods=12)
@@ -88,6 +120,7 @@ def test_taco_rebound_shadow_writes_artifacts(tmp_path) -> None:
88120
assert paths["evidence_csv"].exists()
89121
latest = json.loads(paths["latest_signal"].read_text(encoding="utf-8"))
90122
assert latest["manual_review_required"] is True
123+
assert latest["rebound_confirmation"]["confirmed"] is True
91124
assert "sleeve_suggestion" not in latest
92125
assert "allow_hard_defense" not in latest
93126
assert latest["event_rebound_break_bear"] is False

0 commit comments

Comments
 (0)