gcloud-lab/apps/vwap-monitor/app/scanner.py

206 lines
7.3 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
VWAP Wave Breach Scanner
========================
Continuous monitoring daemon. Scans instruments on a schedule,
detects VWAP wave breaches, and pushes alerts via Telegram.
Environment variables:
TELEGRAM_BOT_TOKEN Telegram Bot API token (required)
TELEGRAM_CHAT_ID Chat ID to send alerts to (required)
SCAN_INTERVAL_SEC Seconds between scans (default: 120)
BREACH_THRESHOLD Sigma threshold for alerts (default: 2.0)
No LLM overhead pure Python, ~20MB RAM.
"""
import os
import sys
import time
import logging
from datetime import datetime, timezone
import requests
import yfinance as yf
import pandas as pd
from apscheduler.schedulers.background import BlockingScheduler
# ── Config ──────────────────────────────────────────────────────────────────
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
TELEGRAM_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "120"))
BREACH_THRESHOLD = float(os.environ.get("BREACH_THRESHOLD", "2.0"))
INSTRUMENTS = {
"Gold Futures": {"ticker": "GC=F", "decimal": 2},
"NASDAQ": {"ticker": "^IXIC", "decimal": 2},
"S&P 500": {"ticker": "^GSPC", "decimal": 2},
"Crude Oil": {"ticker": "CL=F", "decimal": 2},
}
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)
# Track last alert state to prevent spam (no repeat within same threshold direction)
_last_alert = {}
# ── Telegram ────────────────────────────────────────────────────────────────
def send_telegram(message: str) -> bool:
"""Send a message via Telegram Bot API."""
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
try:
resp = requests.post(url, json=payload, timeout=10)
resp.raise_for_status()
log.info("Telegram alert sent: %s", message[:80])
return True
except Exception as e:
log.error("Telegram send failed: %s", e)
return False
# ── VWAP Calculation ────────────────────────────────────────────────────────
def compute_vwap(data: pd.DataFrame) -> dict | None:
"""Compute cumulative VWAP, σ, and deviation."""
df = data.copy()
df["typical"] = (df["High"] + df["Low"] + df["Close"]) / 3.0
df["tp_vol"] = df["typical"] * df["Volume"]
cum_tp_vol = df["tp_vol"].cumsum()
cum_vol = df["Volume"].cumsum().replace(0, 1)
df["cum_vwap"] = cum_tp_vol / cum_vol
df["deviation"] = df["typical"] - df["cum_vwap"]
df["cum_var"] = (df["deviation"] ** 2).cumsum() / cum_vol
df["sigma"] = df["cum_var"] ** 0.5
last = df.iloc[-1]
if last["sigma"] <= 0:
return None
return {
"price": last["Close"],
"vwap": last["cum_vwap"],
"sigma": last["sigma"],
"dev_sigmas": (last["Close"] - last["cum_vwap"]) / last["sigma"],
}
def fetch_data(ticker: str) -> pd.DataFrame:
"""Fetch recent intraday data via yfinance."""
try:
data = yf.Ticker(ticker).history(period="1d", interval="1m", auto_adjust=True)
except Exception:
data = pd.DataFrame()
if len(data) < 30:
data = yf.Ticker(ticker).history(period="5d", interval="1m", auto_adjust=True)
cutoff = pd.Timestamp.now(tz=data.index.tz) - pd.Timedelta(hours=24)
data = data[data.index >= cutoff]
return data
# ── Scanner ─────────────────────────────────────────────────────────────────
def run_scan() -> None:
"""Execute a full scan cycle and push any breaches."""
ts = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
log.info("── Scan %s ──", ts)
breaches = []
for name, cfg in INSTRUMENTS.items():
try:
data = fetch_data(cfg["ticker"])
if data.empty or len(data) < 20:
log.warning("SKIP %s — insufficient data (%d bars)", name, len(data))
continue
info = compute_vwap(data)
if info is None:
log.warning("SKIP %s — zero sigma", name)
continue
d = cfg["decimal"]
dev = info["dev_sigmas"]
log.info(" %-14s $%10.2f | VWAP $%10.2f | %+.2fσ", name, info["price"], info["vwap"], dev)
if abs(dev) >= BREACH_THRESHOLD:
# Prevent repeat spam: only alert if state changed
alert_key = f"{name}:{dev > 0}"
if _last_alert.get(alert_key) == "breach":
log.info("%s already in breach, skipping repeat", name)
continue
breaches.append((name, cfg["decimal"], dev, info["price"], info["vwap"], info["sigma"]))
_last_alert[alert_key] = "breach"
else:
_last_alert[f"{name}:True"] = "clean"
_last_alert[f"{name}:False"] = "clean"
except Exception as e:
log.error("ERROR %s: %s", name, e)
# Push alerts
for name, d, dev, price, vwap, sigma in breaches:
direction = "⬆️ UP" if dev > 0 else "⬇️ DOWN"
band_label = f"±{int(abs(dev))}σ"
severity = "🚨 **EXTREME**" if abs(dev) >= 3.0 else "⚡ **BREACH**"
msg = (
f"{severity} — VWAP Wave Alert\n\n"
f"**{name}** broke through **{band_label}** band\n"
f"Deviation: **{dev:+.2f}σ**\n"
f"Price: **${price:.{d}f}** | VWAP: **${vwap:.{d}f}**\n"
f"σ: ${sigma:.{d}f} | {direction}\n\n"
f"_at {ts}_"
)
send_telegram(msg)
# ── Main ────────────────────────────────────────────────────────────────────
def main() -> None:
log.info("=" * 60)
log.info(" VWAP Wave Breach Scanner")
log.info(" Interval: %d sec | Threshold: ±%.1fσ", SCAN_INTERVAL_SEC, BREACH_THRESHOLD)
log.info(" Telegram: @chat_id=%s", TELEGRAM_CHAT_ID)
log.info("=" * 60)
# Validate Telegram connectivity
send_telegram(
"🟢 *VWAP Breach Scanner* is online.\n"
f"Scanning every **{SCAN_INTERVAL_SEC}s** — threshold ±**{BREACH_THRESHOLD:.1f}σ**\n"
f"Monitoring: Gold, NASDAQ, S&P 500, Crude Oil"
)
scheduler = BlockingScheduler()
scheduler.add_job(run_scan, "interval", seconds=SCAN_INTERVAL_SEC, id="scan")
# Run immediately on start
run_scan()
log.info("Scanner running. Press Ctrl+C to stop.")
try:
scheduler.start()
except KeyboardInterrupt:
log.info("Shutting down...")
scheduler.shutdown()
if __name__ == "__main__":
main()