gcloud-lab/orb-monitor/monitor.py

306 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
ORB (Opening Range Breakout) Monitor for Futures
=================================================
Monitors session opens across global markets and detects ORB signals.
Sessions monitored (ET / UTC-4):
Asia (Tokyo) 19:00 ET (23:00 UTC)
London 03:00 ET (07:00 UTC)
New York 09:30 ET (13:30 UTC)
Strategy: After the session opens, the opening range high/low is captured
over `range_minutes`. If price later breaks above/below that range, an
ORB signal is logged.
EDUCATIONAL PURPOSE ONLY Not financial advice.
Uses yfinance (delayed data). NOT suitable for live trading.
"""
from __future__ import annotations
import sys
import logging
from datetime import datetime, timedelta, timezone
import yaml
import pandas as pd
import yfinance as yf
# ─── Constants ────────────────────────────────────────────────────────────────
# Yahoo Finance futures tickers
SYMBOLS = {
"ES": {"ticker": "ES=F", "name": "S&P 500 E-mini", "multiplier": 0.25},
"NQ": {"ticker": "NQ=F", "name": "Nasdaq 100 E-mini", "multiplier": 0.25},
"YM": {"ticker": "YM=F", "name": "Dow E-mini", "multiplier": 0.05},
"CL": {"ticker": "CL=F", "name": "Crude Oil WTI", "multiplier": 0.01},
"GC": {"ticker": "GC=F", "name": "Gold", "multiplier": 0.10},
}
# Session open times in UTC (no DST ambiguity)
SESSIONS = {
"asia": {"name": "Asia (Tokyo)", "open_utc": 23, "offset_min": 0},
"london": {"name": "London", "open_utc": 7, "offset_min": 0},
"ny": {"name": "New York", "open_utc": 13, "offset_min": 30},
}
UTC = timezone.utc
logger = logging.getLogger("ORB")
# ─── Helpers ──────────────────────────────────────────────────────────────────
def load_config(path: str = "config.yaml") -> dict:
"""Load YAML configuration."""
try:
with open(path) as fh:
return yaml.safe_load(fh)
except FileNotFoundError:
logger.warning("config.yaml not found — using defaults")
return {}
def session_utc_start(date: datetime, session: dict) -> datetime:
"""Return UTC datetime when this session opens on the given UTC date."""
return datetime(date.year, date.month, date.day,
session["open_utc"], session["offset_min"],
tzinfo=UTC)
def fetch_data(ticker: str, days: int = 5) -> pd.DataFrame:
"""Fetch intraday futures data from Yahoo Finance (1-min bars)."""
end = datetime.now(UTC)
start = end - timedelta(days=days)
try:
df = yf.download(ticker, start=start, end=end,
interval="1m", progress=False, auto_adjust=True)
if df.empty:
logger.warning(f"No data returned for {ticker}")
return df
# Flatten MultiIndex columns (yf sometimes returns ('Close', ticker), etc.)
if isinstance(df.columns, pd.MultiIndex):
df.columns = [col[0] for col in df.columns]
return df
except Exception as e:
logger.error(f"Failed to fetch {ticker}: {e}")
return pd.DataFrame()
def find_session_bars(df: pd.DataFrame, session_start: datetime,
range_minutes: int) -> pd.DataFrame | None:
"""Extract the opening-range bars for a session, if data exists."""
# Allow ±2 min tolerance for session start
tolerance = timedelta(minutes=2)
end_bound = session_start + timedelta(minutes=range_minutes) + tolerance
mask = (df.index >= session_start - tolerance) & \
(df.index < end_bound)
range_bars = df.loc[mask]
return range_bars if len(range_bars) >= 5 else None # Need meaningful data
def analyze_orb(symbol_key: str, symbol_info: dict, df: pd.DataFrame,
session_key: str, session_info: dict,
cfg_orb: dict, date: datetime) -> list[dict]:
"""Check for ORB signals in the data for a given session date."""
range_min = cfg_orb.get("range_minutes", 30)
min_range = cfg_orb.get("min_range_ticks", 4)
max_range = cfg_orb.get("max_range_ticks", 100)
multiplier = symbol_info["multiplier"]
signals: list[dict] = []
session_start = session_utc_start(date, session_info)
# Try both start date and day before (in case of overnight sessions)
for offset in [0, -1]:
check_date = date + timedelta(days=offset)
try_start = datetime(check_date.year, check_date.month, check_date.day,
session_info["open_utc"], session_info["offset_min"],
tzinfo=UTC)
range_bars = find_session_bars(df, try_start, range_min)
if range_bars is None:
continue
# Opening range high/low — force scalar extraction
range_high = range_bars["High"].max().item()
range_low = range_bars["Low"].min().item()
range_size = range_high - range_low
range_ticks = range_size / multiplier
if range_ticks < min_range or range_ticks > max_range:
continue # Skip — range too small or too large
# Look for breakout in remaining data after range period
# Skip NaN rows and only use real data
remaining = df.loc[range_bars.index[-1]:].dropna(subset=["Close"])
if remaining.empty:
continue
# Bullish breakout: price closes above range high
bullish_bars = remaining[remaining["Close"] > range_high]
if not bullish_bars.empty:
breakout_time = bullish_bars.index[0]
breakout_price = float(bullish_bars.loc[breakout_time, "Close"])
signals.append({
"symbol": symbol_key,
"name": symbol_info["name"],
"session": session_info["name"],
"direction": "LONG",
"range_high": round(range_high, 2),
"range_low": round(range_low, 2),
"range_size": round(range_size, 2),
"range_ticks": round(range_ticks, 1),
"breakout_time": breakout_time,
"breakout_price": round(breakout_price, 2),
})
# Bearish breakout: price closes below range low
bearish_bars = remaining[remaining["Close"] < range_low]
if not bearish_bars.empty:
breakout_time = bearish_bars.index[0]
breakout_price = float(bearish_bars.loc[breakout_time, "Close"])
signals.append({
"symbol": symbol_key,
"name": symbol_info["name"],
"session": session_info["name"],
"direction": "SHORT",
"range_high": round(range_high, 2),
"range_low": round(range_low, 2),
"range_size": round(range_size, 2),
"range_ticks": round(range_ticks, 1),
"breakout_time": breakout_time,
"breakout_price": round(breakout_price, 2),
})
return signals
# ─── Main ─────────────────────────────────────────────────────────────────────
def run(date_str: str | None = None, days: int = 5,
config_path: str = "config.yaml") -> list[dict]:
"""
Analyze ORB patterns for all sessions and symbols.
Args:
date_str: Optional date in YYYY-MM-DD format. If None, uses today.
days: How many days of history to fetch.
config_path: Path to config.yaml.
Returns:
List of signal dicts sorted by breakout_time.
"""
cfg = load_config(config_path)
cfg_orb = cfg.get("orb", {})
target_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) if date_str else datetime.now(UTC)
# Expand search window to cover all sessions around the target date
search_dates = [target_date + timedelta(days=d) for d in range(-1, days)]
all_signals: list[dict] = []
symbol_items = cfg.get("symbols", SYMBOLS) or SYMBOLS
for sym_key, sym_info in symbol_items.items():
ticker = sym_info.get("ticker", f"{sym_key}=F")
print(f" Fetching {ticker} ({sym_info.get('name', sym_key)}) …", flush=True)
df = fetch_data(ticker, days=days)
if df.empty:
continue
# Reconcile multiplier from config vs hardcoded
sym_info.setdefault("multiplier", SYMBOLS.get(sym_key, {}).get("multiplier", 0.25))
for sd in search_dates:
for sess_key, sess_info in SESSIONS.items():
signals = analyze_orb(
sym_key, sym_info, df,
sess_key, sess_info, cfg_orb, sd
)
all_signals.extend(signals)
# Sort by breakout time
all_signals.sort(key=lambda s: s["breakout_time"])
return all_signals
def format_report(signals: list[dict]) -> str:
"""Pretty-print ORB signals for Telegram / terminal."""
if not signals:
return (
"📊 **ORB Scan Complete — No Signals Found**\n\n"
"No opening range breakouts detected in the scanned period.\n"
"The market may be quiet, or the data may be too delayed.\n\n"
"*Run again closer to session opens for best results.*"
)
lines = [
f"📊 **ORB Signals Found** ({len(signals)} signals)",
f"_Scanned: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}_",
"" * 40,
]
for s in signals:
direction = "🟢 LONG" if s["direction"] == "LONG" else "🔴 SHORT"
lines.append(
f"\n**{s['symbol']}** ({s['name']}) — {s['session']}\n"
f"{direction}\n"
f" Range: {s['range_low']} {s['range_high']} "
f"({s['range_size']} pts / {s['range_ticks']} ticks)\n"
f" Breakout: {s['breakout_price']} at "
f"`{s['breakout_time'].strftime('%H:%M UTC')}`"
)
lines.append("\n" + "" * 40)
lines.append(
"⚠️ _Educational analysis only. Uses delayed data._\n"
"_Not financial advice. Verify with live data before trading._"
)
return "\n".join(lines)
# ─── CLI ──────────────────────────────────────────────────────────────────────
def main():
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
import argparse
parser = argparse.ArgumentParser(description="ORB Futures Monitor")
parser.add_argument("--date", type=str, default=None,
help="Target date YYYY-MM-DD (default: today)")
parser.add_argument("--days", type=int, default=5,
help="Days of history to scan (default: 5)")
parser.add_argument("--config", type=str, default="config.yaml",
help="Path to config file")
parser.add_argument("--json", action="store_true",
help="Output as JSON instead of formatted text")
args = parser.parse_args()
print("\n🔍 ORB Monitor — Scanning futures data …\n", flush=True)
signals = run(date_str=args.date, days=args.days, config_path=args.config)
if args.json:
import json
print(json.dumps(signals, indent=2, default=str))
else:
report = format_report(signals)
print(report)
# Also save to log
log_path = "orb_signals.log"
with open(log_path, "a") as fh:
fh.write(f"\n{'='*50}\n")
fh.write(f"Scan: {datetime.now(UTC).isoformat()}\n")
fh.write(report + "\n")
print(f"\n✅ Done. {len(signals)} signals detected.\n", flush=True)
if __name__ == "__main__":
main()