gcloud-lab/trading-scripts/orb-monitor/monitor.py
2026-04-28 03:31:52 +00:00

305 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()