violent-move watch: display-only warning line + owner heads-up (dark by default)
New violent_watch.py assesses each scan from the cached Forex Factory week events, the UTC clock and the report's own indicator states, and stamps SignalReport.watch_line post-hoc (telegram alerts, CLI, dashboard). A standalone heads-up goes to the owner on the fastest scanned timeframe when the level is "hot", once per UTC hour per trigger set, capped per day. Config block `violent_watch` (enabled False, alert_line, headsup, max_headsup_per_day, headsup_min_level); env VIOLENT_WATCH_ENABLED. Rule chosen from a 3-year bar-by-bar sweep (backtest/research/watch_rule_eval.py, recorded in sizing_campaign_2026-09.md): hot = high-impact USD release within 30 min (before or after) OR a hot clock slot (weekday 12-14 UTC, 01 UTC, Sunday 22-23 UTC) with at least one price-extreme reading (outside Bollinger bands, RSI <30/>=70, outside Keltner); ~8.6% of bars, next-bar odds 3-5x normal, ~4 hot hours/day. watch = clock slot alone or two price-extreme readings (~2x). Context readings (ADX rising, extreme volatility, transitioning, MACD acceleration) are phrases only - letting them raise the level flagged 27% of bars at ~2x and would have sent ~13 heads-ups/day (rejected). Direction sentence: DOWN when bearish readings dominate (70-78% on both TFs), UP on M5 only. Never a gate or exit: every open-trade protection tested lost money. Test-locked out of analyze_from_dataframes / engine / backtester. Module 30 (18 tests). Suite: 453 passed. Guide PDFs not rebuilt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
+414
@@ -9668,6 +9668,420 @@ def _():
|
||||
assert args.export_csv == "/tmp/x.csv"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 30: violent-move watch (display-only)
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 30: violent-move watch ═══")
|
||||
|
||||
import violent_watch as _vw
|
||||
from violent_watch import ViolentWatch, assess, attach_watch, should_send_headsup
|
||||
from news_engine import EconomicEvent
|
||||
|
||||
# Tuesday 08:30 UTC — deliberately OUTSIDE every hot clock slot, so a
|
||||
# test that wants only indicator readings gets only indicator readings.
|
||||
_M30_QUIET = datetime(2026, 9, 8, 8, 30, tzinfo=timezone.utc)
|
||||
# Tuesday 12:30 UTC — inside the US-data-hours slot.
|
||||
_M30_CLOCK = datetime(2026, 9, 8, 12, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _m30_event(minutes_from_now, name="Non-Farm Payrolls",
|
||||
currency="USD", impact="high", now=_M30_QUIET):
|
||||
return EconomicEvent(time=now + timedelta(minutes=minutes_from_now),
|
||||
currency=currency, impact=impact, event=name)
|
||||
|
||||
|
||||
def _m30_analysis(indicators=None, regime="ranging", vol="normal",
|
||||
key_levels=None, adx_slope=None):
|
||||
a = TechnicalAnalysis(
|
||||
symbol="XAU/USD", timeframe=Timeframe.M5.value,
|
||||
timestamp="2026-09-08T08:30:00+00:00",
|
||||
indicators=dict(indicators or {}),
|
||||
market_regime=regime, volatility_regime=vol,
|
||||
key_levels=dict(key_levels or {}),
|
||||
)
|
||||
if adx_slope is not None:
|
||||
a._adx_slope = adx_slope
|
||||
return a
|
||||
|
||||
|
||||
def _m30_ind(name, value, signal=SignalStrength.NEUTRAL, desc=""):
|
||||
return IndicatorSignal(name, value, signal, 60.0, desc, 1.5)
|
||||
|
||||
|
||||
def _m30_report(analysis=None):
|
||||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||||
timestamp="2026-09-08T08:30:00+00:00")
|
||||
r.primary_analysis = analysis
|
||||
return r
|
||||
|
||||
|
||||
# Keltner band geometry used below: mid 2400, upper 2410, lower 2390, so
|
||||
# the indicator's stored value (close - mid) is outside the channel when
|
||||
# it is above +10 or below -10.
|
||||
_M30_KC_LEVELS = {"kc_upper": 2410.0, "kc_lower": 2390.0, "kc_mid": 2400.0}
|
||||
|
||||
|
||||
@test("violent watch: high-impact USD release in 12 min is HOT")
|
||||
def _():
|
||||
w = assess(_m30_report(), _M30_QUIET, [_m30_event(12)], Timeframe.M5)
|
||||
assert w.level == "hot", w.level
|
||||
assert "calendar" in w.kinds and w.kinds == ["calendar"]
|
||||
assert "Non-Farm Payrolls" in w.triggers[0], w.triggers
|
||||
assert "in 12 min" in w.triggers[0], w.triggers
|
||||
assert "HOT" in w.line() and "3–5× normal" in w.line()
|
||||
|
||||
|
||||
@test("violent watch: release 10 minutes ago is still HOT")
|
||||
def _():
|
||||
w = assess(_m30_report(), _M30_QUIET, [_m30_event(-10, name="US CPI")],
|
||||
Timeframe.M5)
|
||||
assert w.level == "hot"
|
||||
assert w.kinds == ["calendar"]
|
||||
assert "just out" in w.triggers[0] and "US CPI" in w.triggers[0]
|
||||
assert "10 min ago" in w.triggers[0], w.triggers
|
||||
|
||||
|
||||
@test("violent watch: calendar ignores non-USD, low impact and far events")
|
||||
def _():
|
||||
for ev in (_m30_event(12, currency="EUR"),
|
||||
_m30_event(12, impact="medium"),
|
||||
_m30_event(90), # outside the +30 min window
|
||||
_m30_event(-90)): # outside the -30 min window
|
||||
w = assess(_m30_report(), _M30_QUIET, [ev], Timeframe.M5)
|
||||
assert w.level == "none", (ev.currency, ev.impact, w.level)
|
||||
|
||||
|
||||
@test("violent watch: hot clock slot alone is WATCH")
|
||||
def _():
|
||||
w = assess(_m30_report(), _M30_CLOCK, [], Timeframe.M5)
|
||||
assert w.level == "watch", w.level
|
||||
assert w.kinds == ["clock"] and w.readings == 0
|
||||
assert "US data hours" in w.triggers[0]
|
||||
assert "about 2× normal" in w.line() and "watch" in w.line()
|
||||
# The other two slots resolve too.
|
||||
assert _vw._clock_trigger(
|
||||
datetime(2026, 9, 8, 1, 5, tzinfo=timezone.utc)) == \
|
||||
"Asian open hour (01:00 UTC)"
|
||||
assert _vw._clock_trigger(
|
||||
datetime(2026, 9, 13, 22, 5, tzinfo=timezone.utc)) == "Sunday reopen"
|
||||
# Sunday 01:00 is NOT the Asian-open slot (weekday rule).
|
||||
assert _vw._clock_trigger(
|
||||
datetime(2026, 9, 13, 1, 5, tzinfo=timezone.utc)) is None
|
||||
|
||||
|
||||
@test("violent watch: clock slot + ONE price extreme is HOT")
|
||||
def _():
|
||||
a = _m30_analysis(indicators={
|
||||
"rsi": _m30_ind("RSI", 24.0),
|
||||
"bollinger": _m30_ind("BB", 0.5),
|
||||
})
|
||||
w = assess(_m30_report(a), _M30_CLOCK, [], Timeframe.M5)
|
||||
assert w.level == "hot", w.level
|
||||
assert w.readings == 1 and sorted(w.kinds) == ["clock", "extreme"]
|
||||
assert "RSI 24, oversold extreme" in w.triggers
|
||||
|
||||
|
||||
@test("violent watch: context readings alone never raise the level")
|
||||
def _():
|
||||
a = _m30_analysis(
|
||||
indicators={"macd": _m30_ind("MACD", -1.2, SignalStrength.STRONG_SELL)},
|
||||
regime="transitioning", vol="extreme", adx_slope=5.0)
|
||||
w = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||||
assert w.level == "none", w.level
|
||||
assert w.readings == 0 and w.kinds == []
|
||||
assert w.line() == ""
|
||||
# ...and they ARE printed once a real level exists.
|
||||
w2 = assess(_m30_report(a), _M30_CLOCK, [], Timeframe.M5)
|
||||
assert w2.level == "watch"
|
||||
for phrase in ("trend strength rising", "volatility extreme",
|
||||
"regime transitioning", "downside momentum accelerating"):
|
||||
assert phrase in w2.triggers, (phrase, w2.triggers)
|
||||
|
||||
|
||||
@test("violent watch: two bearish price extremes are WATCH, direction down")
|
||||
def _():
|
||||
a = _m30_analysis(
|
||||
indicators={
|
||||
"bollinger": _m30_ind("BB", -0.2),
|
||||
"keltner": _m30_ind("KC", -12.0),
|
||||
"macd": _m30_ind("MACD", -1.2, SignalStrength.STRONG_SELL),
|
||||
},
|
||||
key_levels=_M30_KC_LEVELS)
|
||||
w = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||||
assert w.level == "watch", w.level
|
||||
assert w.readings == 2 and w.kinds == ["extreme"]
|
||||
assert w.likely_direction == "down"
|
||||
assert "price below the Bollinger bands" in w.triggers
|
||||
assert "price outside the Keltner channel" in w.triggers
|
||||
assert "DOWN" in w.line()
|
||||
|
||||
|
||||
@test("violent watch: keltner side read from stored channel levels")
|
||||
def _():
|
||||
above = _m30_analysis(indicators={"keltner": _m30_ind("KC", 12.0)},
|
||||
key_levels=_M30_KC_LEVELS)
|
||||
inside = _m30_analysis(indicators={"keltner": _m30_ind("KC", 4.0)},
|
||||
key_levels=_M30_KC_LEVELS)
|
||||
below = _m30_analysis(indicators={"keltner": _m30_ind("KC", -12.0)},
|
||||
key_levels=_M30_KC_LEVELS)
|
||||
assert _vw._keltner_side(above) == "above"
|
||||
assert _vw._keltner_side(inside) is None
|
||||
assert _vw._keltner_side(below) == "below"
|
||||
# A squeeze rewrites the description but keeps the levels — still read.
|
||||
squeezed = _m30_analysis(
|
||||
indicators={"keltner": _m30_ind(
|
||||
"KC", 12.0, SignalStrength.STRONG_BUY,
|
||||
"Keltner SQUEEZE RELEASED — bullish breakout")},
|
||||
key_levels=_M30_KC_LEVELS)
|
||||
assert _vw._keltner_side(squeezed) == "above"
|
||||
# No key_levels → fall back to the description text.
|
||||
desc_only = _m30_analysis(indicators={"keltner": _m30_ind(
|
||||
"KC", 12.0, SignalStrength.BUY,
|
||||
"Price above Keltner upper — strong uptrend continuation")})
|
||||
assert _vw._keltner_side(desc_only) == "above"
|
||||
assert _vw._keltner_side(_m30_analysis(indicators={
|
||||
"keltner": _m30_ind("KC", 1.0, SignalStrength.NEUTRAL,
|
||||
"Price within Keltner Channels")})) is None
|
||||
|
||||
|
||||
@test("violent watch: bullish direction is called on M5 only")
|
||||
def _():
|
||||
a = _m30_analysis(indicators={
|
||||
"bollinger": _m30_ind("BB", 1.2),
|
||||
"rsi": _m30_ind("RSI", 78.0),
|
||||
})
|
||||
m15 = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M15)
|
||||
assert m15.level == "watch" and m15.readings == 2
|
||||
assert m15.likely_direction == "", m15.likely_direction
|
||||
assert "UP" not in m15.line()
|
||||
m5 = assess(_m30_report(a), _M30_QUIET, [], Timeframe.M5)
|
||||
assert m5.likely_direction == "up"
|
||||
assert "UP" in m5.line()
|
||||
# A string timeframe works the same as the enum.
|
||||
assert assess(_m30_report(a), _M30_QUIET, [],
|
||||
Timeframe.M5.value).likely_direction == "up"
|
||||
|
||||
|
||||
@test("violent watch: nothing firing yields level none and an empty line")
|
||||
def _():
|
||||
a = _m30_analysis(indicators={
|
||||
"rsi": _m30_ind("RSI", 51.0),
|
||||
"bollinger": _m30_ind("BB", 0.5),
|
||||
"keltner": _m30_ind("KC", 1.0),
|
||||
}, key_levels=_M30_KC_LEVELS)
|
||||
w = assess(_m30_report(a), _M30_QUIET, None, Timeframe.M5)
|
||||
assert w.level == "none" and w.triggers == [] and w.kinds == []
|
||||
assert w.readings == 0 and w.likely_direction == ""
|
||||
assert w.line() == "" and ViolentWatch().line() == ""
|
||||
|
||||
|
||||
@test("violent watch: degenerate inputs never raise")
|
||||
def _():
|
||||
class _Broken:
|
||||
@property
|
||||
def primary_analysis(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
bad_ind = _m30_analysis(indicators={
|
||||
"rsi": _m30_ind("RSI", float("nan")),
|
||||
"bollinger": IndicatorSignal("BB", None, SignalStrength.NEUTRAL,
|
||||
0.0, "", 1.5),
|
||||
"keltner": _m30_ind("KC", 5.0),
|
||||
}, adx_slope="not-a-number")
|
||||
cases = [
|
||||
_m30_report(None),
|
||||
_m30_report(bad_ind),
|
||||
_m30_report(_m30_analysis(indicators={})),
|
||||
object(),
|
||||
None,
|
||||
]
|
||||
for report in cases:
|
||||
w = assess(report, _M30_QUIET, None, Timeframe.M5)
|
||||
assert w.level == "none", (report, w.level)
|
||||
assert w.line() == ""
|
||||
# Bad event objects and a naive `now` also degrade quietly.
|
||||
assert assess(_m30_report(), _M30_QUIET, [object(), None],
|
||||
Timeframe.M5).level == "none"
|
||||
assert assess(_m30_report(), datetime(2026, 9, 8, 8, 30),
|
||||
None, Timeframe.M5).level == "none"
|
||||
|
||||
|
||||
@test("violent watch: heads-up budget dedups by hour+triggers and by day")
|
||||
def _():
|
||||
state = {}
|
||||
t0 = datetime(2026, 9, 8, 12, 5, tzinfo=timezone.utc)
|
||||
ok, why = should_send_headsup(state, t0, ["clock", "extreme"], 12)
|
||||
assert ok and why == "ok"
|
||||
# Same hour, same trigger set → suppressed.
|
||||
ok, why = should_send_headsup(state, t0.replace(minute=40),
|
||||
["extreme", "clock"], 12)
|
||||
assert not ok and why == "already sent this hour for these triggers"
|
||||
# Same hour, DIFFERENT trigger set → allowed.
|
||||
assert should_send_headsup(state, t0, ["calendar"], 12)[0] is True
|
||||
# New hour → allowed again.
|
||||
assert should_send_headsup(state, t0.replace(hour=13),
|
||||
["clock", "extreme"], 12)[0] is True
|
||||
assert state["count"] == 3
|
||||
# Daily cap.
|
||||
capped = {}
|
||||
assert should_send_headsup(capped, t0, ["clock"], 1)[0] is True
|
||||
ok, why = should_send_headsup(capped, t0.replace(hour=14), ["clock"], 1)
|
||||
assert not ok and why == "daily cap"
|
||||
assert should_send_headsup({}, t0, ["clock"], 0) == (False, "daily cap")
|
||||
# UTC day rollover resets count and the per-hour memory.
|
||||
ok, why = should_send_headsup(capped, t0 + timedelta(days=1),
|
||||
["clock"], 1)
|
||||
assert ok and capped["count"] == 1
|
||||
|
||||
|
||||
@test("app_config: violent_watch defaults dark, env override, validation")
|
||||
def _():
|
||||
defaults = load_app_config(_write_json({})).violent_watch_settings
|
||||
assert defaults == {
|
||||
"enabled": False, "alert_line": True, "headsup": True,
|
||||
"max_headsup_per_day": 12, "headsup_min_level": "hot",
|
||||
}
|
||||
on = {"enabled": True, "alert_line": False, "headsup": False,
|
||||
"max_headsup_per_day": 3, "headsup_min_level": "watch"}
|
||||
app = load_app_config(_write_json({"violent_watch": on}))
|
||||
assert app.violent_watch_settings == on
|
||||
# A partial block keeps the remaining defaults.
|
||||
part = load_app_config(
|
||||
_write_json({"violent_watch": {"enabled": True}}))
|
||||
assert part.violent_watch_settings["enabled"] is True
|
||||
assert part.violent_watch_settings["headsup_min_level"] == "hot"
|
||||
old = os.environ.get("VIOLENT_WATCH_ENABLED")
|
||||
try:
|
||||
os.environ.pop("VIOLENT_WATCH_ENABLED", None)
|
||||
assert app_config.violent_watch_enabled(
|
||||
load_app_config(_write_json({}))) is False
|
||||
os.environ["VIOLENT_WATCH_ENABLED"] = "false"
|
||||
assert app_config.violent_watch_enabled(app) is False
|
||||
os.environ["VIOLENT_WATCH_ENABLED"] = "1"
|
||||
assert app_config.violent_watch_enabled(
|
||||
load_app_config(_write_json({}))) is True
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("VIOLENT_WATCH_ENABLED", None)
|
||||
else:
|
||||
os.environ["VIOLENT_WATCH_ENABLED"] = old
|
||||
for bad in (
|
||||
{"violent_watch": {"enabld": True}},
|
||||
{"violent_watch": {"enabled": "yes"}},
|
||||
{"violent_watch": {"alert_line": 1}},
|
||||
{"violent_watch": {"headsup": "no"}},
|
||||
{"violent_watch": {"max_headsup_per_day": -1}},
|
||||
{"violent_watch": {"max_headsup_per_day": True}},
|
||||
{"violent_watch": {"max_headsup_per_day": 2.5}},
|
||||
{"violent_watch": {"headsup_min_level": "none"}},
|
||||
):
|
||||
try:
|
||||
load_app_config(_write_json(bad))
|
||||
assert False, f"should have raised for {bad}"
|
||||
except AppConfigError:
|
||||
pass
|
||||
|
||||
|
||||
@test("violent watch: attach_watch honours enabled/alert_line, never raises")
|
||||
def _():
|
||||
report = _m30_report(_m30_analysis(indicators={
|
||||
"bollinger": _m30_ind("BB", -0.2), "rsi": _m30_ind("RSI", 22.0)}))
|
||||
with patch("news_engine.NewsEngine.get_week_events", return_value=[]):
|
||||
# Dark by default: the report is untouched.
|
||||
w = attach_watch(report, Timeframe.M5, {"enabled": False},
|
||||
_M30_QUIET)
|
||||
assert w.level == "none" and report.watch_line == ""
|
||||
assert attach_watch(report, Timeframe.M5, {}, _M30_QUIET).level == "none"
|
||||
assert report.watch_line == ""
|
||||
# Enabled: the line is stamped.
|
||||
w = attach_watch(report, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||||
assert w.level == "watch" and w.likely_direction == "down"
|
||||
assert report.watch_line.startswith("🌪 Violent-move watch (watch): ")
|
||||
# alert_line False assesses but does not stamp.
|
||||
report.watch_line = ""
|
||||
w = attach_watch(report, Timeframe.M5,
|
||||
{"enabled": True, "alert_line": False}, _M30_QUIET)
|
||||
assert w.level == "watch" and report.watch_line == ""
|
||||
# A calendar blow-up degrades to a technical-only assessment.
|
||||
report.watch_line = ""
|
||||
with patch("news_engine.NewsEngine.get_week_events",
|
||||
side_effect=RuntimeError("calendar down")):
|
||||
w = attach_watch(report, Timeframe.M5, {"enabled": True}, _M30_QUIET)
|
||||
assert w.level == "watch" and report.watch_line != ""
|
||||
# A broken report can never propagate out of attach_watch.
|
||||
with patch.object(_vw.logger, "exception") as logged:
|
||||
assert attach_watch(object(), Timeframe.M5, {"enabled": True},
|
||||
_M30_QUIET).level == "none"
|
||||
assert logged.called, "a swallowed failure must still be logged"
|
||||
|
||||
|
||||
@test("violent watch: telegram alert renders the line only when stamped")
|
||||
def _():
|
||||
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
|
||||
timestamp="now")
|
||||
assert report.watch_line == ""
|
||||
assert "Violent-move watch" not in format_signal_message(report)
|
||||
report.watch_line = ("🌪 Violent-move watch (HOT): US data hours "
|
||||
"(12:00–15:00 UTC) · RSI 24, oversold extreme "
|
||||
"— odds of a violent bar about 3–5× normal")
|
||||
msg = format_signal_message(report)
|
||||
assert "Violent-move watch (HOT)" in msg
|
||||
assert "RSI 24, oversold extreme" in msg
|
||||
|
||||
|
||||
@test("violent watch: heads-up text names triggers and defends the stop")
|
||||
def _():
|
||||
from telegram_bot import (_format_headsup, _HEADSUP_TF,
|
||||
_TF_INTERVAL_MINUTES, _APP as _tb_app)
|
||||
w = ViolentWatch(level="hot",
|
||||
triggers=["US data hours (12:00–15:00 UTC)",
|
||||
"RSI 24, oversold extreme"],
|
||||
kinds=["clock", "extreme"], readings=1,
|
||||
likely_direction="down")
|
||||
text = _format_headsup(_m30_report(), w)
|
||||
assert "Heads-up" in text and "XAU/USD" in text
|
||||
assert "US data hours (12:00–15:00 UTC)" in text
|
||||
assert "RSI 24, oversold extreme" in text
|
||||
assert "DOWN" in text
|
||||
assert "Your stop is the protection" in text
|
||||
assert "loses money" in text
|
||||
# The heads-up rides the fastest scanned loop only.
|
||||
assert _HEADSUP_TF == min(_tb_app.scan_timeframes,
|
||||
key=lambda tf: _TF_INTERVAL_MINUTES[tf])
|
||||
|
||||
|
||||
@test("violent watch: report_to_dict carries watch_line")
|
||||
def _():
|
||||
from main import report_to_dict
|
||||
report = _m30_report()
|
||||
assert report_to_dict(report)["watch_line"] == ""
|
||||
report.watch_line = "🌪 Violent-move watch (HOT): US release just out"
|
||||
assert report_to_dict(report)["watch_line"] == report.watch_line
|
||||
|
||||
|
||||
@test("violent watch: signal path is free of it (import + runtime lock)")
|
||||
def _():
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
for rel in ("signal_aggregator.py", "engine.py",
|
||||
os.path.join("backtest", "gpu_backtest.py")):
|
||||
with open(os.path.join(root, rel), encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
for banned in ("import violent_watch", "from violent_watch",
|
||||
"attach_watch(", "should_send_headsup("):
|
||||
assert banned not in src, \
|
||||
f"{rel} must not use {banned} (backtest parity)"
|
||||
# A real replay leaves watch_line empty.
|
||||
agg = SignalAggregator(PlatformConfig())
|
||||
dfs = {
|
||||
Timeframe.H1: make_ohlcv(300, seed=42),
|
||||
Timeframe.M15: make_ohlcv(300, seed=43),
|
||||
Timeframe.H4: make_ohlcv(300, seed=44),
|
||||
}
|
||||
rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
|
||||
assert rep.watch_line == "", "analyze_from_dataframes must not stamp it"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user