diff --git a/apps/web/orb_scanner_service.py b/apps/web/orb_scanner_service.py new file mode 100644 index 0000000..af83f18 --- /dev/null +++ b/apps/web/orb_scanner_service.py @@ -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, + ) diff --git a/apps/web/routers/orb_scanner.py b/apps/web/routers/orb_scanner.py new file mode 100644 index 0000000..1057c60 --- /dev/null +++ b/apps/web/routers/orb_scanner.py @@ -0,0 +1,31 @@ +"""ORB Scanner API endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter + +from apps.web import orb_scanner_service as svc + +router = APIRouter(prefix="/orb-scanner", tags=["orb-scanner"]) + + +@router.post("/check", response_model=svc.OrbCheckResponse) +async def check(req: svc.OrbCheckRequest) -> svc.OrbCheckResponse: + return await svc.check(req) + + +@router.post("/exit-check", response_model=svc.OrbExitCheckResponse) +async def exit_check(req: svc.OrbExitCheckRequest) -> svc.OrbExitCheckResponse: + return await svc.exit_check(req) + + +@router.get("/strategies", response_model=svc.StrategiesResponse) +async def strategies() -> svc.StrategiesResponse: + return svc.StrategiesResponse( + strategies=svc.list_strategies(), + default=svc._ORB_DEFAULT_STRATEGY, + ) + + +@router.get("/gainers-scan", response_model=svc.GainersScanResponse) +async def gainers_scan(count: int = 200, strategy: str | None = None) -> svc.GainersScanResponse: + return await svc.gainers_scan(count, strategy) diff --git a/apps/web_frontend/src/api/client.ts b/apps/web_frontend/src/api/client.ts index e153606..c16b80b 100644 --- a/apps/web_frontend/src/api/client.ts +++ b/apps/web_frontend/src/api/client.ts @@ -392,7 +392,11 @@ export interface IntradayResult { start_date: string; end_date: string; trading_days: number; + days_with_activity?: number; + days_with_trades?: number; total_trades: number; + idle_sleeve_entry_days?: number; + idle_sleeve_positions_opened?: number; win_rate: number | null; avg_win_pct: number | null; avg_loss_pct: number | null; @@ -410,7 +414,17 @@ export interface IntradayResult { loss_containment_score?: number | null; }; trades: IntradayTrade[]; - daily_summary: { date: string; daily_pnl: number; daily_return_pct: number; candidates_found: number; trades: number }[]; + daily_summary: { + date: string; + daily_pnl: number; + daily_return_pct: number; + candidates_found: number; + trades: number; + activity_count?: number; + idle_sleeve_positions_opened?: number; + idle_sleeve_symbols_opened?: string[]; + idle_sleeve_labels_opened?: string[]; + }[]; } export interface IntradayStrategy { @@ -422,7 +436,15 @@ export interface IntradayStrategy { universe: string; universe_label?: string | null; universe_symbols_file?: string | null; - strategy_mode?: 'momentum' | 'orb'; + strategy_mode?: 'momentum' | 'orb' | 'adaptive'; + // Adaptive-mode-specific (also reusable by other modes) + min_entry_dollar_volume?: number | null; + atr_initial_multiplier?: number | null; + atr_trail_multiplier?: number | null; + position_sizing?: 'equal_weight' | 'inverse_atr' | string | null; + phase1_min_gain?: number | null; + phase1_max_per_day?: number | null; + slippage_bps?: number | null; output_dir?: string; initial_capital: number; risk_per_trade_pct?: number | null; @@ -1123,3 +1145,298 @@ export const eventsApi = { purge: (before: string) => request<{ deleted: number }>(`/events?before=${encodeURIComponent(before)}`, { method: 'DELETE' }), }; + +// --- TGTC Trading --- + +export interface TgtcSession { + session_id: string; + session_name: string; + config_path: string; + initial_equity: number; + current_equity: number; + total_return_pct: number; + created_at: string; + status: string; + ran_today: boolean; + phase: string; +} + +export interface TgtcSnapshot { + id?: number; + session_id: string; + date: string; + captured_at: string; + symbol: string; + rank: number; + price: number | null; + pct_change: number | null; + volume: number | null; + market_cap: number | null; +} + +export interface TgtcCandidate { + id?: number; + session_id: string; + date: string; + symbol: string; + score: number | null; + rank_persistence: number | null; + rank_velocity: number | null; + price_structure: number | null; + volume_quality: number | null; + relative_strength: number | null; + pct_change_at_10: number | null; + price_at_10: number | null; + vwap_at_10: number | null; + above_vwap: number | null; + decided_at: string; + status: string; +} + +export interface TgtcPosition { + id?: number; + session_id: string; + date: string; + symbol: string; + entry_signal: string; + entry_price: number; + stop_price: number; + current_stop: number; + shares: number; + entered_at: string; + peak_price: number; + partial_taken: number; + be_stop_active: number; + exit_price: number | null; + exit_reason: string | null; + exited_at: string | null; + pnl: number | null; + r_multiple: number | null; + is_dry_run: number; + status: string; +} + +export interface TgtcTrade { + trade_id: string; + session_id: string; + date: string; + symbol: string; + entry_signal: string; + entry_price: number; + exit_price: number; + entered_at: string; + exited_at: string; + shares: number; + pnl: number; + r_multiple: number; + exit_reason: string; + is_dry_run: number; +} + +export interface TgtcStrategyInfo { + config_path: string; + name: string; + id: string; + status: string; + live_readiness: string; + description: string; +} + +export interface TgtcAutoStatus { + running: boolean; + pid: number | null; + sessions: string[]; + dry_run: boolean; + db_path: string; + log: string; + log_lines: string[]; +} + +export interface TgtcBacktestTask { + task_id: string; + date: string; + config_path: string; + universe: string | null; + status: 'pending' | 'running' | 'completed' | 'failed'; + created_at: string; + result: TgtcBacktestResult | null; + error: string | null; +} + +export interface TgtcBacktestResult { + // single-day fields + date?: string; + n_candidates?: number; + equity_curve?: Array<{ ts_et: string; equity: number }>; + candidates?: TgtcCandidate[]; + // multi-day fields + type?: 'multiday'; + start_date?: string; + end_date?: string; + n_days?: number; + per_day?: Array<{ + date: string; + n_candidates: number; + n_trades: number; + pnl: number; + return_pct: number; + win_rate: number; + equity: number; + }>; + // shared + n_trades: number; + total_pnl: number; + total_return_pct: number; + win_rate: number; + initial_equity: number; + final_equity: number; + trades: Array<{ + date?: string; + symbol: string; + entry_price: number; + exit_price: number; + stop_price: number; + shares: number; + pnl: number; + r_multiple: number; + exit_reason: string; + status: string; + }>; +} + +export const tgtcApi = { + sessions: () => request<{ sessions: TgtcSession[] }>('/tgtc/sessions'), + createSession: (name: string, config: string, capital: number) => + request('/tgtc/sessions', { + method: 'POST', + body: JSON.stringify({ name, config, capital }), + }), + deleteSession: (id: string) => + request<{ deleted: string }>(`/tgtc/sessions/${id}`, { method: 'DELETE' }), + strategies: () => request<{ strategies: TgtcStrategyInfo[] }>('/tgtc/strategies'), + snapshots: (id: string, date?: string) => + request<{ date: string; snapshots: TgtcSnapshot[]; count: number }>( + `/tgtc/sessions/${id}/snapshots${date ? '?date=' + date : ''}` + ), + candidates: (id: string, date?: string) => + request<{ date: string; candidates: TgtcCandidate[] }>( + `/tgtc/sessions/${id}/candidates${date ? '?date=' + date : ''}` + ), + positions: (id: string) => + request<{ positions: TgtcPosition[] }>(`/tgtc/sessions/${id}/positions`), + trades: (id: string) => + request<{ trades: TgtcTrade[] }>(`/tgtc/sessions/${id}/trades`), + equity: (id: string) => + request<{ initial_equity: number; current_equity: number; daily_snapshots: unknown[] }>( + `/tgtc/sessions/${id}/equity` + ), + autoStatus: () => request('/tgtc/auto'), + autoStart: (sessions: string[] = [], dry_run = true) => + request<{ started: boolean; dry_run: boolean }>('/tgtc/auto/start', { + method: 'POST', + body: JSON.stringify({ sessions, dry_run }), + }), + autoStop: () => request<{ stopped: boolean }>('/tgtc/auto/stop', { method: 'POST' }), + clearLog: () => request<{ cleared: boolean }>('/tgtc/auto/clear-log', { method: 'POST' }), + submitBacktest: (date: string, config: string, universe?: string, end_date?: string) => + request<{ task_id: string; status: string }>('/tgtc/backtest/submit', { + method: 'POST', + body: JSON.stringify({ date, config, universe, end_date }), + }), + backtestTasks: () => request<{ tasks: TgtcBacktestTask[] }>('/tgtc/backtest/tasks'), + backtestTask: (id: string) => request(`/tgtc/backtest/tasks/${id}`), + backtestResult: (id: string) => request(`/tgtc/backtest/tasks/${id}/result`), +}; + +// ── ORB Scanner ──────────────────────────────────────────────────────────────── + +export interface OrbFilterResult { + name: string; + passed: boolean; + value: string | null; + threshold: string | null; + note: string | null; +} + +export interface OrbCheckResponse { + ticker: string; + asof: string; + evaluated_at: string; + market_status: string; + overall_signal: 'ENTRY' | 'NO_ENTRY' | 'NOT_YET' | 'MARKET_CLOSED' | 'ERROR'; + filters: OrbFilterResult[]; + entry_details: Record | null; + warning: string | null; +} + +export interface OrbExitCheckResponse { + ticker: string; + current_stop: number; + stop_phase: 'initial' | 'breakeven' | 'trailing' | 'trailing_tight' | 'force_exit'; + should_exit: boolean; + reason: string; + r_multiple: number; + profit_r: number; + time_status: string; + current_price: number; + peak_price: number; + entry_time_used: string | null; + entry_price_used: number | null; + atr_at_entry_used: number | null; +} + +export interface GainerResult { + ticker: string; + price: number | null; + change_pct: number | null; + signal: 'ENTRY' | 'SCALE_ENTRY' | 'NO_ENTRY'; + scale_factor: number | null; + filter_summary: string; + failure_reason: string | null; +} + +export interface GainersScanResponse { + scan_time: string; + market_status: string; + count_fetched: number; + count_passed: number; + results: GainerResult[]; +} + +export interface StrategyInfo { + id: string; + name: string; +} + +export interface StrategiesResponse { + strategies: StrategyInfo[]; + default: string; +} + +export const orbScannerApi = { + strategies: () => + request('/orb-scanner/strategies'), + + check: (req: { ticker: string; asof?: string | null; strategy?: string | null }) => + request('/orb-scanner/check', { + method: 'POST', + body: JSON.stringify(req), + }), + + exitCheck: (req: { + ticker: string; + entry_price?: number | null; + atr_at_entry?: number | null; + entry_time?: string | null; + strategy?: string | null; + }) => + request('/orb-scanner/exit-check', { + method: 'POST', + body: JSON.stringify(req), + }), + + gainersScan: (count = 200, strategy?: string | null) => + request( + `/orb-scanner/gainers-scan?count=${count}${strategy ? `&strategy=${encodeURIComponent(strategy)}` : ''}` + ), +}; diff --git a/apps/web_frontend/src/pages/OrbScanner.tsx b/apps/web_frontend/src/pages/OrbScanner.tsx new file mode 100644 index 0000000..6e5dab8 --- /dev/null +++ b/apps/web_frontend/src/pages/OrbScanner.tsx @@ -0,0 +1,800 @@ +import { useState, useEffect, useRef } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + Target, Radar, CheckCircle2, XCircle, AlertCircle, Clock, + TrendingDown, ChevronRight, RefreshCw, Minus, Plus, +} from 'lucide-react'; +import { + orbScannerApi, + type OrbCheckResponse, + type OrbExitCheckResponse, + type GainersScanResponse, + type GainerResult, + type StrategiesResponse, +} from '../api/client'; +import { Loading } from '../components/common/Loading'; + +// ── Styles ──────────────────────────────────────────────────────────────────── + +const card: React.CSSProperties = { + background: 'var(--bg1)', + border: '1px solid var(--border)', + borderRadius: 12, + overflow: 'hidden', +}; + +const inputStyle: React.CSSProperties = { + padding: '8px 11px', + fontSize: 14, + fontFamily: 'var(--font-mono)', + background: 'var(--bg2)', + border: '1px solid var(--border-md)', + borderRadius: 7, + color: 'var(--text1)', + outline: 'none', + width: '100%', + boxSizing: 'border-box' as const, +}; + +const btn = (variant: 'primary' | 'danger' | 'ghost' | 'outline' = 'outline'): React.CSSProperties => ({ + display: 'inline-flex', alignItems: 'center', gap: 6, + padding: '7px 16px', fontSize: 13, fontWeight: 500, + borderRadius: 7, border: '1px solid', cursor: 'pointer', + transition: 'all 0.12s', whiteSpace: 'nowrap' as const, + background: variant === 'primary' ? 'var(--cyan)' : variant === 'danger' ? 'var(--red)' : 'transparent', + color: variant === 'primary' ? '#fff' : variant === 'danger' ? '#fff' : variant === 'ghost' ? 'var(--text3)' : 'var(--text2)', + borderColor: variant === 'primary' ? 'var(--cyan)' : variant === 'danger' ? 'var(--red)' : 'var(--border-md)', +}); + +const label: React.CSSProperties = { + fontSize: 11, fontWeight: 600, color: 'var(--text3)', + textTransform: 'uppercase' as const, letterSpacing: '0.06em', + marginBottom: 4, display: 'block', +}; + +// ── Signal badge ────────────────────────────────────────────────────────────── + +type Signal = 'ENTRY' | 'SCALE_ENTRY' | 'NO_ENTRY' | 'NOT_YET' | 'MARKET_CLOSED' | 'ERROR'; + +const SIGNAL_COLORS: Record = { + ENTRY: { bg: 'var(--green)', color: '#fff', label: 'ENTRY' }, + SCALE_ENTRY: { bg: 'var(--orange)', color: '#fff', label: 'SCALE ENTRY' }, + NO_ENTRY: { bg: 'var(--red)', color: '#fff', label: 'NO ENTRY' }, + NOT_YET: { bg: 'var(--gold)', color: '#fff', label: 'NOT YET' }, + MARKET_CLOSED:{ bg: 'var(--text3)', color: '#fff', label: 'CLOSED' }, + ERROR: { bg: '#888', color: '#fff', label: 'ERROR' }, +}; + +function SignalBadge({ signal, large }: { signal: Signal; large?: boolean }) { + const cfg = SIGNAL_COLORS[signal] ?? SIGNAL_COLORS.ERROR; + return ( + + {cfg.label} + + ); +} + +// ── Filter table ────────────────────────────────────────────────────────────── + +interface FilterItem { + name: string; + passed: boolean; + value: string | null; + threshold: string | null; + note: string | null; +} + +function FilterTable({ filters }: { filters: FilterItem[] }) { + return ( +
+ + + + {['Filter', 'Value', 'Threshold', 'Pass'].map(h => ( + + ))} + + + + {filters.map((f, i) => ( + + + + + + + ))} + +
{h}
{f.name} + {f.value ?? '—'} + {f.note && {f.note}} + {f.threshold ?? '—'} + {f.passed + ? + : } +
+
+ ); +} + +// ── Entry details card ──────────────────────────────────────────────────────── + +function EntryDetailsCard({ details }: { details: Record }) { + const fmt = (v: unknown, dp = 2) => (typeof v === 'number' ? v.toFixed(dp) : v != null ? String(v) : '—'); + const items = [ + { label: 'Entry Price', value: `$${fmt(details.entry_price)}` }, + { label: 'ATR at Entry', value: fmt(details.atr_at_entry, 3) }, + { label: 'RVOL', value: details.rvol != null ? `${fmt(details.rvol, 1)}x` : '—' }, + { label: 'Gap%', value: details.gap_pct != null ? `${((details.gap_pct as number) * 100).toFixed(2)}%` : '—' }, + { label: 'Direction', value: fmt(details.orb_direction) }, + { label: 'Body Ratio', value: fmt(details.body_ratio, 3) }, + { label: 'Close Loc', value: fmt(details.close_location, 3) }, + { label: 'Premarket $Vol', value: details.premarket_dollar_vol != null ? `$${((details.premarket_dollar_vol as number) / 1e6).toFixed(2)}M` : '—' }, + ...(details.hot_reclaim_size_scale != null && (details.hot_reclaim_size_scale as number) < 1.0 + ? [{ label: 'Size Scale', value: `${fmt(details.hot_reclaim_size_scale, 2)}x (hot reclaim)` }] + : []), + ]; + return ( +
+
Entry Details
+
+ {items.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ ); +} + +// ── Single Check panel ──────────────────────────────────────────────────────── + +function SingleCheckPanel({ onSelectTicker, selectedStrategy }: { onSelectTicker: (ticker: string, details: Record) => void; selectedStrategy: string }) { + const [ticker, setTicker] = useState(''); + + const checkMut = useMutation({ + mutationFn: (t: string) => orbScannerApi.check({ ticker: t, strategy: selectedStrategy }), + }); + + const handleCheck = () => { + if (!ticker.trim()) return; + checkMut.mutate(ticker.trim().toUpperCase()); + }; + + const result = checkMut.data as OrbCheckResponse | undefined; + + return ( +
+ {/* Input row */} +
+
+ Ticker Symbol + setTicker(e.target.value.toUpperCase())} + onKeyDown={e => e.key === 'Enter' && handleCheck()} + placeholder="e.g. NVDA" + maxLength={10} + /> +
+ +
+ + {checkMut.isPending && } + + {checkMut.isError && ( +
+ Error: {(checkMut.error as Error).message} +
+ )} + + {result && ( +
+ {/* Status row */} +
+ +
+
{result.ticker}
+
{result.asof} · evaluated {new Date(result.evaluated_at).toLocaleTimeString()}
+
+
+ + {result.warning && ( +
+ {result.warning} +
+ )} + + {result.filters.length > 0 && ( +
+ +
+ )} + + {result.entry_details && } + + {result.overall_signal === 'ENTRY' && result.entry_details && ( + + )} +
+ )} +
+ ); +} + +// ── Exit Monitor panel ──────────────────────────────────────────────────────── + +interface Position { + id: string; + ticker: string; + entryPrice: number | null; // null = auto (current market price) + atrAtEntry: number | null; // null = auto from daily enrichment + entryMode: 'now' | 'time'; + entryTime: string; + addedAt: string; // local time string when position was added, e.g. "14:32" + strategy: string; // locked at add-time +} + +interface PositionStatus { + isPending: boolean; + result: OrbExitCheckResponse | null; + error: string | null; + lastChecked: Date | null; +} + +const PHASE_LABELS: Record = { + initial: 'Initial', breakeven: 'Breakeven', trailing: 'Trailing', + trailing_tight: 'Tight', force_exit: 'Force Exit', +}; +const PHASE_COLORS: Record = { + initial: 'var(--text3)', breakeven: 'var(--cyan)', trailing: 'var(--green)', + trailing_tight: 'var(--purple)', force_exit: 'var(--red)', +}; + +const POSITIONS_KEY = 'orb-monitor-positions'; + +function loadPositions(): Position[] { + try { + const raw = localStorage.getItem(POSITIONS_KEY); + return raw ? (JSON.parse(raw) as Position[]) : []; + } catch { return []; } +} + +function ExitMonitorPanel({ prefill, selectedStrategy }: { prefill?: { ticker: string; details: Record }; selectedStrategy: string }) { + const [positions, setPositions] = useState(loadPositions); + const [statuses, setStatuses] = useState>({}); + const [form, setForm] = useState({ ticker: '', entryPrice: '', atrAtEntry: '', entryMode: 'now' as 'now' | 'time', entryTime: '09:35' }); + const [formError, setFormError] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(() => { + try { return localStorage.getItem('orb-monitor-autorefresh') === '1'; } catch { return false; } + }); + const [countdown, setCountdown] = useState(300); + const timerRef = useRef | null>(null); + const positionsRef = useRef([]); + + useEffect(() => { positionsRef.current = positions; }, [positions]); + + // Persist positions and autoRefresh to localStorage + useEffect(() => { + try { localStorage.setItem(POSITIONS_KEY, JSON.stringify(positions)); } catch {} + }, [positions]); + + useEffect(() => { + try { localStorage.setItem('orb-monitor-autorefresh', autoRefresh ? '1' : '0'); } catch {} + }, [autoRefresh]); + + // Re-check all restored positions on mount + const didMountRef = useRef(false); + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + if (positions.length > 0) { + positions.forEach(pos => checkPosition(pos)); + } + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (prefill) { + const d = prefill.details; + setForm(prev => ({ + ...prev, + ticker: prefill.ticker, + entryPrice: d.entry_price != null ? String((d.entry_price as number).toFixed(2)) : prev.entryPrice, + atrAtEntry: d.atr_at_entry != null ? String((d.atr_at_entry as number).toFixed(4)) : prev.atrAtEntry, + })); + } + }, [prefill]); + + const checkPosition = async (pos: Position) => { + setStatuses(prev => ({ ...prev, [pos.id]: { ...(prev[pos.id] ?? { result: null, lastChecked: null }), isPending: true, error: null } })); + try { + const result = await orbScannerApi.exitCheck({ + ticker: pos.ticker, + entry_price: pos.entryPrice ?? null, + atr_at_entry: pos.atrAtEntry ?? null, + entry_time: pos.entryMode === 'time' ? pos.entryTime : null, + strategy: pos.strategy, + }); + // Lock in auto-resolved entry_price and ATR for subsequent checks + if (pos.entryPrice === null && result.entry_price_used != null) { + setPositions(prev => prev.map(p => + p.id === pos.id ? { ...p, entryPrice: result.entry_price_used!, atrAtEntry: result.atr_at_entry_used ?? p.atrAtEntry } : p + )); + } + setStatuses(prev => ({ ...prev, [pos.id]: { isPending: false, result, error: null, lastChecked: new Date() } })); + } catch (e) { + setStatuses(prev => ({ ...prev, [pos.id]: { ...(prev[pos.id] ?? { result: null, lastChecked: null }), isPending: false, error: (e as Error).message } })); + } + }; + + const checkAll = () => { + positionsRef.current.forEach(pos => checkPosition(pos)); + setCountdown(300); + }; + + const addPosition = () => { + if (!form.ticker) { setFormError('Ticker를 입력하세요'); return; } + let ep: number | null = null; + let atr: number | null = null; + if (form.entryMode === 'now') { + // Auto-fetch from backend; entry price = current market price + } else { + const epVal = parseFloat(form.entryPrice); + const atrVal = parseFloat(form.atrAtEntry); + if (!form.entryPrice || isNaN(epVal)) { setFormError('Entry Price를 입력하세요'); return; } + if (!form.atrAtEntry || isNaN(atrVal)) { setFormError('ATR at Entry를 입력하세요'); return; } + ep = epVal; + atr = atrVal; + } + const now = new Date(); + const addedAt = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + const pos: Position = { + id: `${form.ticker}-${Date.now()}`, + ticker: form.ticker, + entryPrice: ep, + atrAtEntry: atr, + entryMode: form.entryMode, + entryTime: form.entryTime, + addedAt, + strategy: selectedStrategy, + }; + setPositions(prev => [...prev, pos]); + setStatuses(prev => ({ ...prev, [pos.id]: { isPending: true, result: null, error: null, lastChecked: null } })); + setFormError(null); + setForm(prev => ({ ...prev, ticker: '' })); + setTimeout(() => checkPosition(pos), 0); + }; + + const removePosition = (id: string) => { + setPositions(prev => prev.filter(p => p.id !== id)); + setStatuses(prev => { const s = { ...prev }; delete s[id]; return s; }); + }; + + useEffect(() => { + if (!autoRefresh) { + if (timerRef.current) clearInterval(timerRef.current); + return; + } + timerRef.current = setInterval(() => { + setCountdown(c => { + if (c <= 1) { + positionsRef.current.forEach(pos => checkPosition(pos)); + return 300; + } + return c - 1; + }); + }, 1000); + return () => { if (timerRef.current) clearInterval(timerRef.current); }; + }, [autoRefresh]); + + return ( +
+ {/* Add position form */} +
+
+ Add Position +
+
+
+ Ticker + setForm(prev => ({ ...prev, ticker: e.target.value.toUpperCase() }))} + onKeyDown={e => e.key === 'Enter' && addPosition()} + placeholder="NVDA" + maxLength={10} + /> +
+ {form.entryMode !== 'now' && ( + <> +
+ Entry Price + setForm(prev => ({ ...prev, entryPrice: e.target.value }))} placeholder="200.50" /> +
+
+ ATR at Entry + setForm(prev => ({ ...prev, atrAtEntry: e.target.value }))} placeholder="2.40" /> +
+ + )} +
+ Entry Time (ET) +
+ + + {form.entryMode === 'time' && ( + setForm(prev => ({ ...prev, entryTime: e.target.value }))} placeholder="09:35" /> + )} +
+
+
+ {formError &&
{formError}
} + +
+ + {/* Controls */} + {positions.length > 0 && ( +
+ + +
+ )} + + {/* Position cards — 3-column grid */} +
+ {positions.map(pos => { + const status = statuses[pos.id]; + const r = status?.result; + const phaseColor = PHASE_COLORS[r?.stop_phase ?? 'initial'] ?? 'var(--text3)'; + return ( +
+
+
+ {pos.ticker} + {status?.isPending && } + {r && ( + + {r.should_exit ? 'EXIT NOW' : 'HOLD'} + + )} +
+ +
+ {status?.error &&
{status.error}
} + {r && (() => { + const entryPx = r.entry_price_used ?? pos.entryPrice ?? 0; + const pnlPct = entryPx > 0 ? (r.current_price - entryPx) / entryPx * 100 : null; + const pnlColor = pnlPct == null ? 'var(--text3)' : pnlPct > 0 ? 'var(--green)' : pnlPct < 0 ? 'var(--red)' : 'var(--text3)'; + const entryLabel = `Entry @ ${pos.entryMode === 'now' ? pos.addedAt : (r.entry_time_used ?? pos.entryTime)}`; + return ( + <> + {/* Primary row: entry → current → P&L */} +
+
+
{entryLabel}
+
${entryPx.toFixed(2)}
+
+
+
+
Current
+
${r.current_price.toFixed(2)}
+
+
+
P&L
+
+ {pnlPct != null ? `${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(2)}%` : '—'} +
+
+
+ {/* Secondary row: stop / phase / peak / R */} +
+ + Stop ${r.current_stop.toFixed(2)} + + + Phase {PHASE_LABELS[r.stop_phase] ?? r.stop_phase} + + + Peak ${r.peak_price.toFixed(2)} + + + R 1 ? 'var(--green)' : r.r_multiple > 0 ? 'var(--gold)' : 'var(--red)', fontWeight: 600 }}>{r.r_multiple > 0 ? '+' : ''}{r.r_multiple.toFixed(2)} + +
+ + ); + })()} + {!r && !status?.isPending && ( +
Checking…
+ )} +
+ ); + })} +
+ + {positions.length === 0 && ( +
+ 포지션을 추가하세요. "Current" 모드: Ticker만 입력하면 현재가 자동 사용. "Specify" 모드: 매수 시간 직접 입력. +
+ )} +
+ ); +} + +// ── Gainers Scan panel ──────────────────────────────────────────────────────── + +const SIGNAL_ORDER: Record = { ENTRY: 0, SCALE_ENTRY: 1, NO_ENTRY: 2 }; + +function GainersScanPanel({ onSelectTicker, selectedStrategy }: { onSelectTicker: (ticker: string) => void; selectedStrategy: string }) { + const [showAll, setShowAll] = useState(false); + + const scanMut = useMutation({ + mutationFn: () => orbScannerApi.gainersScan(200, selectedStrategy), + }); + + const result = scanMut.data as GainersScanResponse | undefined; + + const sorted = result + ? [...result.results].sort((a, b) => (SIGNAL_ORDER[a.signal] ?? 3) - (SIGNAL_ORDER[b.signal] ?? 3)) + : []; + + const passed = sorted.filter(r => r.signal !== 'NO_ENTRY'); + const failed = sorted.filter(r => r.signal === 'NO_ENTRY'); + const displayList = showAll ? sorted : [...passed, ...failed.slice(0, 5)]; + + return ( +
+
+ + + Oracle gainers API · up to 200 tickers · {selectedStrategy} + +
+ + {scanMut.isPending && ( +
+ +
+ Fetching gainers, running ORB simulation… (~5-10s) +
+
+ )} + + {scanMut.isError && ( +
+ Error: {(scanMut.error as Error).message} +
+ )} + + {result && ( +
+ {/* Summary row */} +
+
+ 0 ? 'var(--cyan)' : 'var(--text2)' }}>{result.count_passed} + / {result.count_fetched} pass +
+
+ Scanned {new Date(result.scan_time).toLocaleTimeString()} +
+ {result.market_status === 'closed' && ( +
+ Market closed — historical signal +
+ )} +
+ + {/* Results table */} +
+
+ + + + {['Ticker', 'Price', 'Change%', 'Signal', 'Summary', 'Reason'].map(h => ( + + ))} + + + + {displayList.map((r: GainerResult, i) => ( + r.signal !== 'NO_ENTRY' && onSelectTicker(r.ticker)} + > + + + + + + + + ))} + +
{h}
+ {r.ticker} + + {r.price != null ? `$${r.price.toFixed(2)}` : '—'} + 0 ? 'var(--green)' : 'var(--red)' }}> + {r.change_pct != null ? `${r.change_pct > 0 ? '+' : ''}${(r.change_pct * 100).toFixed(1)}%` : '—'} + + + {r.scale_factor != null && r.scale_factor < 1.0 && ( + {r.scale_factor.toFixed(1)}x + )} + + {r.filter_summary} + + {r.failure_reason ?? (r.signal !== 'NO_ENTRY' ? ( + + check single + + ) : null)} +
+
+
+ + {failed.length > 5 && !showAll && ( + + )} +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +type Tab = 'check' | 'exit' | 'gainers'; + +const ORB_DEFAULT_STRATEGY = 'orb_gainers_v49_100_mid_hot_rtg_reserve'; +const STRATEGY_KEY = 'orb-scanner-strategy'; + +export function OrbScannerPage() { + const [activeTab, setActiveTab] = useState('check'); + const [exitPrefill, setExitPrefill] = useState<{ ticker: string; details: Record } | undefined>(); + const [selectedStrategy, setSelectedStrategy] = useState(() => { + try { return localStorage.getItem(STRATEGY_KEY) || ORB_DEFAULT_STRATEGY; } catch { return ORB_DEFAULT_STRATEGY; } + }); + + const { data: strategiesData } = useQuery({ + queryKey: ['orbStrategies'], + queryFn: orbScannerApi.strategies, + staleTime: Infinity, + }); + + useEffect(() => { + try { localStorage.setItem(STRATEGY_KEY, selectedStrategy); } catch {} + }, [selectedStrategy]); + + const handleSelectForExit = (ticker: string, details: Record) => { + setExitPrefill({ ticker, details }); + setActiveTab('exit'); + }; + + const handleGainerClick = (_ticker: string) => { + setActiveTab('check'); + }; + + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: 'check', label: 'Single Check', icon: }, + { id: 'exit', label: 'Exit Monitor', icon: }, + { id: 'gainers', label: 'Gainers Scan', icon: }, + ]; + + const selectedName = strategiesData?.strategies.find(s => s.id === selectedStrategy)?.name ?? selectedStrategy; + + return ( +
+ {/* Header */} +
+ +
+

ORB Scanner

+
{selectedName}
+
+ {/* Strategy selector */} +
+ Strategy + +
+
+ + {/* Market status banner is shown inside tab content, driven by API responses */} + + {/* Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Tab content */} +
+
+ {activeTab === 'check' && ( + + )} + {activeTab === 'exit' && ( + + )} + {activeTab === 'gainers' && ( + + )} +
+
+
+ ); +}