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
+2 -1
View File
@@ -34,7 +34,7 @@ Python 3.12; a `venv/` exists in-repo. Dependencies in `requirements.txt` (numpy
## Configuration & credentials
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap). The `agent_panel` config.json block (all-defaults = feature dark) controls the advisory LLM agent panel: `enabled`, per-surface `telegram`/`dashboard` toggles, `min_quality`, `max_panels_per_day`, `reuse_ttl_seconds`, plus the technical-payload enrichment knobs `candles_enabled`/`candle_bars_primary`/`candle_bars_confirm`. The dark-by-default `market_outlook` block controls the hourly advisory market brief: `enabled`, `telegram`, `max_per_day`, `candles_bars`; `MARKET_OUTLOOK_ENABLED` overrides its enabled setting.
Precedence for every credential/deployment setting: **env var → config.json → refuse to run** (no hardcoded fallbacks). A gitignored repo-root `.env` is loaded at `app_config` import via `os.environ.setdefault`. `config.json` (gitignored; schema in `config.json.example`) holds secrets plus deployment settings: `data_providers.active` (the live-feed switch), `data_providers.streaming` (optional tick-stream provider for the level-breach monitor), `scanning.pairs`/`timeframes`, per-pair `blocked_hours_utc`, `log_level`. Key env vars: `TELEGRAM_TOKEN`, `TELEGRAM_CHAT_ID`, `TELEGRAM_CHANNEL_ID`, `TWELVE_DATA_API_KEY`, `TIINGO_API_KEY`, `OANDA_ACCOUNT_ID`/`OANDA_API_TOKEN`/`OANDA_BASE_URL`, `DEEPSEEK_API_KEY` (news LLM; the `deepseek` config.json block holds model/enabled/timeout/daily cap). The `agent_panel` config.json block (all-defaults = feature dark) controls the advisory LLM agent panel: `enabled`, per-surface `telegram`/`dashboard` toggles, `min_quality`, `max_panels_per_day`, `reuse_ttl_seconds`, plus the technical-payload enrichment knobs `candles_enabled`/`candle_bars_primary`/`candle_bars_confirm`. The dark-by-default `market_outlook` block controls the hourly advisory market brief: `enabled`, `telegram`, `max_per_day`, `candles_bars`; `MARKET_OUTLOOK_ENABLED` overrides its enabled setting. The dark-by-default `violent_watch` block controls the display-only violent-move watch: `enabled`, `alert_line`, `headsup`, `max_headsup_per_day`, `headsup_min_level` (`"watch"`/`"hot"`); `VIOLENT_WATCH_ENABLED` overrides its enabled setting.
## Architecture
@@ -62,6 +62,7 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that. **Headline pool (2026-09-04):** only headlines ≤ 4 h old with a parsable publish time (`headline_max_age_seconds=14400`, undated items dropped), and `get_fundamental_context` drops headlines whose every relevant score is inside ±0.1 (`_is_neutral_headline`, `NEUTRAL_HEADLINE_THRESHOLD`) before the per-currency mean — so `news_sentiment` from that date is a mean over directional headlines only (bolder values; log comparisons must split at the deploy date). The market summary prompt writes for someone who has never traded (no acronyms), sends headlines newest-first with the platform's own gold direction, and caps at 400 chars. **Verdict 2026-09-04** (`backtest/research/news_sentiment_value_2026-09-04.md`): `news_sentiment` is a trailing-24h echo of price (r≈0.67 with the past day, ≈0.03 with the next; flips 44 h after tops / 16 h after bottoms) — both gating flags stay False; re-check with `analyze_signal_log.py --stored-candles --news`.
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again. **Verdict source (2026-09-03, owner decision):** all four personas still run and are logged (`panel_takes_json`), but `agent_panel.verdict_source` defaults to `"macro"` — the alert verdict is the macro persona's stance (support/neutral/oppose → AGREE/NEUTRAL/OPPOSE) and the synthesizer call is skipped (4 LLM calls per panel instead of 5); `"chair"` restores the four-voice synthesizer + OPPOSE guard. Re-score personas monthly with `analyze_signal_log.py --stored-candles`; weights are not to be tuned before ~300 graded outcomes with a held-out half.
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
- **`violent_watch.py`** — **display-only** violent-move watch, dark by default (`violent_watch` config block: `enabled`, `alert_line`, `headsup`, `max_headsup_per_day`, `headsup_min_level`; env `VIOLENT_WATCH_ENABLED`). Flags when a "violent bar" (range ≥ 3× prior ATR14) is unusually likely and stamps `SignalReport.watch_line` **post-hoc** via `attach_watch` (telegram alert path, `main.py`, `dashboard_publisher`) — same contract as the agent panel: never imported by `signal_aggregator`/`engine`/`backtest/`, never raises, never read by a gate (test-locked, Module 30). Triggers: a high-impact **USD** calendar release within ±30 min (hot), hot clock slots (weekday 12–14 UTC, weekday 01 UTC, Sunday reopen 22–23 UTC), and **price-extreme** indicator readings (outside the Bollinger bands, RSI <30 / ≥70, outside the Keltner channel). Level = hot when the calendar fires or a clock slot plus ≥1 price extreme; watch on a clock slot alone or ≥2 price extremes. Context readings (ADX slope >3, volatility "extreme", regime "transitioning", MACD STRONG_BUY/STRONG_SELL) are printed but **never raise the level** — the first draft that let them count flagged 27% of bars "hot" at only ~2× odds; the shipped rule flags 8.6% at 3–5× (evidence: `backtest/research/sizing_campaign_2026-09.md` addenda). Direction is called DOWN on bearish readings (70–78% on M5 and M15) and UP only on M5. Telegram also sends a standalone heads-up on the fastest scanned TF (`_HEADSUP_TF`), rate-limited per UTC hour+trigger-set and per day; it explicitly tells the owner **not** to touch open trades — every protection tested lost money, the stop is the protection.
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`.
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
+45 -1
View File
@@ -87,7 +87,7 @@ _SCANNABLE_TFS = ("M5", "M15", "H1", "H4")
_KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
"oanda", "deepseek", "agent_panel", "market_outlook",
"signal_log"}
"violent_watch", "signal_log"}
_KNOWN_OANDA = {
"account_id", "api_token", "base_url",
}
@@ -101,6 +101,8 @@ _KNOWN_AGENT_PANEL = {
"verdict_source",
}
_KNOWN_MARKET_OUTLOOK = {"enabled", "telegram", "max_per_day", "candles_bars"}
_KNOWN_VIOLENT_WATCH = {"enabled", "alert_line", "headsup",
"max_headsup_per_day", "headsup_min_level"}
_KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
_KNOWN_SCANNING = {"pairs", "timeframes"}
@@ -113,6 +115,16 @@ _MARKET_OUTLOOK_DEFAULTS = {
"candles_bars": 20,
}
# Violent-move watch (display-only heads-up) — dark by default.
_VIOLENT_WATCH_DEFAULTS = {
"enabled": False,
"alert_line": True,
"headsup": True,
"max_headsup_per_day": 12,
"headsup_min_level": "hot",
}
_VALID_WATCH_LEVELS = ("watch", "hot")
class AppConfigError(Exception):
"""config.json is present but invalid — fail fast at boot."""
@@ -136,6 +148,8 @@ class AppConfig:
agent_panel_settings: Dict[str, Any] = field(default_factory=dict)
market_outlook_settings: Dict[str, Any] = field(
default_factory=lambda: dict(_MARKET_OUTLOOK_DEFAULTS))
violent_watch_settings: Dict[str, Any] = field(
default_factory=lambda: dict(_VIOLENT_WATCH_DEFAULTS))
signal_log_settings: Dict[str, Any] = field(default_factory=dict)
log_level: str = "INFO"
@@ -337,6 +351,25 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
f"config.json: market_outlook.{key} must be a positive integer")
app.market_outlook_settings.update(outlook)
watch = raw.get("violent_watch", {})
_check_keys(watch, _KNOWN_VIOLENT_WATCH, '"violent_watch"')
for flag in ("enabled", "alert_line", "headsup"):
if flag in watch and not isinstance(watch[flag], bool):
raise AppConfigError(
f"config.json: violent_watch.{flag} must be true or false")
if "max_headsup_per_day" in watch:
cap = watch["max_headsup_per_day"]
if isinstance(cap, bool) or not isinstance(cap, int) or cap < 0:
raise AppConfigError(
"config.json: violent_watch.max_headsup_per_day must be a "
"non-negative integer")
if ("headsup_min_level" in watch
and watch["headsup_min_level"] not in _VALID_WATCH_LEVELS):
raise AppConfigError(
f'config.json: violent_watch.headsup_min_level '
f'"{watch["headsup_min_level"]}" not in {_VALID_WATCH_LEVELS}')
app.violent_watch_settings.update(watch)
slog = raw.get("signal_log", {})
_check_keys(slog, _KNOWN_SIGNAL_LOG, '"signal_log"')
if "enabled" in slog and not isinstance(slog["enabled"], bool):
@@ -462,6 +495,17 @@ def market_outlook_enabled(app: Optional[AppConfig] = None) -> bool:
return bool(app.market_outlook_settings.get("enabled", False))
# ── Violent-move watch resolution helper ─────────────────────────────
def violent_watch_enabled(app: Optional[AppConfig] = None) -> bool:
"""Whether the display-only violent-move watch is on (env wins)."""
env = os.environ.get("VIOLENT_WATCH_ENABLED")
if env:
return env.strip().lower() in ("1", "true", "yes", "on")
app = app or get_app_config()
return bool(app.violent_watch_settings.get("enabled", False))
# ── Signal-log resolution helpers (env → config.json → default) ──────
def signal_log_enabled(app: Optional[AppConfig] = None) -> bool:
+7
View File
@@ -55,6 +55,13 @@
"max_per_day": 12,
"candles_bars": 20
},
"violent_watch": {
"enabled": false,
"alert_line": true,
"headsup": true,
"max_headsup_per_day": 12,
"headsup_min_level": "hot"
},
"signal_log": {
"enabled": true,
"path": "logs/signals.db",
+7
View File
@@ -50,11 +50,13 @@ from logging.handlers import TimedRotatingFileHandler
from typing import Dict, List, Optional
from agent_panel import attach_panel
from app_config import get_app_config, violent_watch_enabled
from config import PlatformConfig, Timeframe, XAUUSD
from main import report_to_dict
from news_engine import NewsEngine
from pair_signals import produce_signal
from providers import make_data_provider
from violent_watch import attach_watch
import providers.base as _providers_base
logger = logging.getLogger("dashboard")
@@ -359,6 +361,11 @@ class DashboardPublisher:
try:
report = produce_signal(XAUUSD, tf, self._provider(),
CONFIRMATIONS[tf])
# Display-only violent-move watch (dark by default). Never
# raises and never touches the signal — only watch_line.
if violent_watch_enabled():
attach_watch(report, tf,
get_app_config().violent_watch_settings)
self._reports[tf.value] = report
self.snapshots[tf.value] = {
"status": "ok",
+1
View File
@@ -86,6 +86,7 @@
<li><b>Grade (A+, A, B, C):</b> the bot's own mark for how clean the setup is. A+ is best. It never sends anything worse than C.</li>
<li><b>Confidence and the indicator list:</b> extra detail for curious people. You don't need to understand it to trade the signal.</li>
<li><b>⚠️ Warning lines:</b> things the bot noticed, like high volatility. They are "be aware," not "don't trade."</li>
<li><b>🌪 Violent-move watch:</b> now and then the bot adds a line saying the next hour could produce very big candles — usually because an important American economic number is about to come out, or the market is in an hour that is historically wild, or the price has stretched a long way from normal. It is only a "hold on tight" note. It does not change the signal, and it is not a reason to close or move your stop loss early — the bot was tested doing exactly that and it lost money. Your stop loss is already your protection; leave it where it is.</li>
<li><b>📰 News line:</b> a short plain-English note on what is moving the gold price right now and why, written from the last few hours of headlines. It is background information, not a reason to trade.</li>
<li><b>The trade setup at the bottom is the only part you must act on.</b></li>
</ul>
+1
View File
@@ -69,6 +69,7 @@
<tr><td><b>Score / confidence</b></td><td>The regime-weighted composite of all nine indicators. Bigger absolute score = stronger consensus.</td></tr>
<tr><td><b>MTF aligned</b></td><td>The two confirmation timeframes agree with the trade direction. Signals without alignment are filtered out on M5.</td></tr>
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
<tr><td><b>Violent-move watch 🌪</b></td><td>An occasional extra line saying the next hour looks unusually likely to produce a very large candle — because a high-impact US release is due (or just landed), because the clock is in a historically wild slot, or because price is stretched outside its bands. It is a heads-up about <i>volatility</i>, not a direction call, and it <b>never</b> changes the signal, the grade or the trade setup — the same alert would be sent without it. Do not close or tighten an open trade because of it: every such protective action was tested over three years and lost money. Your stop is the protection.</td></tr>
<tr><td><b>Market context 📰</b></td><td>An AI-written note in plain English on what is moving the gold price right now, why it matters, and the next report to watch. It is built only from headlines published in the last 4 hours that lean one way (neutral items are dropped) plus today's high-impact releases. Informational only — it never blocks or grades a signal.</td></tr>
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
<tr><td><b>Trail plan 🔄</b></td><td>The real exit. Once price moves +0.6R in your favor, move the stop to trail 0.75R behind the best price and update it at each candle close. <b>TP1 is advisory — let winners run.</b></td></tr>
+11
View File
@@ -22,10 +22,12 @@ if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
from agent_panel import attach_panel
from app_config import get_app_config, violent_watch_enabled
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
from market_outlook import OutlookResult, maybe_run_outlook
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
from violent_watch import attach_watch
# ══════════════════════════════════════════════════════════════════
@@ -160,6 +162,8 @@ def print_signal_report(report: SignalReport, detail: bool = False):
if report.warnings:
for w in report.warnings:
print(f" {Colors.YELLOW}⚠{Colors.RESET} {w}")
if report.watch_line:
print(f" {Colors.YELLOW}🌪{Colors.RESET} {report.watch_line}")
if report.event_description and report.event_risk != "clear":
print(f" {Colors.YELLOW}📅{Colors.RESET} {report.event_description}")
if report.news_description and report.news_description != "Neutral news sentiment":
@@ -246,6 +250,7 @@ def report_to_dict(report: SignalReport) -> dict:
"panel_created_at": report.panel_created_at,
"panel_cache_status": report.panel_cache_status,
"panel_payload_hash": report.panel_payload_hash,
"watch_line": report.watch_line,
"bullish_reasons": report.bullish_reasons,
"bearish_reasons": report.bearish_reasons,
"warnings": report.warnings,
@@ -373,6 +378,12 @@ def run_live(args, config: PlatformConfig):
if getattr(args, "panel", False):
attach_panel(report, surface="cli", force=True)
# Display-only violent-move watch (dark by default; never touches
# the signal, only report.watch_line).
if violent_watch_enabled():
attach_watch(report, config.primary_timeframe,
get_app_config().violent_watch_settings)
outlook = None
if getattr(args, "outlook", False):
outlook = maybe_run_outlook(surface="cli", force=True)
+6
View File
@@ -112,6 +112,12 @@ class SignalReport:
panel_payload_hash: str = ""
panel_verdict_source: str = "" # "macro" / "chair" / ""
# Violent-move watch line (advisory/DISPLAY-ONLY). Stamped post-hoc
# by surfaces via violent_watch.attach_watch — never populated
# inside analyze_from_dataframes (backtest parity) and never read by
# any gate, grade or setup generator.
watch_line: str = ""
final_signal: SignalStrength = SignalStrength.NEUTRAL
final_score: float = 0.0
final_confidence: float = 0.0
+7 -1
View File
@@ -382,7 +382,13 @@ function listBlock(r) {
const group = (title, arr, cls, icon) => arr?.length
? `<div class="small text-secondary fw-semibold mt-2 mb-1">${title} (${arr.length})</div>
<div class="small">${arr.map(t => `<div class="${cls}"><i class="bi ${icon} me-1"></i>${esc(t)}</div>`).join("")}</div>` : "";
return group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
// Violent-move watch: display-only advisory line, stamped post-hoc by
// the publisher. Empty unless the feature is enabled.
const watch = r.watch_line
? `<div class="small text-warning fw-semibold mt-2">${esc(r.watch_line)}</div>`
: "";
return watch
+ group("Warnings", r.warnings, "text-warning", "bi-exclamation-triangle-fill")
+ group("Bullish", r.bullish_reasons, "text-success", "bi-arrow-up-circle-fill")
+ group("Bearish", r.bearish_reasons, "text-danger", "bi-arrow-down-circle-fill");
}
+75 -2
View File
@@ -50,7 +50,7 @@ import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set
from typing import Any, Dict, List, Optional, Set
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
@@ -68,7 +68,7 @@ from config import (
)
from app_config import (
get_app_config, telegram_token, telegram_chat_id,
telegram_channel_id,
telegram_channel_id, violent_watch_enabled,
)
from providers import BaseDataProvider, make_data_provider
from agent_panel import attach_panel, panel_health
@@ -77,6 +77,7 @@ from market_outlook import maybe_run_outlook
from news_engine import FundamentalContext, NewsEngine
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
from violent_watch import ViolentWatch, attach_watch, should_send_headsup
_APP = get_app_config()
@@ -410,6 +411,10 @@ def format_signal_message(report: SignalReport) -> str:
extra.append(f"📋 {ind_summary}")
for w in report.warnings:
extra.append(f"⚠️ {_escape_md(w)}")
# Display-only violent-move watch (violent_watch.attach_watch stamps
# it post-hoc; it is empty unless the feature is enabled).
if report.watch_line:
extra.append(_escape_md(report.watch_line))
if report.news_summary:
extra.append(f"📰 {_escape_md(report.news_summary)}")
if report.panel_verdict:
@@ -753,6 +758,33 @@ def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
return remaining
# ── Violent-move watch (display-only) ────────────────────────────────
# Per-process heads-up budget: {"day": date, "count": int, "sent": {...}}.
_watch_state: Dict[str, Any] = {}
def _format_headsup(report: SignalReport, w: "ViolentWatch") -> str:
"""Standalone owner heads-up. Advisory only — it never asks anyone
to touch an open trade: every protective action tested lost money
(sizing_campaign_2026-09.md), so the stop stays the protection."""
lines = [
f"🌪 *Heads-up — {_escape_md(report.display_name)}*: "
f"violent-move conditions for the next hour.",
_escape_md(" · ".join(w.triggers)),
]
if w.likely_direction == "down":
lines.append("If a big move comes, it has usually gone DOWN from "
"readings like these.")
elif w.likely_direction == "up":
lines.append("If a big move comes, it has usually gone UP from "
"readings like these.")
lines.append(
"If you have a trade open, expect big bars. Your stop is the "
"protection — past tests show closing or tightening early loses "
"money.")
return "\n".join(lines)
async def _scan_timeframe(
bot,
tf: Timeframe,
@@ -779,6 +811,38 @@ async def _scan_timeframe(
_update_level_cache(reports)
# Violent-move watch: DISPLAY-ONLY. Runs before the alert loop so
# the stamped line is present when format_signal_message renders,
# and can never break an alert (whole block is best-effort).
try:
vw_settings = get_app_config().violent_watch_settings
if violent_watch_enabled():
now = datetime.now(timezone.utc)
for report in reports:
w = attach_watch(report, tf, vw_settings, now)
if not (tf == _HEADSUP_TF
and vw_settings.get("headsup", True)
and OWNER_ID is not None):
continue
min_level = vw_settings.get("headsup_min_level", "hot")
if w.level == "hot" or (min_level == "watch"
and w.level == "watch"):
ok, why = should_send_headsup(
_watch_state, now, w.kinds,
int(vw_settings.get("max_headsup_per_day", 12)))
if ok:
await _send_alert(bot, OWNER_ID,
_format_headsup(report, w))
else:
logger.info(
f"violent watch: heads-up skipped ({why})")
else:
logger.debug(
f"violent watch: {report.symbol} {tf.value} "
f"level={w.level} (min {min_level}) — no heads-up")
except Exception:
logger.exception("violent watch failed")
candidates = [
r for r in reports
if r.final_signal != SignalStrength.NEUTRAL # same gate as breach path
@@ -1100,6 +1164,15 @@ _AUTOSCAN_LOOPS = [
(tf, _TF_INTERVAL_MINUTES[tf]) for tf in _APP.scan_timeframes
]
# The violent-move heads-up is evaluated on the FASTEST scanned loop
# only — it is a "next hour" warning, so once per fastest candle is the
# most frequent it can be, and every slower loop would just repeat it.
_HEADSUP_TF: Optional[Timeframe] = min(
_APP.scan_timeframes,
key=lambda tf: _TF_INTERVAL_MINUTES.get(tf, 10 ** 6),
default=None,
)
_ws_task: Optional[asyncio.Task] = None
+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
# ══════════════════════════════════════════════════════════════════════
+410
View File
@@ -0,0 +1,410 @@
"""
ForexBot2 — violent-move watch (DISPLAY-ONLY heads-up)
=======================================================
Flags the conditions under which a "violent bar" (range >= 3x the prior
ATR14) is unusually likely, so a human reading an alert knows the next
hour may be wild. It is a WARNING LINE and a standalone heads-up
message — nothing here ever changes a signal, grade, gate, trade setup
or backtest result.
Hard independence rule (mirrors market_outlook.py / agent_panel.py):
this module is NEVER imported by `signal_aggregator`, `engine`, or
anything under `backtest/`. Surfaces (telegram_bot, main, the dashboard
publisher) attach the result POST-HOC via `attach_watch`, exactly the
way panels are attached.
Evidence — 3-year study, backtest/research/sizing_campaign_2026-09.md
(addenda):
* A violent bar is a bar whose range >= 3x the prior ATR14.
* Hot clock slots run about 3-4x the normal rate: bars closing
12:00-14:59 UTC on weekdays, the 01:xx UTC hour on any weekday, and
the Sunday reopen (22:xx-23:xx UTC).
* The 30 minutes after a high-impact US release run about 10x normal.
Share of releases followed by a violent bar within the hour: NFP
97%, CPI 91%, FOMC 88%, retail sales 64%, PPI 54%, PCE 50%,
jobless claims 44%.
* Indicator readings taken BEFORE the bar each lift the odds 1.3-3x:
price outside the Bollinger bands, RSI < 30 or >= 70, close outside
the Keltner channel, a rising ADX (slope > 3), volatility regime
"extreme", market regime "transitioning", and MACD histogram
negative and accelerating down (the engine emits STRONG_SELL for
exactly that case).
* Only the three PRICE-EXTREME readings (Bollinger, RSI, Keltner)
earn a level. A bar-by-bar sweep of the 3-year window showed the
first draft — which let any two readings raise the level — flagged
27% of all bars "hot" at merely ~2x odds (~13 heads-ups/day). The
shipped rule (calendar, or a clock slot plus one price extreme)
flags 8.6% of bars: next-bar odds 3-5x normal, ~20% chance within
the hour, about 4 hot hours a day. The remaining readings (ADX
slope, volatility regime, regime transition, MACD acceleration) are
CONTEXT: they are printed once a level exists, and never create one.
* Direction: bearish readings preceded a DOWN burst 70-78% of the
time on BOTH M5 and M15. Bullish readings preceded an UP burst only
~60% of the time and only on M5 — so an "up" call is made on M5
alone.
* Open trades are NOT to be touched. Every protective action tested
(early close, tightened stop, partial exit) lost money; the stop
already IS the protection. The heads-up says so explicitly.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from config import SignalStrength, Timeframe
logger = logging.getLogger(__name__)
# Calendar window either side of a high-impact US release (minutes).
RELEASE_WINDOW_MINUTES = 30
# Hot clock slots (UTC), from the 3-year bar study.
US_DATA_HOURS = (12, 13, 14)
ASIAN_OPEN_HOUR = 1
SUNDAY_REOPEN_HOURS = (22, 23)
# ADX slope above this counts as "trend strength rising".
ADX_SLOPE_HOT = 3.0
_LEVEL_NONE = "none"
_LEVEL_WATCH = "watch"
_LEVEL_HOT = "hot"
@dataclass
class ViolentWatch:
"""One scan's violent-move assessment. Advisory/display-only."""
level: str = _LEVEL_NONE # "none" | "watch" | "hot"
triggers: List[str] = field(default_factory=list) # plain language
kinds: List[str] = field(default_factory=list) # calendar/clock/extreme
readings: int = 0 # PRICE-EXTREME readings (0..3)
likely_direction: str = "" # "down" | "up" | ""
def line(self) -> str:
"""One-line alert text; "" when nothing fired."""
if self.level == _LEVEL_NONE or not self.triggers:
return ""
if self.level == _LEVEL_HOT:
head = "🌪 Violent-move watch (HOT): "
odds = " — odds of a violent bar about 3–5× normal"
else:
head = "🌪 Violent-move watch (watch): "
odds = " — about 2× normal"
tail = ""
if self.likely_direction == "down":
tail = " — if one comes it has usually gone DOWN"
elif self.likely_direction == "up":
tail = " — if one comes it has usually gone UP"
return head + " · ".join(self.triggers) + odds + tail
# ── Calendar ─────────────────────────────────────────────────────────
def _is_high_impact_usd(event: Any) -> bool:
"""USD + high impact, tolerating either the `impact` string or the
`is_high_impact` property (EconomicEvent exposes both)."""
try:
if str(getattr(event, "currency", "")).upper() != "USD":
return False
impact = getattr(event, "impact", None)
if isinstance(impact, str):
return impact.strip().lower() == "high"
return bool(getattr(event, "is_high_impact", False))
except Exception:
return False
def _calendar_trigger(events: Optional[List[Any]],
now: datetime) -> Optional[str]:
"""Soonest high-impact USD release inside +/-30 min of `now`."""
if not events:
return None
window = timedelta(minutes=RELEASE_WINDOW_MINUTES)
best: Optional[Tuple[float, str]] = None
for ev in events or []:
if not _is_high_impact_usd(ev):
continue
when = getattr(ev, "time", None)
if not isinstance(when, datetime) or when.tzinfo is None:
continue
try:
delta = when - now
except Exception:
continue
name = str(getattr(ev, "event", "") or "US release").strip()
if timedelta(0) < delta <= window:
minutes = max(1, int(round(delta.total_seconds() / 60)))
text = f"US high-impact release in {minutes} min ({name})"
rank = abs(delta.total_seconds())
elif -window <= delta < timedelta(0):
minutes = max(1, int(round(-delta.total_seconds() / 60)))
text = f"US release just out ({name}, {minutes} min ago)"
rank = abs(delta.total_seconds())
else:
continue
if best is None or rank < best[0]:
best = (rank, text)
return None if best is None else best[1]
# ── Clock ────────────────────────────────────────────────────────────
def _clock_trigger(now: datetime) -> Optional[str]:
"""Hot clock slot for `now` (UTC), or None."""
try:
moment = now.astimezone(timezone.utc) if now.tzinfo else now
except Exception:
return None
weekday, hour = moment.weekday(), moment.hour # Mon=0 .. Sun=6
if weekday <= 4 and hour in US_DATA_HOURS:
return "US data hours (12:00–15:00 UTC)"
if weekday <= 4 and hour == ASIAN_OPEN_HOUR:
return "Asian open hour (01:00 UTC)"
if weekday == 6 and hour in SUNDAY_REOPEN_HOURS:
return "Sunday reopen"
return None
# ── Indicator readings ───────────────────────────────────────────────
def _indicator_value(analysis: Any, name: str) -> Optional[float]:
try:
ind = (analysis.indicators or {}).get(name)
if ind is None:
return None
value = float(ind.value)
except Exception:
return None
if value != value: # NaN
return None
return value
def _indicator_signal(analysis: Any, name: str) -> Optional[SignalStrength]:
try:
ind = (analysis.indicators or {}).get(name)
return None if ind is None else ind.signal
except Exception:
return None
def _keltner_side(analysis: Any) -> Optional[str]:
""""above" / "below" / None — is the close outside the channel?
The engine's keltner indicator carries `value = close - kc_mid` and
stashes kc_upper/kc_lower/kc_mid in `key_levels` (engine.py
_compute_keltner). Comparing the value against the band half-widths
is therefore exact AND survives the squeeze branches, which
overwrite the indicator's signal/description but still publish the
same levels. The description text is only a fallback for analyses
that were built without key_levels.
"""
value = _indicator_value(analysis, "keltner")
levels = getattr(analysis, "key_levels", None) or {}
try:
upper = levels.get("kc_upper")
lower = levels.get("kc_lower")
mid = levels.get("kc_mid")
if value is not None and None not in (upper, lower, mid):
if value > float(upper) - float(mid):
return "above"
if value < float(lower) - float(mid):
return "below"
return None
except Exception:
pass
try:
desc = str((analysis.indicators or {})["keltner"].description).lower()
except Exception:
return None
if "above keltner upper" in desc:
return "above"
if "below keltner lower" in desc:
return "below"
return None
def _collect_readings(analysis: Any) -> Tuple[List[str], List[str], int, int]:
"""(price_extreme_phrases, context_phrases, bearish, bullish).
Only the PRICE-EXTREME group earns a level (see the module
docstring); the context group is printed alongside once a level
exists but can never create one. Both groups feed direction.
"""
extreme: List[str] = []
context: List[str] = []
bear = bull = 0
if analysis is None:
return extreme, context, bear, bull
# ── Price extremes (level-bearing) ──
bb = _indicator_value(analysis, "bollinger")
if bb is not None:
if bb < 0:
extreme.append("price below the Bollinger bands")
bear += 1
elif bb > 1:
extreme.append("price above the Bollinger bands")
bull += 1
rsi = _indicator_value(analysis, "rsi")
if rsi is not None:
if rsi < 30:
extreme.append(f"RSI {rsi:.0f}, oversold extreme")
bear += 1
elif rsi >= 70:
extreme.append(f"RSI {rsi:.0f}, overbought extreme")
bull += 1
kc_side = _keltner_side(analysis)
if kc_side is not None:
extreme.append("price outside the Keltner channel")
if kc_side == "above":
bull += 1
else:
bear += 1
# ── Context (never raises the level) ──
slope = getattr(analysis, "_adx_slope", None)
try:
if slope is not None and float(slope) > ADX_SLOPE_HOT:
context.append("trend strength rising")
except (TypeError, ValueError):
pass
if str(getattr(analysis, "volatility_regime", "")) == "extreme":
context.append("volatility extreme")
regime = str(getattr(analysis, "market_regime", ""))
if regime == "transitioning":
context.append("regime transitioning")
macd = _indicator_signal(analysis, "macd")
if macd == SignalStrength.STRONG_SELL:
context.append("downside momentum accelerating")
bear += 1
elif macd == SignalStrength.STRONG_BUY:
context.append("upside momentum accelerating")
bull += 1
# Regime contributes direction only — it has no phrase of its own
# beyond "regime transitioning" above.
if regime == "trending_down":
bear += 1
elif regime == "trending_up":
bull += 1
return extreme, context, bear, bull
# ── Assessment ───────────────────────────────────────────────────────
def assess(report: Any,
now: datetime,
events: Optional[List[Any]] = None,
primary_tf: Optional[Timeframe] = None) -> ViolentWatch:
"""Score violent-move conditions. Every input may be missing or
malformed — the answer then degrades to "no trigger". Never raises."""
triggers: List[str] = []
kinds: List[str] = []
calendar = _calendar_trigger(events, now)
if calendar:
triggers.append(calendar)
kinds.append("calendar")
clock = _clock_trigger(now)
if clock:
triggers.append(clock)
kinds.append("clock")
analysis = getattr(report, "primary_analysis", None)
extreme_phrases, context_phrases, bear, bull = _collect_readings(analysis)
triggers.extend(extreme_phrases)
readings = len(extreme_phrases)
if readings >= 1:
kinds.append("extreme")
# Level rule (bar-by-bar validated, see module docstring): only
# price extremes and the clock/calendar create a level.
if calendar or (clock and readings >= 1):
level = _LEVEL_HOT
elif clock or readings >= 2:
level = _LEVEL_WATCH
else:
level = _LEVEL_NONE
# Context readings are colour, printed only once a level exists.
if level != _LEVEL_NONE:
triggers.extend(context_phrases)
direction = ""
if bear >= 2 and bear > bull:
direction = "down"
elif bull >= 2 and bull > bear:
# Bullish readings only earned a call on M5 in the study.
tf_value = getattr(primary_tf, "value", primary_tf)
if tf_value == Timeframe.M5.value:
direction = "up"
return ViolentWatch(level=level, triggers=triggers, kinds=kinds,
readings=readings, likely_direction=direction)
def attach_watch(report: Any,
primary_tf: Optional[Timeframe],
settings: Dict[str, Any],
now: Optional[datetime] = None) -> ViolentWatch:
"""Stamp `report.watch_line` with the display line (advisory only).
Contract: NEVER raises. A broken watch must not cost an alert send
or a dashboard publish — a failure logs and returns level "none".
"""
try:
settings = settings or {}
if not settings.get("enabled", False):
return ViolentWatch()
moment = now or datetime.now(timezone.utc)
events: Optional[List[Any]] = None
try:
# Lazy import keeps this module import-light and free of any
# signal-path dependency. The calendar rides NewsEngine's
# class-level cache, so a warm cache costs no network I/O.
from config import PlatformConfig
from news_engine import NewsEngine
events = NewsEngine(PlatformConfig().news).get_week_events(["USD"])
except Exception as exc:
logger.debug("violent watch: calendar unavailable (%s)", exc)
watch = assess(report, moment, events, primary_tf)
if settings.get("alert_line", True):
report.watch_line = watch.line()
return watch
except Exception:
logger.exception("violent watch: assessment failed")
return ViolentWatch()
def should_send_headsup(state: Dict[str, Any],
now: datetime,
kinds: List[str],
max_per_day: int) -> Tuple[bool, str]:
"""Rate-limit the standalone heads-up message.
`state` is a mutable dict owned by the caller:
{"day": date, "count": int, "sent": {(hour_iso, kinds): True}}
One message per UTC hour per trigger combination, capped per UTC day.
"""
day = now.date()
if state.get("day") != day:
state["day"] = day
state["count"] = 0
state["sent"] = {}
sent = state.setdefault("sent", {})
if int(state.get("count", 0)) >= int(max_per_day):
return False, "daily cap"
hour_bucket = now.replace(minute=0, second=0, microsecond=0).isoformat()
key = (hour_bucket, tuple(sorted(kinds)))
if key in sent:
return False, "already sent this hour for these triggers"
sent[key] = True
state["count"] = int(state.get("count", 0)) + 1
return True, "ok"