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:
+75
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user