Add dynamic strategy selection to ORB Scanner

- List all orb_gainers_*.yaml configs via GET /orb-scanner/strategies (reads _meta.name)
- _get_params() now accepts strategy_id with per-strategy dict cache
- exit_check() replaces hardcoded stop constants (0.75/0.6/0.2/1.0/1.5) with params fields
- check() and gainers_scan() accept optional strategy param
- Frontend: strategy dropdown in page header (localStorage persisted)
- Position.strategy locked at add-time so exit checks always use entry-strategy params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 1d24893326
commit 8771667e61

@ -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,
)

@ -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)

@ -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<TgtcSession>('/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<TgtcAutoStatus>('/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<TgtcBacktestTask>(`/tgtc/backtest/tasks/${id}`),
backtestResult: (id: string) => request<TgtcBacktestResult>(`/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<string, unknown> | 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<StrategiesResponse>('/orb-scanner/strategies'),
check: (req: { ticker: string; asof?: string | null; strategy?: string | null }) =>
request<OrbCheckResponse>('/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<OrbExitCheckResponse>('/orb-scanner/exit-check', {
method: 'POST',
body: JSON.stringify(req),
}),
gainersScan: (count = 200, strategy?: string | null) =>
request<GainersScanResponse>(
`/orb-scanner/gainers-scan?count=${count}${strategy ? `&strategy=${encodeURIComponent(strategy)}` : ''}`
),
};

@ -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<Signal, { bg: string; color: string; label: string }> = {
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 (
<span style={{
background: cfg.bg, color: cfg.color,
borderRadius: 6, fontWeight: 700,
fontSize: large ? 18 : 12,
padding: large ? '6px 18px' : '3px 9px',
letterSpacing: large ? '0.04em' : '0.05em',
}}>
{cfg.label}
</span>
);
}
// ── Filter table ──────────────────────────────────────────────────────────────
interface FilterItem {
name: string;
passed: boolean;
value: string | null;
threshold: string | null;
note: string | null;
}
function FilterTable({ filters }: { filters: FilterItem[] }) {
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
{['Filter', 'Value', 'Threshold', 'Pass'].map(h => (
<th key={h} style={{ padding: '7px 12px', textAlign: 'left', fontSize: 11, color: 'var(--text3)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{filters.map((f, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)', background: i % 2 === 0 ? 'transparent' : 'var(--bg2)' }}>
<td style={{ padding: '7px 12px', color: 'var(--text2)', fontWeight: 500 }}>{f.name}</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: f.passed ? 'var(--green)' : 'var(--red)' }}>
{f.value ?? '—'}
{f.note && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--orange)', fontStyle: 'italic' }}>{f.note}</span>}
</td>
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)', fontSize: 12 }}>{f.threshold ?? '—'}</td>
<td style={{ padding: '7px 12px' }}>
{f.passed
? <CheckCircle2 size={15} color="var(--green)" />
: <XCircle size={15} color="var(--red)" />}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Entry details card ────────────────────────────────────────────────────────
function EntryDetailsCard({ details }: { details: Record<string, unknown> }) {
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 (
<div style={{ ...card, padding: '16px 20px', marginTop: 16, borderColor: 'var(--cyan)' }}>
<div style={{ marginBottom: 10, fontSize: 12, fontWeight: 700, color: 'var(--cyan)', textTransform: 'uppercase', letterSpacing: '0.07em' }}>Entry Details</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '10px 20px' }}>
{items.map(item => (
<div key={item.label}>
<div style={label}>{item.label}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--text1)', fontWeight: 600 }}>{item.value}</div>
</div>
))}
</div>
</div>
);
}
// ── Single Check panel ────────────────────────────────────────────────────────
function SingleCheckPanel({ onSelectTicker, selectedStrategy }: { onSelectTicker: (ticker: string, details: Record<string, unknown>) => 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 (
<div>
{/* Input row */}
<div style={{ display: 'flex', gap: 10, marginBottom: 20, alignItems: 'flex-end' }}>
<div style={{ flex: '0 0 200px' }}>
<span style={label}>Ticker Symbol</span>
<input
style={inputStyle}
value={ticker}
onChange={e => setTicker(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleCheck()}
placeholder="e.g. NVDA"
maxLength={10}
/>
</div>
<button style={btn('primary')} onClick={handleCheck} disabled={checkMut.isPending}>
{checkMut.isPending ? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <Target size={14} />}
{checkMut.isPending ? 'Checking…' : 'Check Entry'}
</button>
</div>
{checkMut.isPending && <Loading />}
{checkMut.isError && (
<div style={{ color: 'var(--red)', fontSize: 13, padding: '10px 0' }}>
Error: {(checkMut.error as Error).message}
</div>
)}
{result && (
<div>
{/* Status row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<SignalBadge signal={result.overall_signal as Signal} large />
<div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>{result.ticker}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>{result.asof} · evaluated {new Date(result.evaluated_at).toLocaleTimeString()}</div>
</div>
</div>
{result.warning && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 12px', borderRadius: 7, background: 'color-mix(in srgb, var(--gold) 10%, transparent)', border: '1px solid color-mix(in srgb, var(--gold) 25%, transparent)', fontSize: 12, color: 'var(--gold)', marginBottom: 14 }}>
<AlertCircle size={13} />{result.warning}
</div>
)}
{result.filters.length > 0 && (
<div style={card}>
<FilterTable filters={result.filters} />
</div>
)}
{result.entry_details && <EntryDetailsCard details={result.entry_details} />}
{result.overall_signal === 'ENTRY' && result.entry_details && (
<button
style={{ ...btn('outline'), marginTop: 14 }}
onClick={() => onSelectTicker(result.ticker, result.entry_details!)}
>
<ChevronRight size={14} />
Open in Exit Monitor
</button>
)}
</div>
)}
</div>
);
}
// ── 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<string, string> = {
initial: 'Initial', breakeven: 'Breakeven', trailing: 'Trailing',
trailing_tight: 'Tight', force_exit: 'Force Exit',
};
const PHASE_COLORS: Record<string, string> = {
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<string, unknown> }; selectedStrategy: string }) {
const [positions, setPositions] = useState<Position[]>(loadPositions);
const [statuses, setStatuses] = useState<Record<string, PositionStatus>>({});
const [form, setForm] = useState({ ticker: '', entryPrice: '', atrAtEntry: '', entryMode: 'now' as 'now' | 'time', entryTime: '09:35' });
const [formError, setFormError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(() => {
try { return localStorage.getItem('orb-monitor-autorefresh') === '1'; } catch { return false; }
});
const [countdown, setCountdown] = useState(300);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const positionsRef = useRef<Position[]>([]);
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 (
<div>
{/* Add position form */}
<div style={{ ...card, padding: '16px', marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 }}>
Add Position
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 10 }}>
<div style={{ flex: '0 0 110px' }}>
<span style={label}>Ticker</span>
<input
style={inputStyle}
value={form.ticker}
onChange={e => setForm(prev => ({ ...prev, ticker: e.target.value.toUpperCase() }))}
onKeyDown={e => e.key === 'Enter' && addPosition()}
placeholder="NVDA"
maxLength={10}
/>
</div>
{form.entryMode !== 'now' && (
<>
<div style={{ flex: 1, minWidth: 110 }}>
<span style={label}>Entry Price</span>
<input style={inputStyle} value={form.entryPrice} onChange={e => setForm(prev => ({ ...prev, entryPrice: e.target.value }))} placeholder="200.50" />
</div>
<div style={{ flex: 1, minWidth: 110 }}>
<span style={label}>ATR at Entry</span>
<input style={inputStyle} value={form.atrAtEntry} onChange={e => setForm(prev => ({ ...prev, atrAtEntry: e.target.value }))} placeholder="2.40" />
</div>
</>
)}
<div style={{ flex: '0 0 auto' }}>
<span style={label}>Entry Time (ET)</span>
<div style={{ display: 'flex', gap: 6 }}>
<button style={{ ...btn(form.entryMode === 'now' ? 'primary' : 'outline') }} onClick={() => setForm(prev => ({ ...prev, entryMode: 'now' }))}>
<Clock size={12} /> Current
</button>
<button style={{ ...btn(form.entryMode === 'time' ? 'primary' : 'outline') }} onClick={() => setForm(prev => ({ ...prev, entryMode: 'time' }))}>
Specify
</button>
{form.entryMode === 'time' && (
<input style={{ ...inputStyle, width: 68 }} value={form.entryTime} onChange={e => setForm(prev => ({ ...prev, entryTime: e.target.value }))} placeholder="09:35" />
)}
</div>
</div>
</div>
{formError && <div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 8 }}>{formError}</div>}
<button style={btn('primary')} onClick={addPosition}>
<Plus size={14} /> Add & Check
</button>
</div>
{/* Controls */}
{positions.length > 0 && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 16 }}>
<button style={btn('outline')} onClick={checkAll}>
<RefreshCw size={13} /> Check All Now
</button>
<button style={{ ...btn(autoRefresh ? 'danger' : 'outline') }} onClick={() => { setAutoRefresh(a => !a); setCountdown(300); }}>
<Clock size={13} />
{autoRefresh ? `Auto OFF (${countdown}s)` : 'Auto 5min'}
</button>
</div>
)}
{/* Position cards — 3-column grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
{positions.map(pos => {
const status = statuses[pos.id];
const r = status?.result;
const phaseColor = PHASE_COLORS[r?.stop_phase ?? 'initial'] ?? 'var(--text3)';
return (
<div key={pos.id} style={{ ...card, padding: '14px 16px', borderColor: r ? (r.should_exit ? 'var(--red)' : 'var(--green)') : 'var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: r ? 10 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 15 }}>{pos.ticker}</span>
{status?.isPending && <RefreshCw size={12} style={{ color: 'var(--text3)', animation: 'spin 1s linear infinite' }} />}
{r && (
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 5, background: r.should_exit ? 'var(--red)' : 'var(--green)', color: '#fff' }}>
{r.should_exit ? 'EXIT NOW' : 'HOLD'}
</span>
)}
</div>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', padding: '0 2px', fontSize: 16, lineHeight: 1 }} onClick={() => removePosition(pos.id)} title="Remove">×</button>
</div>
{status?.error && <div style={{ color: 'var(--red)', fontSize: 12 }}>{status.error}</div>}
{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 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr 1fr', alignItems: 'center', gap: '0 6px', marginBottom: 10 }}>
<div>
<div style={label}>{entryLabel}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 16, fontWeight: 700, color: 'var(--text2)' }}>${entryPx.toFixed(2)}</div>
</div>
<div style={{ color: 'var(--text3)', fontSize: 14, paddingTop: 14 }}></div>
<div>
<div style={label}>Current</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>${r.current_price.toFixed(2)}</div>
</div>
<div>
<div style={label}>P&L</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 800, color: pnlColor }}>
{pnlPct != null ? `${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(2)}%` : '—'}
</div>
</div>
</div>
{/* Secondary row: stop / phase / peak / R */}
<div style={{ display: 'flex', gap: '6px 20px', flexWrap: 'wrap', paddingTop: 10, borderTop: '1px solid var(--border-subtle)' }}>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
Stop <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--red)', fontWeight: 600 }}>${r.current_stop.toFixed(2)}</span>
</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
Phase <span style={{ fontWeight: 600, color: phaseColor, textTransform: 'uppercase' }}>{PHASE_LABELS[r.stop_phase] ?? r.stop_phase}</span>
</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
Peak <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>${r.peak_price.toFixed(2)}</span>
</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
R <span style={{ fontFamily: 'var(--font-mono)', color: r.r_multiple > 1 ? 'var(--green)' : r.r_multiple > 0 ? 'var(--gold)' : 'var(--red)', fontWeight: 600 }}>{r.r_multiple > 0 ? '+' : ''}{r.r_multiple.toFixed(2)}</span>
</span>
</div>
</>
);
})()}
{!r && !status?.isPending && (
<div style={{ color: 'var(--text3)', fontSize: 12 }}>Checking</div>
)}
</div>
);
})}
</div>
{positions.length === 0 && (
<div style={{ color: 'var(--text3)', fontSize: 13, textAlign: 'center', padding: '32px 0' }}>
. "Current" : Ticker . "Specify" : .
</div>
)}
</div>
);
}
// ── Gainers Scan panel ────────────────────────────────────────────────────────
const SIGNAL_ORDER: Record<string, number> = { 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 (
<div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 20 }}>
<button style={btn('primary')} onClick={() => scanMut.mutate()} disabled={scanMut.isPending}>
{scanMut.isPending ? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <Radar size={14} />}
{scanMut.isPending ? 'Scanning…' : 'Scan Gainers'}
</button>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
Oracle gainers API · up to 200 tickers · {selectedStrategy}
</span>
</div>
{scanMut.isPending && (
<div style={{ padding: '20px 0' }}>
<Loading />
<div style={{ textAlign: 'center', marginTop: 10, fontSize: 12, color: 'var(--text3)' }}>
Fetching gainers, running ORB simulation (~5-10s)
</div>
</div>
)}
{scanMut.isError && (
<div style={{ color: 'var(--red)', fontSize: 13 }}>
Error: {(scanMut.error as Error).message}
</div>
)}
{result && (
<div>
{/* Summary row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 28, fontWeight: 800, color: result.count_passed > 0 ? 'var(--cyan)' : 'var(--text2)' }}>{result.count_passed}</span>
<span style={{ fontSize: 14, color: 'var(--text3)' }}>/ {result.count_fetched} pass</span>
</div>
<div style={{ flex: 1, fontSize: 12, color: 'var(--text3)' }}>
Scanned {new Date(result.scan_time).toLocaleTimeString()}
</div>
{result.market_status === 'closed' && (
<div style={{ fontSize: 12, color: 'var(--text3)', background: 'var(--bg2)', padding: '4px 10px', borderRadius: 6 }}>
Market closed historical signal
</div>
)}
</div>
{/* Results table */}
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
{['Ticker', 'Price', 'Change%', 'Signal', 'Summary', 'Reason'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontSize: 11, color: 'var(--text3)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{displayList.map((r: GainerResult, i) => (
<tr
key={r.ticker}
style={{ borderBottom: '1px solid var(--border-subtle)', background: i % 2 === 0 ? 'transparent' : 'var(--bg2)', cursor: r.signal !== 'NO_ENTRY' ? 'pointer' : 'default' }}
onClick={() => r.signal !== 'NO_ENTRY' && onSelectTicker(r.ticker)}
>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: r.signal === 'ENTRY' ? 'var(--cyan)' : r.signal === 'SCALE_ENTRY' ? 'var(--orange)' : 'var(--text2)' }}>
{r.ticker}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text1)' }}>
{r.price != null ? `$${r.price.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: r.change_pct != null && r.change_pct > 0 ? 'var(--green)' : 'var(--red)' }}>
{r.change_pct != null ? `${r.change_pct > 0 ? '+' : ''}${(r.change_pct * 100).toFixed(1)}%` : '—'}
</td>
<td style={{ padding: '8px 12px' }}>
<SignalBadge signal={r.signal as Signal} />
{r.scale_factor != null && r.scale_factor < 1.0 && (
<span style={{ marginLeft: 6, fontSize: 11, color: 'var(--orange)' }}>{r.scale_factor.toFixed(1)}x</span>
)}
</td>
<td style={{ padding: '8px 12px', fontSize: 12, color: 'var(--text2)', fontFamily: 'var(--font-mono)' }}>
{r.filter_summary}
</td>
<td style={{ padding: '8px 12px', fontSize: 12, color: 'var(--text3)', maxWidth: 200 }}>
{r.failure_reason ?? (r.signal !== 'NO_ENTRY' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--cyan)' }}>
<ChevronRight size={12} />check single
</span>
) : null)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{failed.length > 5 && !showAll && (
<button style={{ ...btn('ghost'), marginTop: 12, fontSize: 12 }} onClick={() => setShowAll(true)}>
<Minus size={12} />
Show all {failed.length} rejected tickers
</button>
)}
</div>
)}
</div>
);
}
// ── 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<Tab>('check');
const [exitPrefill, setExitPrefill] = useState<{ ticker: string; details: Record<string, unknown> } | undefined>();
const [selectedStrategy, setSelectedStrategy] = useState<string>(() => {
try { return localStorage.getItem(STRATEGY_KEY) || ORB_DEFAULT_STRATEGY; } catch { return ORB_DEFAULT_STRATEGY; }
});
const { data: strategiesData } = useQuery<StrategiesResponse>({
queryKey: ['orbStrategies'],
queryFn: orbScannerApi.strategies,
staleTime: Infinity,
});
useEffect(() => {
try { localStorage.setItem(STRATEGY_KEY, selectedStrategy); } catch {}
}, [selectedStrategy]);
const handleSelectForExit = (ticker: string, details: Record<string, unknown>) => {
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: <Target size={14} /> },
{ id: 'exit', label: 'Exit Monitor', icon: <TrendingDown size={14} /> },
{ id: 'gainers', label: 'Gainers Scan', icon: <Radar size={14} /> },
];
const selectedName = strategiesData?.strategies.find(s => s.id === selectedStrategy)?.name ?? selectedStrategy;
return (
<div style={{ padding: '36px 40px', maxWidth: '80vw' }}>
{/* Header */}
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Target size={22} color="var(--cyan)" />
<div style={{ flex: 1 }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text1)' }}>ORB Scanner</h1>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{selectedName}</div>
</div>
{/* Strategy selector */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Strategy</span>
<select
value={selectedStrategy}
onChange={e => setSelectedStrategy(e.target.value)}
style={{
padding: '6px 10px',
fontSize: 12,
fontFamily: 'var(--font-mono)',
background: 'var(--bg2)',
border: '1px solid var(--border-md)',
borderRadius: 7,
color: 'var(--text1)',
cursor: 'pointer',
maxWidth: 320,
}}
>
{(strategiesData?.strategies ?? [{ id: ORB_DEFAULT_STRATEGY, name: ORB_DEFAULT_STRATEGY }]).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
{/* Market status banner is shown inside tab content, driven by API responses */}
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 7,
padding: '8px 18px', fontSize: 13, fontWeight: activeTab === tab.id ? 600 : 400,
background: 'transparent', border: 'none', cursor: 'pointer',
borderBottom: activeTab === tab.id ? '2px solid var(--cyan)' : '2px solid transparent',
marginBottom: -1,
color: activeTab === tab.id ? 'var(--cyan)' : 'var(--text3)',
transition: 'color 0.12s',
}}
>
{tab.icon}{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div style={card}>
<div style={{ padding: '24px 28px' }}>
{activeTab === 'check' && (
<SingleCheckPanel
onSelectTicker={handleSelectForExit}
selectedStrategy={selectedStrategy}
/>
)}
{activeTab === 'exit' && (
<ExitMonitorPanel prefill={exitPrefill} selectedStrategy={selectedStrategy} />
)}
{activeTab === 'gainers' && (
<GainersScanPanel onSelectTicker={handleGainerClick} selectedStrategy={selectedStrategy} />
)}
</div>
</div>
</div>
);
}
Loading…
Cancel
Save