You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""ORB single-ticker advisor.
|
|
|
|
Wraps the same data fetch + pre-screen + run_orb_simulation pipeline the
|
|
intraday backtester uses, scoped to one (ticker, asof). Verdicts match the
|
|
historical backtest entries on that date for that ticker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import os
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import yaml
|
|
from pydantic import BaseModel, Field
|
|
|
|
from libs.common.logging import get_logger
|
|
from libs.intraday.cache import DailyBarCache, IntradayCache
|
|
from libs.intraday.domain import ORBStrategyParams
|
|
from libs.intraday.features import enrich_daily_bars
|
|
from libs.intraday.orb_simulator import run_orb_simulation
|
|
from libs.intraday.screener import (
|
|
fetch_daily_bars_bulk,
|
|
fetch_intraday_bulk,
|
|
orb_pre_screen_candidates,
|
|
)
|
|
from libs.oracle_client.client import OracleClient
|
|
|
|
logger = get_logger(__name__)
|
|
_ET = ZoneInfo("America/New_York")
|
|
_MARKET_OPEN = dt.time(9, 30)
|
|
_MARKET_CLOSE = dt.time(16, 0)
|
|
|
|
|
|
Signal = Literal["BUY", "SELL", "HOLD", "NO_SIGNAL", "ERROR"]
|
|
|
|
|
|
class OrbVerdict(BaseModel):
|
|
signal: Signal
|
|
score: float | None = None
|
|
reason: str
|
|
details: dict[str, Any] = Field(default_factory=dict)
|
|
warning: str | None = None
|
|
|
|
|
|
def _oracle_url() -> str:
|
|
return os.environ.get("ORACLE_URL") or os.environ.get(
|
|
"STOCK_ORACLE_URL", "http://localhost:8000"
|
|
)
|
|
|
|
|
|
def _load_orb_params(yaml_path: str | Path) -> ORBStrategyParams:
|
|
with open(yaml_path) as f:
|
|
raw = yaml.safe_load(f) or {}
|
|
return ORBStrategyParams(**raw.get("orb_strategy", {}))
|
|
|
|
|
|
def _support_tickers(params: ORBStrategyParams) -> list[str]:
|
|
"""Auxiliary tickers needed for regime / market-quality gates."""
|
|
candidates: set[str] = {"SPY", "QQQ"}
|
|
for attr in (
|
|
"market_regime_ticker",
|
|
"market_orb_quality_ticker",
|
|
"market_orb_quality_secondary_ticker",
|
|
):
|
|
value = getattr(params, attr, None)
|
|
if value:
|
|
candidates.add(str(value))
|
|
return sorted(candidates)
|
|
|
|
|
|
def _now_et() -> dt.datetime:
|
|
return dt.datetime.now(_ET)
|
|
|
|
|
|
def _orb_window_end(params: ORBStrategyParams, asof: dt.date) -> dt.datetime:
|
|
orb_min = int(getattr(params, "orb_minutes", 5) or 5)
|
|
return dt.datetime.combine(asof, _MARKET_OPEN, tzinfo=_ET) + timedelta(minutes=orb_min)
|
|
|
|
|
|
async def evaluate_orb_buy(
|
|
ticker: str,
|
|
asof: dt.date,
|
|
yaml_path: str | Path,
|
|
) -> OrbVerdict:
|
|
params = _load_orb_params(yaml_path)
|
|
upper = ticker.upper()
|
|
|
|
now = _now_et()
|
|
today_et = now.date()
|
|
warning: str | None = None
|
|
|
|
if asof > today_et:
|
|
return OrbVerdict(
|
|
signal="ERROR",
|
|
reason=f"asof={asof.isoformat()} is in the future",
|
|
)
|
|
if asof == today_et:
|
|
if now.time() < _MARKET_OPEN:
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason="market not yet open in ET; ORB requires the opening 5-min range",
|
|
)
|
|
orb_end_dt = _orb_window_end(params, asof)
|
|
if now < orb_end_dt:
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason=(
|
|
f"ORB window not yet closed; check back at "
|
|
f"{orb_end_dt.strftime('%H:%M ET')} "
|
|
f"(orb_minutes={params.orb_minutes})"
|
|
),
|
|
)
|
|
if now.time() >= _MARKET_CLOSE:
|
|
warning = "ORB session is over for today; this is a historical signal"
|
|
else:
|
|
warning = "ORB session in progress; intraday data may be partial"
|
|
else:
|
|
warning = f"evaluating historical date {asof.isoformat()}; signal is post-hoc"
|
|
|
|
asof_str = asof.isoformat()
|
|
daily_start = (asof - timedelta(days=120)).isoformat()
|
|
tickers_needed = sorted({upper, *_support_tickers(params)})
|
|
|
|
daily_cache = DailyBarCache()
|
|
intraday_cache = IntradayCache()
|
|
|
|
async with OracleClient(base_url=_oracle_url()) as client:
|
|
daily_bars = await fetch_daily_bars_bulk(
|
|
tickers_needed,
|
|
daily_start,
|
|
asof_str,
|
|
client,
|
|
cache=daily_cache,
|
|
intraday_cache_fallback=intraday_cache,
|
|
prefer_intraday_fallback=True,
|
|
skip_oracle_when_unhealthy=True,
|
|
concurrency=8,
|
|
)
|
|
if upper not in daily_bars or not daily_bars[upper]:
|
|
return OrbVerdict(
|
|
signal="ERROR",
|
|
reason=f"no daily bars available for {upper}; ticker invalid or data unavailable",
|
|
warning=warning,
|
|
)
|
|
if not any(str(b.get("date", ""))[:10] == asof_str for b in daily_bars[upper]):
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason=(
|
|
f"no daily bar for {upper} on {asof_str}; not a trading day or data missing"
|
|
),
|
|
warning=warning,
|
|
)
|
|
|
|
trading_days = [asof_str]
|
|
enrichment = enrich_daily_bars(daily_bars, trading_days)
|
|
|
|
candidate_dict = orb_pre_screen_candidates(
|
|
daily_bars,
|
|
trading_days,
|
|
enrichment,
|
|
min_price=params.min_price,
|
|
min_atr=params.min_atr_14,
|
|
min_avg_dollar_vol=params.min_avg_dollar_volume,
|
|
max_per_day=None,
|
|
)
|
|
|
|
if upper not in candidate_dict.get(asof_str, []):
|
|
ticker_enrich = enrichment.get(upper, {}).get(asof_str, {})
|
|
atr = ticker_enrich.get("atr_14")
|
|
adv = ticker_enrich.get("avg_dollar_vol_30d")
|
|
today_open = ticker_enrich.get("today_open")
|
|
reasons: list[str] = []
|
|
if today_open is not None and today_open < params.min_price:
|
|
reasons.append(f"open ${today_open:.2f} < min_price ${params.min_price}")
|
|
if atr is None or atr < params.min_atr_14:
|
|
reasons.append(f"atr_14={atr} < {params.min_atr_14}")
|
|
if adv is None or adv < params.min_avg_dollar_volume:
|
|
reasons.append(
|
|
f"avg_dollar_vol_30d={adv} < {params.min_avg_dollar_volume:.0f}"
|
|
)
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason=(
|
|
f"pre-screen rejected {upper}: "
|
|
f"{', '.join(reasons) or 'liquidity/volatility gate'}"
|
|
),
|
|
details={
|
|
"atr_14": atr,
|
|
"avg_dollar_vol_30d": adv,
|
|
"today_open": today_open,
|
|
},
|
|
warning=warning,
|
|
)
|
|
|
|
intraday_candidates: dict[str, list[str]] = {asof_str: tickers_needed}
|
|
all_intraday = await fetch_intraday_bulk(
|
|
intraday_candidates,
|
|
client,
|
|
intraday_cache,
|
|
concurrency=4,
|
|
skip_oracle_when_unhealthy=True,
|
|
)
|
|
|
|
if upper not in all_intraday.get(asof_str, {}):
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason=f"no intraday bars available for {upper} on {asof_str}",
|
|
warning=warning,
|
|
)
|
|
|
|
ticker_sectors = {t: "UNKNOWN" for t in tickers_needed}
|
|
day_results = run_orb_simulation(
|
|
all_intraday=all_intraday,
|
|
trading_days=[asof_str],
|
|
params=params,
|
|
enrichment=enrichment,
|
|
ticker_sectors=ticker_sectors,
|
|
)
|
|
if not day_results:
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason="ORB simulator returned no day result",
|
|
warning=warning,
|
|
)
|
|
|
|
day = day_results[0]
|
|
ticker_trades = [t for t in day.trades if t.ticker.upper() == upper]
|
|
if not ticker_trades:
|
|
skip_msg = day.skip_reason or "no entry generated (gap/RVOL/breakout filter)"
|
|
return OrbVerdict(
|
|
signal="NO_SIGNAL",
|
|
reason=f"ORB simulation produced no trade for {upper}: {skip_msg}",
|
|
details={
|
|
"day_skip_reason": day.skip_reason,
|
|
"candidates_found": day.candidates_found,
|
|
"regime_scaler": day.regime_scaler,
|
|
"breadth_scaler": day.breadth_scaler,
|
|
"candidate_filter_stats": day.candidate_filter_stats,
|
|
},
|
|
warning=warning,
|
|
)
|
|
|
|
trade = ticker_trades[0]
|
|
return OrbVerdict(
|
|
signal="BUY",
|
|
reason=f"ORB breakout entry @ {trade.entry_time}",
|
|
details={
|
|
"entry_price": trade.entry_price,
|
|
"entry_time": trade.entry_time,
|
|
"shares": trade.shares,
|
|
"atr_at_entry": trade.atr_at_entry,
|
|
"rvol": trade.rvol,
|
|
"gap_pct": trade.gap_pct,
|
|
"orb_direction": trade.orb_direction,
|
|
"simulated_exit_price": trade.exit_price,
|
|
"simulated_exit_time": trade.exit_time,
|
|
"simulated_exit_reason": trade.exit_reason,
|
|
},
|
|
warning=warning,
|
|
)
|
|
|
|
|
|
async def evaluate_orb_sell(
|
|
ticker: str,
|
|
entry_date: dt.date,
|
|
asof: dt.date,
|
|
yaml_path: str | Path,
|
|
) -> OrbVerdict:
|
|
"""ORB exits intraday on the entry day at 15:55 ET.
|
|
|
|
If the user holds past the entry day, the position is outside ORB's risk
|
|
model — return HOLD with a manual-exit warning. If entry_date == asof,
|
|
re-run the simulation and report whether the modeled exit has fired.
|
|
"""
|
|
upper = ticker.upper()
|
|
|
|
if entry_date != asof:
|
|
return OrbVerdict(
|
|
signal="HOLD",
|
|
reason=(
|
|
f"ORB exits intraday at 15:55 ET on the entry day; entry_date="
|
|
f"{entry_date.isoformat()} != asof={asof.isoformat()} means this position "
|
|
"is outside ORB's lifecycle. Consider manual exit."
|
|
),
|
|
warning="position outside ORB lifecycle — strategy provides no further signal",
|
|
)
|
|
|
|
buy = await evaluate_orb_buy(upper, asof, yaml_path)
|
|
if buy.signal != "BUY":
|
|
return OrbVerdict(
|
|
signal="HOLD",
|
|
reason=(
|
|
f"no ORB entry simulated for {upper} on {asof.isoformat()}; cannot derive "
|
|
"an engine-consistent exit"
|
|
),
|
|
details=buy.details,
|
|
warning=buy.warning or "ORB intraday in progress; check back after 16:00 ET",
|
|
)
|
|
|
|
sim_exit_time = buy.details.get("simulated_exit_time")
|
|
sim_exit_reason = buy.details.get("simulated_exit_reason")
|
|
sim_exit_price = buy.details.get("simulated_exit_price")
|
|
if not sim_exit_time:
|
|
return OrbVerdict(
|
|
signal="HOLD",
|
|
reason="ORB simulation has no exit yet; intraday session still in progress",
|
|
warning="ORB exits at 15:55 ET; check back after market close",
|
|
)
|
|
|
|
now = _now_et()
|
|
exit_dt: dt.datetime | None = None
|
|
try:
|
|
exit_dt = dt.datetime.fromisoformat(str(sim_exit_time).replace("Z", "+00:00"))
|
|
if exit_dt.tzinfo is None:
|
|
exit_dt = exit_dt.replace(tzinfo=_ET)
|
|
except Exception:
|
|
exit_dt = None
|
|
|
|
base = {
|
|
"exit_time": sim_exit_time,
|
|
"exit_reason": sim_exit_reason,
|
|
"exit_price": sim_exit_price,
|
|
}
|
|
if exit_dt is None or exit_dt <= now:
|
|
return OrbVerdict(
|
|
signal="SELL",
|
|
reason=f"ORB exit fired in simulation: {sim_exit_reason}",
|
|
details=base,
|
|
warning=buy.warning,
|
|
)
|
|
return OrbVerdict(
|
|
signal="HOLD",
|
|
reason=f"simulated exit at {sim_exit_time} has not yet occurred",
|
|
details=base,
|
|
warning="ORB intraday in progress",
|
|
)
|