|
|
|
|
@ -0,0 +1,962 @@
|
|
|
|
|
"""ORB Scanner service — v49.100 manual entry/exit checker.
|
|
|
|
|
|
|
|
|
|
Provides three operations:
|
|
|
|
|
check() — single-ticker entry filter breakdown + verdict
|
|
|
|
|
exit_check() — pure-math stop/trailing state machine (no Oracle calls)
|
|
|
|
|
gainers_scan() — batched pipeline over Oracle gainers universe
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import datetime as dt
|
|
|
|
|
import os
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
from apps.intraday_bt.run import _load_config_yaml
|
|
|
|
|
from libs.common.logging import get_logger
|
|
|
|
|
from libs.intraday.cache import IntradayCache
|
|
|
|
|
from libs.intraday.domain import ORBStrategyParams
|
|
|
|
|
from libs.intraday.features import compute_rvol_approx, 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
|
|
|
|
|
from libs.oracle_client.price import PriceService
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
|
|
_MARKET_OPEN = dt.time(9, 30)
|
|
|
|
|
_ORB_END = dt.time(9, 35)
|
|
|
|
|
_ORDER_TIMEOUT = dt.time(9, 55)
|
|
|
|
|
_FORCE_EXIT_TIME = dt.time(15, 55)
|
|
|
|
|
|
|
|
|
|
_ORB_DEFAULT_STRATEGY = "orb_gainers_v49_100_mid_hot_rtg_reserve"
|
|
|
|
|
_STRATEGIES_DIR = Path("configs/intraday/strategies")
|
|
|
|
|
|
|
|
|
|
_params_cache: dict[str, ORBStrategyParams] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_params(strategy_id: str | None = None) -> ORBStrategyParams:
|
|
|
|
|
key = strategy_id or _ORB_DEFAULT_STRATEGY
|
|
|
|
|
if key not in _params_cache:
|
|
|
|
|
yaml_path = _STRATEGIES_DIR / f"{key}.yaml"
|
|
|
|
|
raw = _load_config_yaml(yaml_path)
|
|
|
|
|
_params_cache[key] = ORBStrategyParams(**raw.get("orb_strategy", {}))
|
|
|
|
|
return _params_cache[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_strategies() -> list[dict]:
|
|
|
|
|
import yaml as _yaml
|
|
|
|
|
result = []
|
|
|
|
|
for p in sorted(_STRATEGIES_DIR.glob("orb_gainers_*.yaml")):
|
|
|
|
|
try:
|
|
|
|
|
with open(p) as f:
|
|
|
|
|
raw = _yaml.safe_load(f) or {}
|
|
|
|
|
meta = raw.get("_meta") or {}
|
|
|
|
|
name = meta.get("name") or raw.get("name") or p.stem
|
|
|
|
|
result.append({"id": p.stem, "name": name})
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _oracle_url() -> str:
|
|
|
|
|
return os.environ.get("STOCK_ORACLE_URL", "http://localhost:18001")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _now_et() -> dt.datetime:
|
|
|
|
|
return dt.datetime.now(_ET)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _market_status_str() -> str:
|
|
|
|
|
t = _now_et().time()
|
|
|
|
|
if t < _MARKET_OPEN:
|
|
|
|
|
return "pre_market"
|
|
|
|
|
elif t < _ORB_END:
|
|
|
|
|
return "orb_forming"
|
|
|
|
|
elif t < _ORDER_TIMEOUT:
|
|
|
|
|
return "entry_window"
|
|
|
|
|
elif t < _FORCE_EXIT_TIME:
|
|
|
|
|
return "active"
|
|
|
|
|
else:
|
|
|
|
|
return "closed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _premarket_dollar_vol(bars: list[dict], date_str: str) -> float:
|
|
|
|
|
"""Sum close*volume for premarket bars (04:00-09:30 ET) on date_str."""
|
|
|
|
|
total = 0.0
|
|
|
|
|
for bar in bars:
|
|
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
|
|
if not ts_raw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ts = dt.datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
|
|
|
if ts.tzinfo is None:
|
|
|
|
|
ts = ts.replace(tzinfo=_ET)
|
|
|
|
|
ts_et = ts.astimezone(_ET)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.date().isoformat() != date_str:
|
|
|
|
|
continue
|
|
|
|
|
if not (dt.time(4, 0) <= ts_et.time() < _MARKET_OPEN):
|
|
|
|
|
continue
|
|
|
|
|
price = bar.get("close") or bar.get("open") or 0.0
|
|
|
|
|
volume = bar.get("volume") or 0.0
|
|
|
|
|
if price > 0 and volume > 0:
|
|
|
|
|
total += price * volume
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _orb_bar(bars: list[dict], date_str: str) -> dict | None:
|
|
|
|
|
"""Find the first regular 5-min bar at 09:30 ET on date_str."""
|
|
|
|
|
candidates: list[tuple[dt.datetime, dict]] = []
|
|
|
|
|
for bar in bars:
|
|
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
|
|
if not ts_raw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ts = dt.datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
|
|
|
if ts.tzinfo is None:
|
|
|
|
|
ts = ts.replace(tzinfo=_ET)
|
|
|
|
|
ts_et = ts.astimezone(_ET)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.date().isoformat() != date_str:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.time() == _MARKET_OPEN:
|
|
|
|
|
candidates.append((ts_et, bar))
|
|
|
|
|
if not candidates:
|
|
|
|
|
# Fallback: first bar on that date within regular hours
|
|
|
|
|
for bar in bars:
|
|
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
|
|
if not ts_raw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ts = dt.datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
|
|
|
if ts.tzinfo is None:
|
|
|
|
|
ts = ts.replace(tzinfo=_ET)
|
|
|
|
|
ts_et = ts.astimezone(_ET)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.date().isoformat() == date_str and _MARKET_OPEN <= ts_et.time() < dt.time(10, 0):
|
|
|
|
|
candidates.append((ts_et, bar))
|
|
|
|
|
if not candidates:
|
|
|
|
|
return None
|
|
|
|
|
candidates.sort(key=lambda x: x[0])
|
|
|
|
|
return candidates[0][1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _latest_bar(bars: list[dict], date_str: str) -> dict | None:
|
|
|
|
|
"""Most recent regular-hours bar on date_str."""
|
|
|
|
|
matches: list[tuple[dt.datetime, dict]] = []
|
|
|
|
|
for bar in bars:
|
|
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
|
|
if not ts_raw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ts = dt.datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
|
|
|
if ts.tzinfo is None:
|
|
|
|
|
ts = ts.replace(tzinfo=_ET)
|
|
|
|
|
ts_et = ts.astimezone(_ET)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.date().isoformat() != date_str:
|
|
|
|
|
continue
|
|
|
|
|
if _MARKET_OPEN <= ts_et.time():
|
|
|
|
|
matches.append((ts_et, bar))
|
|
|
|
|
if not matches:
|
|
|
|
|
return None
|
|
|
|
|
matches.sort(key=lambda x: x[0])
|
|
|
|
|
return matches[-1][1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_gainers(data: object) -> list[dict]:
|
|
|
|
|
"""Extract ticker + price/change info from unknown gainers response shape."""
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
return data
|
|
|
|
|
if isinstance(data, dict):
|
|
|
|
|
for key in ("gainers", "stocks", "tickers", "data", "results"):
|
|
|
|
|
val = data.get(key)
|
|
|
|
|
if isinstance(val, list):
|
|
|
|
|
return val
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _gainer_ticker(item: object) -> str | None:
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
return item.upper()
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
for key in ("symbol", "ticker", "Symbol", "Ticker"):
|
|
|
|
|
v = item.get(key)
|
|
|
|
|
if v and isinstance(v, str):
|
|
|
|
|
return v.upper()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Pydantic models ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FilterResult(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
passed: bool
|
|
|
|
|
value: str | None = None
|
|
|
|
|
threshold: str | None = None
|
|
|
|
|
note: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StrategyInfo(BaseModel):
|
|
|
|
|
id: str
|
|
|
|
|
name: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StrategiesResponse(BaseModel):
|
|
|
|
|
strategies: list[StrategyInfo]
|
|
|
|
|
default: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrbCheckRequest(BaseModel):
|
|
|
|
|
ticker: str
|
|
|
|
|
asof: str | None = None
|
|
|
|
|
strategy: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrbCheckResponse(BaseModel):
|
|
|
|
|
ticker: str
|
|
|
|
|
asof: str
|
|
|
|
|
evaluated_at: str
|
|
|
|
|
market_status: str
|
|
|
|
|
overall_signal: str
|
|
|
|
|
filters: list[FilterResult]
|
|
|
|
|
entry_details: dict | None = None
|
|
|
|
|
warning: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrbExitCheckRequest(BaseModel):
|
|
|
|
|
ticker: str
|
|
|
|
|
entry_price: float | None = None # auto-fetched from ORB bar if omitted
|
|
|
|
|
atr_at_entry: float | None = None # auto-fetched from enrichment if omitted
|
|
|
|
|
current_price: float | None = None # auto-fetched from intraday if omitted
|
|
|
|
|
peak_price: float | None = None # auto-fetched from intraday if omitted
|
|
|
|
|
entry_time: str | None = None # "HH:MM" ET; defaults to 09:35
|
|
|
|
|
strategy: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrbExitCheckResponse(BaseModel):
|
|
|
|
|
ticker: str
|
|
|
|
|
current_stop: float
|
|
|
|
|
stop_phase: str
|
|
|
|
|
should_exit: bool
|
|
|
|
|
reason: str
|
|
|
|
|
r_multiple: float
|
|
|
|
|
profit_r: float
|
|
|
|
|
time_status: str
|
|
|
|
|
current_price: float
|
|
|
|
|
peak_price: float
|
|
|
|
|
entry_time_used: str | None = None
|
|
|
|
|
entry_price_used: float | None = None
|
|
|
|
|
atr_at_entry_used: float | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GainerResult(BaseModel):
|
|
|
|
|
ticker: str
|
|
|
|
|
price: float | None = None
|
|
|
|
|
change_pct: float | None = None
|
|
|
|
|
signal: str
|
|
|
|
|
scale_factor: float | None = None
|
|
|
|
|
filter_summary: str
|
|
|
|
|
failure_reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GainersScanResponse(BaseModel):
|
|
|
|
|
scan_time: str
|
|
|
|
|
market_status: str
|
|
|
|
|
count_fetched: int
|
|
|
|
|
count_passed: int
|
|
|
|
|
results: list[GainerResult]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Service functions ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def check(req: OrbCheckRequest) -> OrbCheckResponse:
|
|
|
|
|
params = _get_params(req.strategy)
|
|
|
|
|
now = _now_et()
|
|
|
|
|
asof = dt.date.fromisoformat(req.asof) if req.asof else now.date()
|
|
|
|
|
asof_str = asof.isoformat()
|
|
|
|
|
mstat = _market_status_str()
|
|
|
|
|
evaluated_at = now.isoformat()
|
|
|
|
|
|
|
|
|
|
if asof == now.date() and mstat == "pre_market":
|
|
|
|
|
return OrbCheckResponse(
|
|
|
|
|
ticker=req.ticker.upper(), asof=asof_str, evaluated_at=evaluated_at,
|
|
|
|
|
market_status=mstat, overall_signal="NOT_YET",
|
|
|
|
|
filters=[], warning="Market not yet open. Check after 09:30 ET.",
|
|
|
|
|
)
|
|
|
|
|
if asof == now.date() and mstat == "orb_forming":
|
|
|
|
|
return OrbCheckResponse(
|
|
|
|
|
ticker=req.ticker.upper(), asof=asof_str, evaluated_at=evaluated_at,
|
|
|
|
|
market_status=mstat, overall_signal="NOT_YET",
|
|
|
|
|
filters=[], warning="ORB window still forming. Check back after 09:35 ET.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ticker = req.ticker.strip().upper()
|
|
|
|
|
daily_start = (asof - timedelta(days=120)).isoformat()
|
|
|
|
|
support_tickers = ["SPY", "QQQ"]
|
|
|
|
|
all_tickers = sorted({ticker, *support_tickers})
|
|
|
|
|
warning: str | None = None
|
|
|
|
|
if mstat == "closed":
|
|
|
|
|
warning = "Market session over. Signal is based on today's completed session."
|
|
|
|
|
elif mstat == "active":
|
|
|
|
|
warning = "Entry window closed (09:55 ET). Shown for reference only."
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
async with OracleClient(base_url=_oracle_url()) as client:
|
|
|
|
|
daily_bars = await fetch_daily_bars_bulk(
|
|
|
|
|
all_tickers, daily_start, asof_str, client,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=4,
|
|
|
|
|
)
|
|
|
|
|
if ticker not in daily_bars or not daily_bars[ticker]:
|
|
|
|
|
# Per-ticker fallback: try individual price endpoint directly
|
|
|
|
|
try:
|
|
|
|
|
svc_fallback = PriceService(client)
|
|
|
|
|
pd_resp = await svc_fallback.get_daily_bars(ticker, start=daily_start, end=asof_str)
|
|
|
|
|
if pd_resp.bars:
|
|
|
|
|
daily_bars[ticker] = [
|
|
|
|
|
{"date": b.date, "open": b.open, "high": b.high,
|
|
|
|
|
"low": b.low, "close": b.close, "volume": b.volume}
|
|
|
|
|
for b in pd_resp.bars
|
|
|
|
|
]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if ticker not in daily_bars or not daily_bars[ticker]:
|
|
|
|
|
return OrbCheckResponse(
|
|
|
|
|
ticker=ticker, asof=asof_str, evaluated_at=evaluated_at,
|
|
|
|
|
market_status=mstat, overall_signal="ERROR",
|
|
|
|
|
filters=[], warning=f"No price data for {ticker}. Oracle may be loading — try again in a moment.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Inject synthetic today row (Oracle only has yesterday's daily bars when market is open)
|
|
|
|
|
for sym, bars in daily_bars.items():
|
|
|
|
|
if bars:
|
|
|
|
|
last_bar = max(bars, key=lambda b: b["date"])
|
|
|
|
|
if last_bar["date"] < asof_str:
|
|
|
|
|
daily_bars[sym] = bars + [{
|
|
|
|
|
"date": asof_str,
|
|
|
|
|
"open": last_bar["close"], "high": last_bar["close"],
|
|
|
|
|
"low": last_bar["close"], "close": last_bar["close"],
|
|
|
|
|
"volume": 0, "synthetic_today_daily": True,
|
|
|
|
|
}]
|
|
|
|
|
|
|
|
|
|
enrichment = enrich_daily_bars(daily_bars, [asof_str])
|
|
|
|
|
|
|
|
|
|
# Fetch intraday for ticker + support (for regime gates in simulation)
|
|
|
|
|
intraday_candidates = {asof_str: all_tickers}
|
|
|
|
|
all_intraday = await fetch_intraday_bulk(
|
|
|
|
|
intraday_candidates, client, cache=None,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=4,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception("orb_scanner_check_failed", ticker=ticker)
|
|
|
|
|
return OrbCheckResponse(
|
|
|
|
|
ticker=ticker, asof=asof_str, evaluated_at=evaluated_at,
|
|
|
|
|
market_status=mstat, overall_signal="ERROR",
|
|
|
|
|
filters=[], warning=f"Data fetch failed: {exc}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ticker_enrich = enrichment.get(ticker, {}).get(asof_str, {})
|
|
|
|
|
ticker_bars = all_intraday.get(asof_str, {}).get(ticker, [])
|
|
|
|
|
|
|
|
|
|
# ── Build filter breakdown ─────────────────────────────────────────────
|
|
|
|
|
filters: list[FilterResult] = []
|
|
|
|
|
|
|
|
|
|
# 1. Price
|
|
|
|
|
today_open = ticker_enrich.get("today_open")
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Price",
|
|
|
|
|
passed=today_open is not None and today_open >= params.min_price,
|
|
|
|
|
value=f"${today_open:.2f}" if today_open else "N/A",
|
|
|
|
|
threshold=f"≥ ${params.min_price:.2f}",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 2. Avg Daily Dollar Volume (30d)
|
|
|
|
|
avg_dvol = ticker_enrich.get("avg_dollar_vol_30d")
|
|
|
|
|
min_dvol = params.min_avg_dollar_volume
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Avg Daily $Vol (30d)",
|
|
|
|
|
passed=avg_dvol is not None and avg_dvol >= min_dvol,
|
|
|
|
|
value=f"${avg_dvol / 1e6:.1f}M" if avg_dvol else "N/A",
|
|
|
|
|
threshold=f"≥ ${min_dvol / 1e6:.0f}M",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 3. ATR(14)
|
|
|
|
|
atr = ticker_enrich.get("atr_14")
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="ATR(14)",
|
|
|
|
|
passed=atr is not None and atr >= params.min_atr_14,
|
|
|
|
|
value=f"{atr:.3f}" if atr else "N/A",
|
|
|
|
|
threshold=f"≥ {params.min_atr_14:.2f}",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 4. Gap%
|
|
|
|
|
prev_close = ticker_enrich.get("prev_close")
|
|
|
|
|
gap_pct: float | None = None
|
|
|
|
|
if today_open and prev_close and prev_close > 0:
|
|
|
|
|
gap_pct = (today_open - prev_close) / prev_close
|
|
|
|
|
min_gap = params.min_abs_gap_pct or 0.0
|
|
|
|
|
max_gap = getattr(params, "max_gap_pct", None) or 1.0
|
|
|
|
|
gap_ok = gap_pct is not None and min_gap <= abs(gap_pct) <= max_gap
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Gap%",
|
|
|
|
|
passed=gap_ok,
|
|
|
|
|
value=f"{gap_pct * 100:+.2f}%" if gap_pct is not None else "N/A",
|
|
|
|
|
threshold=f"{min_gap * 100:.0f}%–{max_gap * 100:.0f}% (abs)",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# ORB-specific filters require intraday bars
|
|
|
|
|
orb = _orb_bar(ticker_bars, asof_str)
|
|
|
|
|
latest = _latest_bar(ticker_bars, asof_str)
|
|
|
|
|
premarket_dvol = _premarket_dollar_vol(ticker_bars, asof_str)
|
|
|
|
|
avg_vol_14d = ticker_enrich.get("avg_daily_vol_14d")
|
|
|
|
|
|
|
|
|
|
# Refine gap% using real ORB bar open (synthetic today row uses prev_close as open)
|
|
|
|
|
if orb and ticker_enrich.get("synthetic_today_daily"):
|
|
|
|
|
real_open = orb.get("open")
|
|
|
|
|
if real_open and real_open > 0:
|
|
|
|
|
today_open = real_open
|
|
|
|
|
if prev_close and prev_close > 0:
|
|
|
|
|
gap_pct = (today_open - prev_close) / prev_close
|
|
|
|
|
gap_ok = min_gap <= abs(gap_pct) <= max_gap
|
|
|
|
|
# Update the already-appended Gap% filter
|
|
|
|
|
for f in filters:
|
|
|
|
|
if f.name == "Gap%":
|
|
|
|
|
f.value = f"{gap_pct * 100:+.2f}%"
|
|
|
|
|
f.passed = gap_ok
|
|
|
|
|
|
|
|
|
|
# 5. RVOL (informational — v49 uses volume_attention scoring, no simple cutoff)
|
|
|
|
|
rvol: float | None = None
|
|
|
|
|
if orb and avg_vol_14d and avg_vol_14d > 0:
|
|
|
|
|
rvol = compute_rvol_approx(orb.get("volume", 0), avg_vol_14d)
|
|
|
|
|
min_rvol = params.min_rvol
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="RVOL",
|
|
|
|
|
passed=True if min_rvol is None else (rvol is not None and rvol >= min_rvol),
|
|
|
|
|
value=f"{rvol:.1f}x" if rvol is not None else ("No intraday bars" if not orb else "N/A"),
|
|
|
|
|
threshold=f"≥ {min_rvol:.1f}x" if min_rvol is not None else "volume_attention scored",
|
|
|
|
|
note="informational" if min_rvol is None else None,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 6. Premarket $Vol
|
|
|
|
|
min_premarket = params.min_premarket_dollar_vol
|
|
|
|
|
premarket_unavailable = premarket_dvol == 0.0 and orb is not None
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Premarket $Vol",
|
|
|
|
|
passed=True if (min_premarket is None or premarket_unavailable) else premarket_dvol >= min_premarket,
|
|
|
|
|
value=f"${premarket_dvol / 1e6:.2f}M" if not premarket_unavailable else "0 (no premarket bars)",
|
|
|
|
|
threshold=f"≥ ${min_premarket / 1e6:.1f}M" if min_premarket else "N/A (no global floor)",
|
|
|
|
|
note="IEX feed may not include extended hours" if premarket_unavailable else None,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 7. ORB Bullish
|
|
|
|
|
orb_bullish = False
|
|
|
|
|
if orb:
|
|
|
|
|
orb_open_p = orb.get("open", 0) or 0.0
|
|
|
|
|
orb_close_p = orb.get("close", 0) or 0.0
|
|
|
|
|
orb_bullish = orb_close_p > orb_open_p
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="ORB Bullish",
|
|
|
|
|
passed=orb_bullish,
|
|
|
|
|
value=(f"close {orb.get('close', 0):.2f} > open {orb.get('open', 0):.2f}" if orb and orb_bullish
|
|
|
|
|
else (f"close {orb.get('close', 0):.2f} ≤ open {orb.get('open', 0):.2f}" if orb else "N/A")),
|
|
|
|
|
threshold="close > open",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 8. Breakout (current price ≥ ORB high)
|
|
|
|
|
orb_high = orb.get("high", 0) if orb else 0.0
|
|
|
|
|
current_price = latest.get("close", 0) if latest else 0.0
|
|
|
|
|
breakout = current_price >= orb_high if (orb_high > 0 and current_price > 0) else False
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Breakout",
|
|
|
|
|
passed=breakout,
|
|
|
|
|
value=f"${current_price:.2f}",
|
|
|
|
|
threshold=f"≥ ORB high ${orb_high:.2f}" if orb_high > 0 else "ORB high N/A",
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 9. Hot Reclaim Guard
|
|
|
|
|
ret_5d = ticker_enrich.get("ret_5d")
|
|
|
|
|
hot_min_ret5d = params.hot_reclaim_min_ret_5d
|
|
|
|
|
hot_max_premarket = params.hot_reclaim_max_premarket_dollar_vol
|
|
|
|
|
hot_triggered = (
|
|
|
|
|
hot_min_ret5d is not None and hot_max_premarket is not None
|
|
|
|
|
and ret_5d is not None and ret_5d >= hot_min_ret5d
|
|
|
|
|
and premarket_dvol <= hot_max_premarket
|
|
|
|
|
)
|
|
|
|
|
if hot_min_ret5d is not None:
|
|
|
|
|
scale = params.hot_reclaim_size_scale if hot_triggered else None
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Hot Reclaim Guard",
|
|
|
|
|
passed=True,
|
|
|
|
|
value=f"5d ret {ret_5d * 100:.1f}%" if ret_5d is not None else "N/A",
|
|
|
|
|
threshold=f"5d ≥ {hot_min_ret5d * 100:.0f}% + premarket ≤ ${(hot_max_premarket or 0) / 1e6:.1f}M → scale",
|
|
|
|
|
note=f"triggered → {scale:.1f}x size" if hot_triggered and scale else None,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# 10. Stale OBV Gate
|
|
|
|
|
obv_slope = ticker_enrich.get("obv_slope_20")
|
|
|
|
|
stale_max_obv = params.stale_obv_reversal_max_obv_slope_20d
|
|
|
|
|
if stale_max_obv is not None and obv_slope is not None:
|
|
|
|
|
stale_triggered = obv_slope <= stale_max_obv
|
|
|
|
|
stale_scale = params.stale_obv_reversal_size_scale if stale_triggered else None
|
|
|
|
|
filters.append(FilterResult(
|
|
|
|
|
name="Stale OBV Gate",
|
|
|
|
|
passed=True,
|
|
|
|
|
value=f"OBV slope {obv_slope:.3f}",
|
|
|
|
|
threshold=f"slope ≤ {stale_max_obv:.2f} → scale",
|
|
|
|
|
note=f"triggered → {stale_scale:.2f}x size" if stale_triggered and stale_scale else None,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# ── Run simulation for final verdict ──────────────────────────────────
|
|
|
|
|
ticker_sectors = {t: "UNKNOWN" for t in all_tickers}
|
|
|
|
|
try:
|
|
|
|
|
day_results = run_orb_simulation(
|
|
|
|
|
all_intraday, [asof_str], params, enrichment,
|
|
|
|
|
ticker_sectors=ticker_sectors,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("orb_simulation_failed", ticker=ticker, error=str(exc))
|
|
|
|
|
day_results = []
|
|
|
|
|
|
|
|
|
|
day = day_results[0] if day_results else None
|
|
|
|
|
trade = next((t for t in (day.trades if day else []) if t.ticker == ticker), None)
|
|
|
|
|
|
|
|
|
|
if trade:
|
|
|
|
|
overall_signal = "ENTRY"
|
|
|
|
|
entry_details = {
|
|
|
|
|
"entry_price": trade.entry_price,
|
|
|
|
|
"atr_at_entry": trade.atr_at_entry,
|
|
|
|
|
"rvol": trade.rvol,
|
|
|
|
|
"gap_pct": trade.gap_pct,
|
|
|
|
|
"orb_direction": trade.orb_direction,
|
|
|
|
|
"body_ratio": trade.body_ratio,
|
|
|
|
|
"close_location": trade.close_location,
|
|
|
|
|
"premarket_dollar_vol": trade.premarket_dollar_vol,
|
|
|
|
|
"hot_reclaim_size_scale": trade.hot_reclaim_size_scale,
|
|
|
|
|
"entry_time": trade.entry_time,
|
|
|
|
|
}
|
|
|
|
|
# Refine filter values from simulation if available
|
|
|
|
|
if trade.rvol is not None and rvol is None:
|
|
|
|
|
for f in filters:
|
|
|
|
|
if f.name == "RVOL":
|
|
|
|
|
f.value = f"{trade.rvol:.1f}x"
|
|
|
|
|
f.passed = True
|
|
|
|
|
if trade.premarket_dollar_vol is not None:
|
|
|
|
|
for f in filters:
|
|
|
|
|
if f.name == "Premarket $Vol":
|
|
|
|
|
f.value = f"${trade.premarket_dollar_vol / 1e6:.2f}M"
|
|
|
|
|
f.passed = trade.premarket_dollar_vol >= (params.min_premarket_dollar_vol or 0)
|
|
|
|
|
f.note = None
|
|
|
|
|
# Update Hot Reclaim Guard scale from actual trade
|
|
|
|
|
if trade.hot_reclaim_size_scale is not None and trade.hot_reclaim_size_scale < 1.0:
|
|
|
|
|
for f in filters:
|
|
|
|
|
if f.name == "Hot Reclaim Guard":
|
|
|
|
|
f.note = f"triggered → {trade.hot_reclaim_size_scale:.1f}x size"
|
|
|
|
|
else:
|
|
|
|
|
overall_signal = "NO_ENTRY"
|
|
|
|
|
entry_details = None
|
|
|
|
|
|
|
|
|
|
return OrbCheckResponse(
|
|
|
|
|
ticker=ticker, asof=asof_str, evaluated_at=evaluated_at,
|
|
|
|
|
market_status=mstat, overall_signal=overall_signal,
|
|
|
|
|
filters=filters, entry_details=entry_details, warning=warning,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def exit_check(req: OrbExitCheckRequest) -> OrbExitCheckResponse:
|
|
|
|
|
"""Stop state machine. Auto-fetches prices/ATR from Oracle when not provided."""
|
|
|
|
|
ticker = req.ticker.upper()
|
|
|
|
|
now_et = _now_et()
|
|
|
|
|
asof_str = now_et.date().isoformat()
|
|
|
|
|
|
|
|
|
|
# Parse entry time (ET)
|
|
|
|
|
entry_time_used: str | None = None
|
|
|
|
|
entry_dt: dt.datetime | None = None
|
|
|
|
|
if req.entry_time:
|
|
|
|
|
try:
|
|
|
|
|
parts = req.entry_time.replace(" ", "").split(":")
|
|
|
|
|
h, m = int(parts[0]), int(parts[1])
|
|
|
|
|
entry_dt = now_et.replace(hour=h, minute=m, second=0, microsecond=0)
|
|
|
|
|
entry_time_used = f"{h:02d}:{m:02d}"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if entry_dt is None:
|
|
|
|
|
entry_dt = now_et.replace(hour=9, minute=35, second=0, microsecond=0)
|
|
|
|
|
entry_time_used = "09:35"
|
|
|
|
|
|
|
|
|
|
need_quote = req.current_price is None or req.entry_price is None
|
|
|
|
|
need_intraday = req.peak_price is None # intraday needed for peak tracking
|
|
|
|
|
need_daily = req.atr_at_entry is None
|
|
|
|
|
|
|
|
|
|
bars_raw: list[dict] = []
|
|
|
|
|
atr = req.atr_at_entry
|
|
|
|
|
live_price: float | None = None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
async with OracleClient(base_url=_oracle_url()) as client:
|
|
|
|
|
# Real-time quote for current_price / entry_price
|
|
|
|
|
if need_quote:
|
|
|
|
|
try:
|
|
|
|
|
svc = PriceService(client)
|
|
|
|
|
quote = await svc.get_quote(ticker)
|
|
|
|
|
live_price = quote.price
|
|
|
|
|
except Exception as qexc:
|
|
|
|
|
logger.warning("exit_check_quote_failed", ticker=ticker, error=str(qexc))
|
|
|
|
|
|
|
|
|
|
# Intraday bars for peak tracking
|
|
|
|
|
if need_intraday or need_quote:
|
|
|
|
|
intraday = await fetch_intraday_bulk(
|
|
|
|
|
{asof_str: [ticker]}, client, cache=None,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=2,
|
|
|
|
|
)
|
|
|
|
|
bars_raw = intraday.get(asof_str, {}).get(ticker, [])
|
|
|
|
|
|
|
|
|
|
if need_daily:
|
|
|
|
|
daily_start = (now_et.date() - timedelta(days=60)).isoformat()
|
|
|
|
|
daily_bars_atr = await fetch_daily_bars_bulk(
|
|
|
|
|
[ticker], daily_start, asof_str, client,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=2,
|
|
|
|
|
)
|
|
|
|
|
bars = daily_bars_atr.get(ticker, [])
|
|
|
|
|
if bars:
|
|
|
|
|
last_bar = max(bars, key=lambda b: b["date"])
|
|
|
|
|
if last_bar["date"] < asof_str:
|
|
|
|
|
daily_bars_atr[ticker] = bars + [{
|
|
|
|
|
"date": asof_str,
|
|
|
|
|
"open": last_bar["close"], "high": last_bar["close"],
|
|
|
|
|
"low": last_bar["close"], "close": last_bar["close"],
|
|
|
|
|
"volume": 0,
|
|
|
|
|
}]
|
|
|
|
|
enrichment = enrich_daily_bars(daily_bars_atr, [asof_str])
|
|
|
|
|
atr = enrichment.get(ticker, {}).get(asof_str, {}).get("atr_14")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("exit_check_fetch_failed", ticker=ticker, error=str(exc))
|
|
|
|
|
|
|
|
|
|
# Parse intraday timestamps for peak tracking
|
|
|
|
|
timed: list[tuple[dt.datetime, dict]] = []
|
|
|
|
|
for bar in bars_raw:
|
|
|
|
|
ts_raw = bar.get("timestamp", "")
|
|
|
|
|
if not ts_raw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
ts = dt.datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
|
|
|
|
if ts.tzinfo is None:
|
|
|
|
|
ts = ts.replace(tzinfo=_ET)
|
|
|
|
|
ts_et = ts.astimezone(_ET)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
if ts_et.date().isoformat() == asof_str and ts_et.time() >= _MARKET_OPEN:
|
|
|
|
|
timed.append((ts_et, bar))
|
|
|
|
|
timed.sort(key=lambda x: x[0])
|
|
|
|
|
|
|
|
|
|
# Resolve entry_price: real-time quote when in Current mode
|
|
|
|
|
entry_price = req.entry_price
|
|
|
|
|
if entry_price is None:
|
|
|
|
|
entry_price = live_price or (float(timed[-1][1].get("close") or 0) if timed else 0.0)
|
|
|
|
|
|
|
|
|
|
# current_price: always use real-time quote when available; fallback to latest bar
|
|
|
|
|
current_price = req.current_price
|
|
|
|
|
if current_price is None:
|
|
|
|
|
current_price = live_price or (float(timed[-1][1].get("close") or entry_price) if timed else entry_price)
|
|
|
|
|
|
|
|
|
|
# peak_price: max high of intraday bars since entry_time
|
|
|
|
|
since_entry = [(ts, b) for ts, b in timed if ts >= entry_dt]
|
|
|
|
|
peak_price = req.peak_price
|
|
|
|
|
if peak_price is None:
|
|
|
|
|
if since_entry:
|
|
|
|
|
peak_price = max((float(b.get("high") or entry_price) for _, b in since_entry), default=entry_price)
|
|
|
|
|
peak_price = max(peak_price, entry_price)
|
|
|
|
|
else:
|
|
|
|
|
peak_price = entry_price
|
|
|
|
|
|
|
|
|
|
if current_price is None:
|
|
|
|
|
current_price = entry_price
|
|
|
|
|
if peak_price is None:
|
|
|
|
|
peak_price = entry_price
|
|
|
|
|
|
|
|
|
|
if not atr or atr <= 0:
|
|
|
|
|
atr = 0.001
|
|
|
|
|
|
|
|
|
|
exit_params = _get_params(req.strategy)
|
|
|
|
|
atr_mult = exit_params.atr_stop_multiplier or 0.75
|
|
|
|
|
trailing_atr = exit_params.trailing_stop_atr_multiplier or 0.6
|
|
|
|
|
tight_atr = exit_params.trailing_stop_atr_multiplier_tight or 0.2
|
|
|
|
|
trailing_r = exit_params.trailing_at_r or 1.0
|
|
|
|
|
tighten_r = exit_params.trailing_tighten_at_r # may be None
|
|
|
|
|
breakeven_r = exit_params.breakeven_at_r or 1.0
|
|
|
|
|
|
|
|
|
|
stop_distance = atr_mult * atr
|
|
|
|
|
initial_stop = entry_price - stop_distance
|
|
|
|
|
|
|
|
|
|
peak_r = (peak_price - entry_price) / stop_distance
|
|
|
|
|
current_r = (current_price - entry_price) / stop_distance
|
|
|
|
|
|
|
|
|
|
if tighten_r is not None and peak_r >= tighten_r:
|
|
|
|
|
current_stop = peak_price - tight_atr * atr
|
|
|
|
|
phase = "trailing_tight"
|
|
|
|
|
elif peak_r >= trailing_r:
|
|
|
|
|
current_stop = peak_price - trailing_atr * atr
|
|
|
|
|
phase = "trailing"
|
|
|
|
|
else:
|
|
|
|
|
current_stop = initial_stop
|
|
|
|
|
phase = "initial"
|
|
|
|
|
|
|
|
|
|
# Enforce breakeven floor
|
|
|
|
|
if current_r >= breakeven_r and current_stop < entry_price:
|
|
|
|
|
current_stop = entry_price
|
|
|
|
|
if phase == "initial":
|
|
|
|
|
phase = "breakeven"
|
|
|
|
|
|
|
|
|
|
# 15:55 ET force exit
|
|
|
|
|
force_exit = now_et.time() >= _FORCE_EXIT_TIME
|
|
|
|
|
if force_exit:
|
|
|
|
|
phase = "force_exit"
|
|
|
|
|
time_status = "force_exit_due"
|
|
|
|
|
elif now_et.time() >= _MARKET_OPEN:
|
|
|
|
|
time_status = "in_window"
|
|
|
|
|
else:
|
|
|
|
|
time_status = "post_close"
|
|
|
|
|
|
|
|
|
|
hit_stop = current_price <= current_stop
|
|
|
|
|
|
|
|
|
|
if force_exit:
|
|
|
|
|
should_exit = True
|
|
|
|
|
reason = "Force exit at 15:55 ET"
|
|
|
|
|
elif hit_stop:
|
|
|
|
|
should_exit = True
|
|
|
|
|
reason = f"Stop hit: ${current_price:.2f} ≤ stop ${current_stop:.2f} ({phase})"
|
|
|
|
|
else:
|
|
|
|
|
should_exit = False
|
|
|
|
|
reason = f"Hold: ${current_price:.2f} > stop ${current_stop:.2f} ({phase})"
|
|
|
|
|
|
|
|
|
|
return OrbExitCheckResponse(
|
|
|
|
|
ticker=ticker,
|
|
|
|
|
current_stop=round(current_stop, 4),
|
|
|
|
|
stop_phase=phase,
|
|
|
|
|
should_exit=should_exit,
|
|
|
|
|
reason=reason,
|
|
|
|
|
r_multiple=round(current_r, 3),
|
|
|
|
|
profit_r=round(current_r, 3),
|
|
|
|
|
time_status=time_status,
|
|
|
|
|
current_price=round(current_price, 4),
|
|
|
|
|
peak_price=round(peak_price, 4),
|
|
|
|
|
entry_time_used=entry_time_used,
|
|
|
|
|
entry_price_used=round(entry_price, 4),
|
|
|
|
|
atr_at_entry_used=round(atr, 4),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def gainers_scan(count: int = 200, strategy: str | None = None) -> GainersScanResponse:
|
|
|
|
|
params = _get_params(strategy)
|
|
|
|
|
now = _now_et()
|
|
|
|
|
asof = now.date()
|
|
|
|
|
asof_str = asof.isoformat()
|
|
|
|
|
mstat = _market_status_str()
|
|
|
|
|
scan_time = now.isoformat()
|
|
|
|
|
|
|
|
|
|
# 1. Fetch gainers from Oracle
|
|
|
|
|
gainers_url = f"{_oracle_url()}/api/v1/stocks/gainers"
|
|
|
|
|
try:
|
|
|
|
|
resp = httpx.get(gainers_url, params={"count": count}, timeout=10.0)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
raw_gainers = resp.json()
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("gainers_fetch_failed", error=str(exc))
|
|
|
|
|
return GainersScanResponse(
|
|
|
|
|
scan_time=scan_time, market_status=mstat,
|
|
|
|
|
count_fetched=0, count_passed=0, results=[],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
gainer_items = _parse_gainers(raw_gainers)
|
|
|
|
|
if not gainer_items:
|
|
|
|
|
logger.warning("gainers_response_unrecognized", raw=str(raw_gainers)[:200])
|
|
|
|
|
return GainersScanResponse(
|
|
|
|
|
scan_time=scan_time, market_status=mstat,
|
|
|
|
|
count_fetched=0, count_passed=0, results=[],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Build gainer metadata map
|
|
|
|
|
gainer_meta: dict[str, dict] = {}
|
|
|
|
|
for item in gainer_items:
|
|
|
|
|
sym = _gainer_ticker(item)
|
|
|
|
|
if not sym:
|
|
|
|
|
continue
|
|
|
|
|
meta: dict = {}
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
meta["price"] = item.get("price") or item.get("regularMarketPrice")
|
|
|
|
|
pct_raw = (
|
|
|
|
|
item.get("change_percent")
|
|
|
|
|
or item.get("pct_change")
|
|
|
|
|
or item.get("regularMarketChangePercent")
|
|
|
|
|
or item.get("change_pct")
|
|
|
|
|
)
|
|
|
|
|
# Oracle returns change_percent as a whole number (e.g. 12.5 = +12.5%)
|
|
|
|
|
meta["change_pct"] = pct_raw / 100.0 if pct_raw is not None else None
|
|
|
|
|
gainer_meta[sym] = meta
|
|
|
|
|
|
|
|
|
|
all_gainer_tickers = sorted(gainer_meta.keys())
|
|
|
|
|
logger.info("gainers_fetched", count=len(all_gainer_tickers))
|
|
|
|
|
|
|
|
|
|
if not all_gainer_tickers:
|
|
|
|
|
return GainersScanResponse(
|
|
|
|
|
scan_time=scan_time, market_status=mstat,
|
|
|
|
|
count_fetched=0, count_passed=0, results=[],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
support_tickers = ["SPY", "QQQ"]
|
|
|
|
|
all_tickers = sorted({*all_gainer_tickers, *support_tickers})
|
|
|
|
|
daily_start = (asof - timedelta(days=120)).isoformat()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
async with OracleClient(base_url=_oracle_url()) as client:
|
|
|
|
|
# 2. Fetch daily bars for all gainers
|
|
|
|
|
daily_bars = await fetch_daily_bars_bulk(
|
|
|
|
|
all_tickers, daily_start, asof_str, client,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=8,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 3. Inject synthetic today row (Oracle only has yesterday's daily bars when market is open)
|
|
|
|
|
for sym, bars in daily_bars.items():
|
|
|
|
|
if bars:
|
|
|
|
|
last_bar = max(bars, key=lambda b: b["date"])
|
|
|
|
|
if last_bar["date"] < asof_str:
|
|
|
|
|
daily_bars[sym] = bars + [{
|
|
|
|
|
"date": asof_str,
|
|
|
|
|
"open": last_bar["close"], "high": last_bar["close"],
|
|
|
|
|
"low": last_bar["close"], "close": last_bar["close"],
|
|
|
|
|
"volume": 0, "synthetic_today_daily": True,
|
|
|
|
|
}]
|
|
|
|
|
|
|
|
|
|
# 4. Enrich
|
|
|
|
|
enrichment = enrich_daily_bars(daily_bars, [asof_str])
|
|
|
|
|
|
|
|
|
|
# 5. Pre-screen
|
|
|
|
|
prescreened = orb_pre_screen_candidates(
|
|
|
|
|
daily_bars, [asof_str], 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,
|
|
|
|
|
)
|
|
|
|
|
candidate_tickers = prescreened.get(asof_str, [])
|
|
|
|
|
logger.info("gainers_prescreened", total=len(all_gainer_tickers), passed=len(candidate_tickers))
|
|
|
|
|
|
|
|
|
|
# 6. Fetch intraday only for candidates + support
|
|
|
|
|
intraday_candidates = {asof_str: sorted({*candidate_tickers, *support_tickers})}
|
|
|
|
|
all_intraday: dict = {}
|
|
|
|
|
if candidate_tickers:
|
|
|
|
|
all_intraday = await fetch_intraday_bulk(
|
|
|
|
|
intraday_candidates, client, cache=None,
|
|
|
|
|
skip_oracle_when_unhealthy=True, concurrency=4,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception("gainers_scan_failed")
|
|
|
|
|
return GainersScanResponse(
|
|
|
|
|
scan_time=scan_time, market_status=mstat,
|
|
|
|
|
count_fetched=len(all_gainer_tickers), count_passed=0, results=[],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 6. Run simulation
|
|
|
|
|
ticker_sectors = {t: "UNKNOWN" for t in all_tickers}
|
|
|
|
|
day_results: list = []
|
|
|
|
|
if candidate_tickers and all_intraday:
|
|
|
|
|
try:
|
|
|
|
|
day_results = run_orb_simulation(
|
|
|
|
|
all_intraday, [asof_str], params, enrichment,
|
|
|
|
|
ticker_sectors=ticker_sectors,
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning("gainers_simulation_failed", error=str(exc))
|
|
|
|
|
|
|
|
|
|
# 7. Build results
|
|
|
|
|
day = day_results[0] if day_results else None
|
|
|
|
|
traded_tickers = {t.ticker: t for t in (day.trades if day else [])}
|
|
|
|
|
pre_screened_set = set(candidate_tickers)
|
|
|
|
|
|
|
|
|
|
results: list[GainerResult] = []
|
|
|
|
|
for sym in all_gainer_tickers:
|
|
|
|
|
meta = gainer_meta.get(sym, {})
|
|
|
|
|
price = meta.get("price")
|
|
|
|
|
change_pct = meta.get("change_pct")
|
|
|
|
|
|
|
|
|
|
if sym in traded_tickers:
|
|
|
|
|
trade = traded_tickers[sym]
|
|
|
|
|
size_scale = trade.hot_reclaim_size_scale
|
|
|
|
|
if size_scale is not None and size_scale < 1.0:
|
|
|
|
|
signal = "SCALE_ENTRY"
|
|
|
|
|
else:
|
|
|
|
|
signal = "ENTRY"
|
|
|
|
|
parts = []
|
|
|
|
|
if trade.gap_pct is not None:
|
|
|
|
|
parts.append(f"gap {trade.gap_pct * 100:+.1f}%")
|
|
|
|
|
if trade.rvol is not None:
|
|
|
|
|
parts.append(f"rvol {trade.rvol:.1f}x")
|
|
|
|
|
if trade.atr_at_entry is not None:
|
|
|
|
|
parts.append(f"atr {trade.atr_at_entry:.2f}")
|
|
|
|
|
filter_summary = " | ".join(parts) if parts else "entry confirmed"
|
|
|
|
|
results.append(GainerResult(
|
|
|
|
|
ticker=sym, price=price, change_pct=change_pct,
|
|
|
|
|
signal=signal, scale_factor=size_scale,
|
|
|
|
|
filter_summary=filter_summary, failure_reason=None,
|
|
|
|
|
))
|
|
|
|
|
elif sym in pre_screened_set:
|
|
|
|
|
# Passed pre-screen but failed ORB simulation filters
|
|
|
|
|
e = enrichment.get(sym, {}).get(asof_str, {})
|
|
|
|
|
atr = e.get("atr_14")
|
|
|
|
|
dvol = e.get("avg_dollar_vol_30d")
|
|
|
|
|
summary = f"atr {atr:.2f} | dvol ${(dvol or 0) / 1e6:.0f}M" if atr else "prescreened"
|
|
|
|
|
reason = "ORB filters: gap/RVOL/breakout/direction"
|
|
|
|
|
if day and day.skip_reason:
|
|
|
|
|
reason = f"day skipped: {day.skip_reason}"
|
|
|
|
|
results.append(GainerResult(
|
|
|
|
|
ticker=sym, price=price, change_pct=change_pct,
|
|
|
|
|
signal="NO_ENTRY", scale_factor=None,
|
|
|
|
|
filter_summary=summary, failure_reason=reason,
|
|
|
|
|
))
|
|
|
|
|
else:
|
|
|
|
|
# Failed pre-screen
|
|
|
|
|
e = enrichment.get(sym, {}).get(asof_str, {})
|
|
|
|
|
atr = e.get("atr_14")
|
|
|
|
|
dvol = e.get("avg_dollar_vol_30d")
|
|
|
|
|
today_open = e.get("today_open")
|
|
|
|
|
reasons = []
|
|
|
|
|
if today_open is not None and today_open < params.min_price:
|
|
|
|
|
reasons.append(f"price ${today_open:.2f} < ${params.min_price:.0f}")
|
|
|
|
|
elif atr is None or atr < params.min_atr_14:
|
|
|
|
|
reasons.append(f"atr {atr or 'N/A'} < {params.min_atr_14}")
|
|
|
|
|
elif dvol is None or dvol < params.min_avg_dollar_volume:
|
|
|
|
|
reasons.append(f"dvol ${(dvol or 0) / 1e6:.0f}M < ${params.min_avg_dollar_volume / 1e6:.0f}M")
|
|
|
|
|
else:
|
|
|
|
|
reasons.append("no daily bar on date")
|
|
|
|
|
results.append(GainerResult(
|
|
|
|
|
ticker=sym, price=price, change_pct=change_pct,
|
|
|
|
|
signal="NO_ENTRY", scale_factor=None,
|
|
|
|
|
filter_summary="pre-screen fail",
|
|
|
|
|
failure_reason=", ".join(reasons),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
# Sort: ENTRY → SCALE_ENTRY → NO_ENTRY
|
|
|
|
|
_order = {"ENTRY": 0, "SCALE_ENTRY": 1, "NO_ENTRY": 2}
|
|
|
|
|
results.sort(key=lambda r: _order.get(r.signal, 3))
|
|
|
|
|
|
|
|
|
|
count_passed = sum(1 for r in results if r.signal in ("ENTRY", "SCALE_ENTRY"))
|
|
|
|
|
return GainersScanResponse(
|
|
|
|
|
scan_time=scan_time, market_status=mstat,
|
|
|
|
|
count_fetched=len(all_gainer_tickers),
|
|
|
|
|
count_passed=count_passed,
|
|
|
|
|
results=results,
|
|
|
|
|
)
|