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.

Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
This commit is contained in:
Harwinder Sekhon
2026-09-05 21:19:24 -04:00
parent 6bf58fd9d4
commit e79b533a60
12 changed files with 986 additions and 5 deletions
+414
View File
@@ -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
# ══════════════════════════════════════════════════════════════════════