Add "지금 시작" button for late-added ORB sessions

- GET /orb/sessions now returns ran_today boolean (true if daily_state.phase
  is set, meaning engine ran ORB detection for today)
- POST /orb/sessions/{id}/run_today: fires ORB detection in background and
  injects remaining today-events (breakout, stop, EOD) into the live schedule
- ORBAutoScheduler.run_session_now(): coroutine that runs detection then
  splices session's future events into self._today_schedule
- Session card shows a cyan "지금 시작 (현재 가격 기준)" button when
  ran_today === false; hides it once detection has run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 5bc648f4c7
commit 76a5d70004

@ -471,6 +471,59 @@ class ORBAutoScheduler:
self._log(f" ERROR {session_name}: {exc}")
log.error("ORB engine error: %s\n%s", exc, tb)
# ── Run-now: manually trigger detection for a late-added session ─────────
async def run_session_now(self, session_name: str) -> dict[str, Any]:
"""Run ORB detection immediately for a session that missed the morning window.
After detection, injects the session's remaining today-events into the
active schedule so breakout checks, stop checks, and EOD exit still fire.
Returns a summary dict or {"error": "<reason>"} on failure.
"""
from apps.orb_trader.state import ORBStateManager
now_et = self._now_et()
today = now_et.date()
date_str = today.isoformat()
state_mgr = ORBStateManager(self._db_path)
session = state_mgr.get_session(session_name)
if session is None:
return {"error": f"Session '{session_name}' not found"}
# Already ran if daily_state.phase is set (engine wrote it during orb_detect)
daily = state_mgr.get_daily_state(session.session_id, date_str)
if daily.phase:
return {"error": f"Session '{session_name}' already ran today (phase={daily.phase})"}
self._log(f"🔄 지금 시작: {session_name} — ORB 감지 실행 중 (현재 시세 기준)...")
# Run detection now (blocking in thread so we don't stall the event loop)
await self._run_trading("orb_detect", [session_name], date_str)
# Inject remaining future events into the live schedule
params = _load_session_params(self._db_path, session_name)
all_events = build_schedule(today, **params)
for ev in all_events:
ev["session"] = session_name
ev["name"] = f"{session_name}:{ev['name']}"
existing_names = {e["name"] for e in self._today_schedule}
injected = 0
for ev in all_events:
if ev["et_dt"] <= now_et:
# Past events: mark as completed so the loop skips them
self._completed.add(ev["name"])
elif ev["name"] not in existing_names:
self._today_schedule.append(ev)
injected += 1
self._today_schedule.sort(key=lambda e: (e["et_dt"], e.get("session", "")))
self._log(f"{session_name}: {injected} 이벤트 추가됨 (오늘 남은 일정 포함)")
return {"session": session_name, "injected": injected}
# ── Main scheduler loop ────────────────────────────────────────────────────
async def _run_loop(self) -> None:

@ -0,0 +1,403 @@
"""ORB Paper Trading API endpoints."""
from __future__ import annotations
import datetime as dt
import os
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/orb", tags=["orb-trading"])
# ---------------------------------------------------------------------------
# DB path resolution
# ---------------------------------------------------------------------------
def _db_path() -> str:
from pathlib import Path
project_root = Path(__file__).parent.parent.parent.parent
env_path = os.environ.get("ORB_TRADER_DB", "data/paper/orb.db")
path = Path(env_path) if Path(env_path).is_absolute() else project_root / env_path
path.parent.mkdir(parents=True, exist_ok=True)
return str(path)
def _state() -> Any:
from apps.orb_trader.state import ORBStateManager
return ORBStateManager(_db_path())
# ---------------------------------------------------------------------------
# Request/Response models
# ---------------------------------------------------------------------------
class CreateSessionRequest(BaseModel):
name: str
config: str # config path (relative to project root)
capital: float = 10000.0
class AutoStartRequest(BaseModel):
sessions: list[str] = []
dry_run: bool = False
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
@router.get("/sessions")
def list_sessions() -> dict[str, Any]:
state = _state()
sessions = state.list_sessions()
today = dt.date.today().isoformat()
results = []
for s in sessions:
daily = state.get_daily_state(s.session_id, today)
ran_today = bool(daily.phase) # phase is set by engine on first run
results.append({
"session_id": s.session_id,
"session_name": s.session_name,
"config_path": s.config_path,
"initial_equity": s.initial_equity,
"created_at": s.created_at,
"status": s.status,
"ran_today": ran_today,
})
return {"sessions": results}
@router.post("/sessions")
def create_session(req: CreateSessionRequest) -> dict[str, Any]:
from pathlib import Path
import yaml
project_root = Path(__file__).parent.parent.parent.parent
config_path = req.config if Path(req.config).is_absolute() else str(project_root / req.config)
if not Path(config_path).exists():
raise HTTPException(status_code=404, detail=f"Config not found: {config_path}")
# Validate it's an ORB strategy config
try:
with open(config_path) as f:
raw = yaml.safe_load(f)
if raw.get("strategy_mode") != "orb":
raise HTTPException(
status_code=400,
detail=f"Config strategy_mode must be 'orb', got '{raw.get('strategy_mode')}'"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid config: {e}")
state = _state()
# Check duplicate name
if state.get_session(req.name) is not None:
raise HTTPException(status_code=409, detail=f"Session '{req.name}' already exists")
session_id = state.create_session(req.name, config_path, req.capital)
return {
"session_id": session_id,
"session_name": req.name,
"config_path": config_path,
"initial_equity": req.capital,
}
@router.get("/sessions/{session_id}")
def get_session(session_id: str) -> dict[str, Any]:
session = _state().get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
equity = _state().get_equity(session.session_id) or session.initial_equity
return {
"session_id": session.session_id,
"session_name": session.session_name,
"config_path": session.config_path,
"initial_equity": session.initial_equity,
"current_equity": equity,
"total_return_pct": (equity - session.initial_equity) / session.initial_equity * 100,
"created_at": session.created_at,
"status": session.status,
}
@router.post("/sessions/{session_id}/pause")
def pause_session(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
state.set_session_status(session.session_id, "paused")
return {"session_id": session.session_id, "status": "paused"}
@router.post("/sessions/{session_id}/resume")
def resume_session(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
state.set_session_status(session.session_id, "active")
return {"session_id": session.session_id, "status": "active"}
@router.post("/sessions/{session_id}/run_today")
async def run_session_today(session_id: str) -> dict[str, Any]:
"""Immediately run ORB detection for a session that missed the morning window.
Requires the auto-scheduler to be running. After detection, the session's
remaining today-events (breakout checks, stop checks, EOD exit) are injected
into the live schedule automatically.
"""
import asyncio as _asyncio
from apps.web.orb_trading_service import orb_auto_scheduler
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
if not orb_auto_scheduler.running:
raise HTTPException(
status_code=400,
detail="스케줄러가 실행 중이 아닙니다. 먼저 자동 스케줄러를 시작하세요.",
)
# Fire-and-forget — detection takes ~12 min; we return immediately
_asyncio.create_task(orb_auto_scheduler.run_session_now(session.session_name))
return {
"session_id": session.session_id,
"session_name": session.session_name,
"status": "started",
"note": "ORB 감지가 백그라운드에서 시작되었습니다. 스케줄러 로그를 확인하세요.",
}
@router.delete("/sessions/{session_id}")
def close_session(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
# Close all open Alpaca positions across all dates
positions_closed = 0
close_errors: list[str] = []
try:
from apps.paper_trader.alpaca_broker import AlpacaBroker
broker = AlpacaBroker.from_env()
open_pos = state.get_all_open_positions(session.session_id)
for pos in open_pos:
try:
# Always close only this session's shares (partial close if other
# sessions also hold the same ticker in the same Alpaca account)
broker.close_position(pos.ticker, qty=int(pos.shares))
positions_closed += 1
except Exception as e:
close_errors.append(f"{pos.ticker}: {e}")
except Exception as e:
# Broker init failed — refuse to delete so user can investigate
raise HTTPException(status_code=500, detail=f"Alpaca broker error: {e}")
# Always delete the session; report any per-ticker failures in response
state.delete_session(session.session_id)
return {
"deleted": True,
"positions_closed": positions_closed,
"close_errors": close_errors, # empty list if all succeeded
}
# ---------------------------------------------------------------------------
# Data endpoints
# ---------------------------------------------------------------------------
@router.get("/sessions/{session_id}/positions")
def get_positions(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
today = dt.date.today().isoformat()
positions = state.get_open_positions(session.session_id, today)
# Enrich with real-time prices from Oracle/Alpaca snapshot API
from libs.oracle_client.alpaca import get_snapshots, AlpacaSnapshot
snapshots: dict[str, AlpacaSnapshot] = {}
if positions:
tickers = [p.ticker for p in positions]
try:
snapshots = get_snapshots(tickers)
except Exception:
pass
def _pos_to_dict(p: Any) -> dict[str, Any]:
snap = snapshots.get(p.ticker)
current_price = (snap.price if snap and snap.price else None) or p.entry_price
bid = snap.bid if snap else None
ask = snap.ask if snap else None
change_pct = snap.change_pct if snap else None
if p.direction == "long":
unrealized_pnl = (current_price - p.entry_price) * p.shares
r_multiple = (current_price - p.entry_price) / p.stop_distance if p.stop_distance > 0 else 0
else:
unrealized_pnl = (p.entry_price - current_price) * p.shares
r_multiple = (p.entry_price - current_price) / p.stop_distance if p.stop_distance > 0 else 0
return {
"ticker": p.ticker,
"direction": p.direction,
"entry_price": p.entry_price,
"entry_time": p.entry_time,
"shares": p.shares,
"current_stop": p.current_stop,
"peak_price": p.peak_price,
"trailing_active": p.trailing_active,
"atr_at_entry": p.atr_at_entry,
"stop_distance": p.stop_distance,
"rvol": p.rvol,
"composite_score": p.composite_score,
"current_price": current_price,
"bid": bid,
"ask": ask,
"change_pct": change_pct,
"unrealized_pnl": round(unrealized_pnl, 2),
"r_multiple": round(r_multiple, 3),
}
return {"positions": [_pos_to_dict(p) for p in positions], "date": today}
@router.get("/sessions/{session_id}/trades")
def get_trades(session_id: str, last: int | None = None) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
trades = state.list_trades(session.session_id, limit=last)
return {"trades": trades, "total": len(trades)}
@router.get("/sessions/{session_id}/equity")
def get_equity(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
snapshots = state.list_snapshots(session.session_id)
return {"snapshots": snapshots, "initial_equity": session.initial_equity}
@router.get("/sessions/{session_id}/candidates")
def get_candidates(session_id: str) -> dict[str, Any]:
state = _state()
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
today = dt.date.today().isoformat()
candidates = state.list_candidates(session.session_id, today)
return {"candidates": candidates, "date": today}
# ---------------------------------------------------------------------------
# Auto scheduler
# ---------------------------------------------------------------------------
@router.get("/auto")
def get_auto_status() -> dict[str, Any]:
from apps.web.orb_trading_service import orb_auto_scheduler
return orb_auto_scheduler.get_status()
@router.post("/auto/start")
async def start_auto(req: AutoStartRequest) -> dict[str, Any]:
from apps.web.orb_trading_service import orb_auto_scheduler
if orb_auto_scheduler.running:
raise HTTPException(status_code=409, detail="Scheduler already running")
try:
orb_auto_scheduler.start(
sessions=req.sessions,
db_path=_db_path(),
dry_run=req.dry_run,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"started": True, "dry_run": req.dry_run, "sessions": req.sessions}
@router.post("/auto/stop")
async def stop_auto() -> dict[str, Any]:
from apps.web.orb_trading_service import orb_auto_scheduler
orb_auto_scheduler.stop()
return {"stopped": True}
# ---------------------------------------------------------------------------
# Strategies — list ORB configs with config_path for session creation
# ---------------------------------------------------------------------------
@router.get("/strategies")
def list_orb_strategies() -> dict[str, Any]:
"""List all ORB strategy configs available for paper trading sessions.
Returns both built-in presets and every *.yaml under
configs/intraday/strategies/ that has strategy_mode=orb.
Always includes config_path so the frontend can pass it to POST /sessions.
"""
from pathlib import Path
import yaml as _yaml
from apps.web.routers.intraday import _BUILTIN_STRATEGIES, get_project_root
strategies: list[dict[str, Any]] = []
# 1. Built-in presets (always include, they are all ORB)
for strat in _BUILTIN_STRATEGIES.values():
strategies.append({
"slug": strat["slug"],
"name": strat["name"],
"description": strat.get("description", ""),
"builtin": True,
"config_path": strat["config_path"],
"orb_minutes": strat.get("orb_minutes"),
"sim_bar_minutes": strat.get("sim_bar_minutes"),
"entry_direction": strat.get("entry_direction", "long_only"),
"risk_per_trade_pct": strat.get("risk_per_trade_pct"),
"atr_stop_multiplier": strat.get("atr_stop_multiplier"),
})
# 2. YAMLs in configs/intraday/strategies/ not already listed as builtins
builtin_paths = {s["config_path"] for s in _BUILTIN_STRATEGIES.values()}
strategies_dir = Path(get_project_root()) / "configs" / "intraday" / "strategies"
if strategies_dir.exists():
for yaml_file in sorted(strategies_dir.glob("*.yaml")):
rel_path = str(yaml_file.relative_to(get_project_root()))
if rel_path in builtin_paths:
continue
try:
raw = _yaml.safe_load(yaml_file.read_text()) or {}
if raw.get("strategy_mode") != "orb":
continue
meta = raw.get("_meta", {})
orb = raw.get("orb_strategy", {})
slug = yaml_file.stem
strategies.append({
"slug": slug,
"name": meta.get("name", slug),
"description": meta.get("description", ""),
"builtin": False,
"config_path": rel_path,
"orb_minutes": orb.get("orb_minutes"),
"sim_bar_minutes": orb.get("sim_bar_minutes"),
"entry_direction": orb.get("entry_direction", "long_only"),
"risk_per_trade_pct": orb.get("risk_per_trade_pct"),
"atr_stop_multiplier": orb.get("atr_stop_multiplier"),
})
except Exception:
continue
return {"strategies": strategies}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fithia2</title>
<script type="module" crossorigin src="/assets/index-C2901qKI.js"></script>
<script type="module" crossorigin src="/assets/index-CNWrJRnR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVDbFxMk.css">
</head>
<body>

@ -329,6 +329,175 @@ export const backtestApi = {
request<{ snapshots: { id: string; start: string | null; end: string | null; rows: number | null }[] }>('/backtest/snapshots'),
};
// --- Intraday Backtest (ORB) ---
export interface IntradayTask {
task_id: string;
config: string;
days: number;
universe: string;
year: string | null;
start_date: string | null;
end_date: string | null;
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
created_at: string;
started_at: string | null;
finished_at: string | null;
pid: number | null;
error: string | null;
result_file: string | null;
result_summary: {
return_pct: number | null;
max_dd_pct: number | null;
sharpe: number | null;
win_rate: number | null;
trade_count: number | null;
final_equity: number | null;
calmar: number | null;
} | null;
}
export interface IntradayTrade {
date: string;
ticker: string;
entry_price: number;
exit_price: number;
entry_time: string;
exit_time: string;
shares: number;
pnl: number;
pnl_pct: number;
exit_reason: string;
morning_gain_pct: number;
slippage_cost: number;
orb_direction: string | null;
rvol: number | null;
atr_at_entry: number | null;
r_multiple_at_exit: number | null;
}
export interface IntradayResult {
run_id: string;
generated_at: string;
config: Record<string, unknown>;
metrics: {
run_id: string;
start_date: string;
end_date: string;
trading_days: number;
total_trades: number;
win_rate: number | null;
avg_win_pct: number | null;
avg_loss_pct: number | null;
profit_factor: number | null;
total_return_pct: number | null;
annualized_return_pct: number | null;
max_drawdown_pct: number | null;
sharpe_ratio: number | null;
calmar_ratio: number | null;
initial_capital: number;
final_equity: number;
};
trades: IntradayTrade[];
daily_summary: { date: string; daily_pnl: number; daily_return_pct: number; candidates_found: number; trades: number }[];
}
export interface IntradayStrategy {
slug: string;
name: string;
description: string;
builtin: boolean;
days: number;
universe: string;
initial_capital: number;
risk_per_trade_pct: number;
max_position_pct: number;
atr_stop_multiplier: number;
min_rvol: number;
max_candidates: number;
daily_max_loss_pct: number;
max_stops_per_day: number;
breakeven_at_r: number;
trailing_at_r: number;
trailing_stop_atr_multiplier?: number;
order_timeout_minutes: number;
settlement_days?: number;
max_gap_pct?: number | null;
sim_bar_minutes?: number;
orb_minutes?: number;
compound_returns?: boolean;
}
export type CreateStrategyPayload = Omit<IntradayStrategy, 'slug' | 'builtin'>;
export type UpdateStrategyPayload = Partial<Omit<IntradayStrategy, 'slug' | 'builtin'>>;
/** ORB strategy entry returned by GET /orb/strategies — always includes config_path. */
export interface OrbStrategyInfo {
slug: string;
name: string;
description: string;
builtin: boolean;
config_path: string;
orb_minutes?: number | null;
sim_bar_minutes?: number | null;
entry_direction?: string | null;
risk_per_trade_pct?: number | null;
atr_stop_multiplier?: number | null;
}
export const intradayApi = {
submit: (req: {
config: string;
days: number;
universe: string;
year?: string | null;
start_date?: string | null;
end_date?: string | null;
compound_returns?: boolean;
initial_capital?: number;
}) =>
request<{ task_id: string; status: string }>('/intraday/backtest/submit', {
method: 'POST',
body: JSON.stringify(req),
}),
tasks: () => request<{ tasks: IntradayTask[] }>('/intraday/backtest/tasks'),
task: (id: string) => request<IntradayTask>(`/intraday/backtest/tasks/${id}`),
cancel: (id: string) =>
request<{ task_id: string; status: string }>(`/intraday/backtest/tasks/${id}`, { method: 'DELETE' }),
taskLog: (id: string) => request<{ log: string }>(`/intraday/backtest/tasks/${id}/log`),
taskResult: (id: string) => request<IntradayResult>(`/intraday/backtest/tasks/${id}/result`),
// Strategy CRUD
strategies: () => request<{ strategies: IntradayStrategy[] }>('/intraday/strategies'),
getStrategy: (slug: string) => request<IntradayStrategy>(`/intraday/strategies/${encodeURIComponent(slug)}`),
createStrategy: (payload: CreateStrategyPayload) =>
request<IntradayStrategy>('/intraday/strategies', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateStrategy: (slug: string, payload: UpdateStrategyPayload) =>
request<IntradayStrategy>(`/intraday/strategies/${encodeURIComponent(slug)}`, {
method: 'PUT',
body: JSON.stringify(payload),
}),
deleteStrategy: (slug: string) =>
request<{ slug: string; deleted: boolean }>(`/intraday/strategies/${encodeURIComponent(slug)}`, {
method: 'DELETE',
}),
deleteTask: (id: string) =>
request<{ task_id: string; deleted: boolean }>(`/intraday/backtest/tasks/${id}/delete`, { method: 'DELETE' }),
};
// --- Docs ---
export const docsApi = {
get: (filename: string) =>
@ -568,6 +737,175 @@ export const paperApi = {
},
};
// --- ORB Paper Trading ---
export interface OrbSession {
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; // true if ORB detection already ran for today
}
export interface OrbPosition {
ticker: string;
direction: 'long' | 'short';
entry_price: number;
entry_time: string;
shares: number;
current_stop: number;
peak_price: number;
trailing_active: boolean;
atr_at_entry: number;
stop_distance: number;
rvol: number | null;
composite_score: number | null;
current_price: number;
bid: number | null;
ask: number | null;
change_pct: number | null;
unrealized_pnl: number;
r_multiple: number;
}
export interface OrbTrade {
trade_id: string;
session_id: string;
date: string;
ticker: string;
direction: string;
entry_price: number;
exit_price: number;
entry_time: string;
exit_time: string;
shares: number;
pnl: number;
r_multiple: number;
exit_reason: string;
atr_at_entry: number | null;
rvol: number | null;
composite_score: number | null;
}
export interface OrbSnapshot {
session_id: string;
date: string;
equity: number;
daily_pnl: number;
total_pnl: number;
trades_taken: number;
stops_hit: number;
drawdown_pct: number;
}
export interface OrbCandidate {
id: number;
session_id: string;
date: string;
ticker: string;
direction: string;
orb_high: number;
orb_low: number;
breakout_level: number;
atr: number;
rvol: number;
gap_pct: number;
composite_score: number;
status: string;
}
export interface OrbScheduleEvent {
name: string;
kind: string;
session: string;
label: string;
et_time: string;
et_iso: string;
past: boolean;
done: boolean;
wait_secs: number;
}
export const orbTradingApi = {
// Sessions
sessions: () => request<{ sessions: OrbSession[] }>('/orb/sessions'),
createSession: (name: string, config: string, capital: number) =>
request<{ session_id: string; session_name: string; initial_equity: number }>(
'/orb/sessions',
{ method: 'POST', body: JSON.stringify({ name, config, capital }) },
),
getSession: (id: string) => request<OrbSession>(`/orb/sessions/${encodeURIComponent(id)}`),
pauseSession: (id: string) =>
request<{ session_id: string; status: string }>(
`/orb/sessions/${encodeURIComponent(id)}/pause`, { method: 'POST' }
),
resumeSession: (id: string) =>
request<{ session_id: string; status: string }>(
`/orb/sessions/${encodeURIComponent(id)}/resume`, { method: 'POST' }
),
closeSession: (id: string) =>
request<{ deleted: boolean; positions_closed: number; close_errors: string[] }>(
`/orb/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }
),
runToday: (id: string) =>
request<{ session_id: string; session_name: string; status: string; note: string }>(
`/orb/sessions/${encodeURIComponent(id)}/run_today`, { method: 'POST' }
),
// Data
positions: (id: string) =>
request<{ positions: OrbPosition[]; date: string }>(
`/orb/sessions/${encodeURIComponent(id)}/positions`
),
trades: (id: string, last?: number) =>
request<{ trades: OrbTrade[]; total: number }>(
`/orb/sessions/${encodeURIComponent(id)}/trades${last != null ? `?last=${last}` : ''}`
),
equity: (id: string) =>
request<{ snapshots: OrbSnapshot[]; initial_equity: number }>(
`/orb/sessions/${encodeURIComponent(id)}/equity`
),
candidates: (id: string) =>
request<{ candidates: OrbCandidate[]; date: string }>(
`/orb/sessions/${encodeURIComponent(id)}/candidates`
),
// Auto scheduler
autoStatus: () =>
request<{
running: boolean;
sessions: string[];
dry_run: boolean;
schedule: OrbScheduleEvent[];
log_tail: string[];
log_line_count: number;
}>('/orb/auto'),
autoStart: (sessions?: string[], dryRun?: boolean) =>
request<{ started: boolean; dry_run: boolean; sessions: string[] }>(
'/orb/auto/start',
{ method: 'POST', body: JSON.stringify({ sessions: sessions ?? [], dry_run: dryRun ?? false }) }
),
autoStop: () => request<{ stopped: boolean }>('/orb/auto/stop', { method: 'POST' }),
// Strategies
strategies: () => request<{ strategies: OrbStrategyInfo[] }>('/orb/strategies'),
};
// --- SQS ---
export interface SqsPillars {
rqs_score: number | null;

@ -0,0 +1,913 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import {
Zap, Play, Square, Plus, Trash2, Pause, RotateCcw,
TrendingUp, TrendingDown, ChevronDown, PlayCircle,
} from 'lucide-react';
import {
orbTradingApi,
type OrbSession,
type OrbScheduleEvent,
type OrbStrategyInfo,
} from '../api/client';
import { Loading, ErrorState } 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 = {
width: '100%',
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',
};
const btn = (variant: 'primary' | 'danger' | 'ghost' | 'outline' = 'outline'): React.CSSProperties => ({
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '7px 14px', 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)',
});
function fmt(n: number, dp = 2) { return n.toFixed(dp); }
function fmtPct(n: number) { return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`; }
function fmtDollars(n: number) {
return `${n >= 0 ? '+' : ''}$${Math.abs(n).toFixed(2)}`;
}
// ── Scheduler Panel ───────────────────────────────────────────────────────────
function SchedulerPanel({ selectedSessions }: { selectedSessions: string[] }) {
const qc = useQueryClient();
const [showLog, setShowLog] = useState(false);
const logRef = useRef<HTMLPreElement>(null);
const { data, isLoading } = useQuery({
queryKey: ['orb-auto-status'],
queryFn: orbTradingApi.autoStatus,
refetchInterval: 5000,
});
const startMut = useMutation({
mutationFn: () => orbTradingApi.autoStart(selectedSessions.length > 0 ? selectedSessions : []),
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-auto-status'] }),
onError: (e: Error) => alert(`스케줄러 시작 실패: ${e.message}`),
});
const stopMut = useMutation({
mutationFn: orbTradingApi.autoStop,
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-auto-status'] }),
onError: (e: Error) => alert(`스케줄러 중지 실패: ${e.message}`),
});
const logLines = data?.log_tail ?? [];
// Auto-scroll to bottom when new log lines arrive (must be before early return)
useEffect(() => {
if (showLog && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [logLines.length, showLog]);
if (isLoading) return <div style={{ padding: 20 }}><Loading /></div>;
const running = data?.running ?? false;
const schedule = data?.schedule ?? [];
// Find next upcoming event
const nextEvent = schedule.find(e => !e.done && e.wait_secs > 0);
return (
<div style={{ ...card, marginBottom: 24 }}>
<div style={{
padding: '14px 20px',
background: running ? 'color-mix(in srgb, var(--green) 8%, transparent)' : 'var(--bg2)',
borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: running ? 'var(--green)' : 'var(--text3)',
animation: running ? 'pulse-line 2s infinite' : 'none',
}} />
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text1)' }}>
ORB
</span>
{running && data?.dry_run && (
<span style={{ fontSize: 11, color: 'var(--orange)', background: 'color-mix(in srgb, var(--orange) 12%, transparent)', padding: '2px 8px', borderRadius: 4 }}>
DRY RUN
</span>
)}
{running && !data?.dry_run && (
<span style={{ fontSize: 11, color: 'var(--green)', background: 'color-mix(in srgb, var(--green) 12%, transparent)', padding: '2px 8px', borderRadius: 4 }}>
LIVE
</span>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
style={btn(running ? 'danger' : 'primary')}
onClick={() => running ? stopMut.mutate() : startMut.mutate()}
disabled={startMut.isPending || stopMut.isPending}
>
{running ? <><Square size={13} /> </> : <><Play size={13} /> </>}
</button>
</div>
</div>
{/* Schedule timeline — per-session rows */}
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(() => {
// Group events by session
const sessionNames = Array.from(new Set(schedule.map((e: OrbScheduleEvent) => e.session))).filter(Boolean) as string[];
const renderSessionRow = (sessionName: string) => {
const sessionEvs = schedule.filter((e: OrbScheduleEvent) => e.session === sessionName);
const breakouts = sessionEvs.filter((e: OrbScheduleEvent) => e.kind === 'breakout');
const displayed = sessionEvs.filter((ev: OrbScheduleEvent) => {
if (['orb_monitor', 'orb_detect', 'stop_check', 'eod_exit', 'post_close'].includes(ev.kind)) return true;
if (ev.kind === 'breakout') return ev === breakouts[0] || ev === breakouts[breakouts.length - 1];
return false;
});
const isNext = (ev: OrbScheduleEvent) => nextEvent && ev.name === nextEvent.name;
return (
<div key={sessionName} style={{ marginBottom: sessionNames.length > 1 ? 8 : 0 }}>
{sessionNames.length > 1 && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 4, fontFamily: 'var(--font-mono)' }}>
{sessionName}
</div>
)}
<div style={{ display: 'flex', gap: 3, minWidth: 'max-content', flexWrap: 'nowrap' }}>
{displayed.map((ev: OrbScheduleEvent) => (
<div
key={ev.name}
title={`[${ev.session}] ${ev.label}\n${ev.et_time}${ev.done ? ' ✓' : ev.wait_secs > 0 ? `${fmtCountdown(ev.wait_secs)}` : ''}`}
style={{
padding: '3px 7px',
borderRadius: 5,
fontSize: 10,
fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap',
background: ev.done
? 'color-mix(in srgb, var(--green) 15%, transparent)'
: isNext(ev)
? 'color-mix(in srgb, var(--cyan) 20%, transparent)'
: ev.kind === 'orb_monitor'
? 'color-mix(in srgb, var(--yellow) 8%, transparent)'
: 'var(--bg2)',
color: ev.done ? 'var(--green)' : isNext(ev) ? 'var(--cyan)' : ev.kind === 'orb_monitor' ? 'color-mix(in srgb, var(--yellow) 70%, var(--text3))' : 'var(--text3)',
border: `1px solid ${ev.done ? 'var(--green)' : isNext(ev) ? 'var(--cyan)' : ev.kind === 'orb_monitor' ? 'color-mix(in srgb, var(--yellow) 30%, transparent)' : 'var(--border)'}`,
opacity: ev.kind === 'orb_monitor' && !ev.done && !isNext(ev) ? 0.65 : 1,
}}
>
{ev.et_time.replace(' ET', '')}
{ev.done && ' ✓'}
{ev.kind === 'breakout' && ev === breakouts[0] && breakouts.length > 1 && (
<span style={{ color: 'var(--text3)', marginLeft: 2 }}>
~{breakouts[breakouts.length - 1]?.et_time?.replace(' ET', '')}
</span>
)}
</div>
))}
</div>
</div>
);
};
return sessionNames.length > 0 ? sessionNames.map(renderSessionRow) : (
<div style={{ fontSize: 11, color: 'var(--text3)' }}>( )</div>
);
})()}
{nextEvent && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text2)' }}>
: <strong style={{ color: 'var(--cyan)' }}>{nextEvent.label}</strong>
{' — '}<Countdown seconds={nextEvent.wait_secs} />
</div>
)}
</div>
{/* Log */}
<div>
<button
style={{ ...btn('ghost'), width: '100%', justifyContent: 'space-between', borderRadius: 0, padding: '8px 20px' }}
onClick={() => setShowLog(v => !v)}
>
<span style={{ fontSize: 12 }}>
{logLines.length > 0 && (
<span style={{ marginLeft: 8, color: 'var(--text3)', fontWeight: 400 }}>
({logLines.length})
</span>
)}
</span>
<ChevronDown size={13} style={{ transform: showLog ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }} />
</button>
{showLog && (
<pre ref={logRef} style={{
margin: 0, padding: '10px 20px', maxHeight: 480, overflowY: 'auto',
fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text2)',
background: 'var(--bg2)', whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{logLines.length > 0 ? logLines.join('\n') : '(로그 없음)'}
</pre>
)}
</div>
</div>
);
}
function fmtCountdown(seconds: number): string {
if (seconds <= 0) return 'now';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
return `${s}s`;
}
function Countdown({ seconds }: { seconds: number }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
setRemaining(seconds);
const iv = setInterval(() => setRemaining(r => Math.max(0, r - 1)), 1000);
return () => clearInterval(iv);
}, [seconds]);
return <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--cyan)' }}>{fmtCountdown(remaining)}</span>;
}
// ── Session Card ──────────────────────────────────────────────────────────────
function SessionCard({
session,
selected,
onSelect,
onDelete,
onPause,
onResume,
onRunToday,
runTodayPending,
}: {
session: OrbSession;
selected: boolean;
onSelect: () => void;
onDelete: () => void;
onPause: () => void;
onResume: () => void;
onRunToday: () => void;
runTodayPending: boolean;
}) {
const totalReturn = session.total_return_pct ?? 0;
const equity = session.current_equity ?? session.initial_equity;
return (
<div
onClick={onSelect}
style={{
...card,
cursor: 'pointer',
border: `1px solid ${selected ? 'var(--cyan)' : 'var(--border)'}`,
background: selected ? 'color-mix(in srgb, var(--cyan) 5%, var(--bg1))' : 'var(--bg1)',
transition: 'all 0.12s',
}}
>
<div style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text1)', marginBottom: 2 }}>
{session.session_name}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
{session.status === 'active' ? '● active' : session.status === 'paused' ? '⏸ paused' : session.status}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 18, fontWeight: 700, color: totalReturn >= 0 ? 'var(--green)' : 'var(--red)', fontFamily: 'var(--font-mono)' }}>
{fmtPct(totalReturn)}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>${fmt(equity)}</div>
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.config_path.split('/').pop()}
</div>
{/* "지금 시작" — only when this session hasn't run today */}
{session.ran_today === false && (
<div style={{ marginBottom: 8 }}>
<button
style={{
...btn('outline'),
width: '100%', justifyContent: 'center',
borderColor: 'var(--cyan)', color: 'var(--cyan)',
background: 'color-mix(in srgb, var(--cyan) 6%, transparent)',
fontSize: 12,
}}
onClick={e => { e.stopPropagation(); onRunToday(); }}
disabled={runTodayPending}
title="오늘 ORB 감지를 지금 즉시 실행 (스케줄러 실행 중이어야 함)"
>
<PlayCircle size={13} />
{runTodayPending ? '감지 중...' : '지금 시작 (현재 가격 기준)'}
</button>
</div>
)}
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{session.status === 'active' ? (
<button style={btn('ghost')} onClick={onPause} title="일시 정지">
<Pause size={12} />
</button>
) : (
<button style={btn('ghost')} onClick={onResume} title="재개">
<RotateCcw size={12} />
</button>
)}
<button style={btn('danger')} onClick={onDelete} title="세션 삭제">
<Trash2 size={12} />
</button>
</div>
</div>
</div>
);
}
// ── Create Session Modal ──────────────────────────────────────────────────────
function StrategyBadge({ label, value }: { label: string; value: string }) {
return (
<span style={{
display: 'inline-block', padding: '2px 7px', borderRadius: 5, background: 'var(--bg2)',
fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text3)',
border: '1px solid var(--border)', whiteSpace: 'nowrap',
}}>
{label}: <span style={{ color: 'var(--text2)' }}>{value}</span>
</span>
);
}
function CreateSessionModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [selectedSlug, setSelectedSlug] = useState('');
const [capital, setCapital] = useState('10000');
const { data: stratData, isLoading: straLoading } = useQuery({
queryKey: ['orb-strategies'],
queryFn: orbTradingApi.strategies,
});
const strategies: OrbStrategyInfo[] = stratData?.strategies ?? [];
// Auto-select first strategy when list loads
useEffect(() => {
if (!selectedSlug && strategies.length > 0) {
setSelectedSlug(strategies[0].slug);
}
}, [strategies, selectedSlug]);
const selected = strategies.find(s => s.slug === selectedSlug) ?? null;
const createMut = useMutation({
mutationFn: () => {
if (!selected) throw new Error('전략을 선택하세요');
return orbTradingApi.createSession(name, selected.config_path, parseFloat(capital));
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
onClose();
},
});
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={onClose}>
<div style={{ ...card, width: 520, padding: 24, maxHeight: '90vh', overflowY: 'auto' }}
onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 18px', fontSize: 16, color: 'var(--text1)' }}>ORB </h3>
{/* Session name */}
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}> </label>
<input style={inputStyle} value={name} onChange={e => setName(e.target.value)} placeholder="예: v55_live" />
</div>
{/* Strategy selector */}
<div style={{ marginBottom: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}> </label>
{straLoading ? (
<div style={{ fontSize: 12, color: 'var(--text3)', padding: '8px 0' }}> ...</div>
) : (
<select
style={inputStyle}
value={selectedSlug}
onChange={e => setSelectedSlug(e.target.value)}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.slug} value={s.slug}>
{s.name}{s.builtin ? '' : ' ✦'}
</option>
))}
</select>
)}
</div>
{/* Strategy detail card */}
{selected && (
<div style={{
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 8,
padding: '10px 14px', marginBottom: 14, fontSize: 12,
}}>
{selected.description && (
<div style={{ color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
{selected.description}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{selected.orb_minutes != null && (
<StrategyBadge label="ORB" value={`${selected.orb_minutes}`} />
)}
{selected.sim_bar_minutes != null && (
<StrategyBadge label="Bar" value={`${selected.sim_bar_minutes}`} />
)}
{selected.entry_direction && (
<StrategyBadge
label="방향"
value={selected.entry_direction === 'both' ? 'Long+Short' : selected.entry_direction === 'long_only' ? 'Long only' : selected.entry_direction}
/>
)}
{selected.risk_per_trade_pct != null && (
<StrategyBadge label="리스크" value={`${(selected.risk_per_trade_pct * 100).toFixed(2)}%`} />
)}
{selected.atr_stop_multiplier != null && (
<StrategyBadge label="ATR스톱" value={`×${selected.atr_stop_multiplier}`} />
)}
</div>
<div style={{ marginTop: 7, fontSize: 10, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
{selected.config_path}
</div>
</div>
)}
{/* Capital */}
<div style={{ marginBottom: 20 }}>
<label style={{ fontSize: 12, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
(USD)
</label>
<input style={inputStyle} type="number" value={capital} onChange={e => setCapital(e.target.value)} />
</div>
{createMut.isError && (
<div style={{ color: 'var(--red)', fontSize: 12, marginBottom: 12 }}>
{(createMut.error as Error).message}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button style={btn('ghost')} onClick={onClose}></button>
<button
style={btn('primary')}
onClick={() => createMut.mutate()}
disabled={!name || !selected || createMut.isPending}
>
{createMut.isPending ? '생성 중...' : '세션 생성'}
</button>
</div>
</div>
</div>
);
}
// ── Session Detail ────────────────────────────────────────────────────────────
function PositionsPanel({ sessionId }: { sessionId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['orb-positions', sessionId],
queryFn: () => orbTradingApi.positions(sessionId),
refetchInterval: 30000,
});
if (isLoading) return <Loading />;
const positions = data?.positions ?? [];
if (positions.length === 0) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
({data?.date ?? '—'})
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
{['티커', '방향', '진입가', '수량', '현재가', '변동%', '현재 스톱', 'R', '미실현 P&L', '트레일'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{positions.map(pos => (
<tr key={pos.ticker} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{pos.ticker}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
color: pos.direction === 'long' ? 'var(--green)' : 'var(--red)',
}}>
{pos.direction === 'long' ? <TrendingUp size={11} /> : <TrendingDown size={11} />}
{pos.direction.toUpperCase()}
</span>
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(pos.entry_price)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{pos.shares}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(pos.current_price)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.change_pct == null ? 'var(--text3)' : pos.change_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>
{pos.change_pct == null ? '—' : `${pos.change_pct >= 0 ? '+' : ''}${pos.change_pct.toFixed(2)}%`}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(pos.current_stop)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.r_multiple >= 0 ? 'var(--green)' : 'var(--red)' }}>
{pos.r_multiple >= 0 ? '+' : ''}{fmt(pos.r_multiple)}R
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: pos.unrealized_pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
{fmtDollars(pos.unrealized_pnl)}
</td>
<td style={{ padding: '8px 12px', color: pos.trailing_active ? 'var(--cyan)' : 'var(--text3)' }}>
{pos.trailing_active ? '● 활성' : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function CandidatesPanel({ sessionId }: { sessionId: string }) {
const { data } = useQuery({
queryKey: ['orb-candidates', sessionId],
queryFn: () => orbTradingApi.candidates(sessionId),
refetchInterval: 60000,
});
const candidates = data?.candidates ?? [];
if (candidates.length === 0) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
ORB
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
{['티커', '방향', '돌파 레벨', 'ORB High', 'ORB Low', 'ATR', 'RVOL', 'Gap%', '점수', '상태'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{candidates.map((c: any) => (
<tr key={c.ticker} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{c.ticker}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: c.direction === 'bullish' ? 'var(--green)' : 'var(--red)' }}>
{c.direction === 'bullish' ? '▲ LONG' : '▼ SHORT'}
</span>
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontWeight: 600 }}>${fmt(c.breakout_level)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.orb_high)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.orb_low)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>${fmt(c.atr, 3)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{fmt(c.rvol, 1)}x</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: c.gap_pct >= 0 ? 'var(--green)' : 'var(--red)' }}>
{fmtPct(c.gap_pct * 100)}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{fmt(c.composite_score, 3)}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{
fontSize: 11, fontWeight: 500,
color: c.status === 'filled' ? 'var(--green)' : c.status === 'timeout' ? 'var(--red)' : c.status === 'pending' ? 'var(--cyan)' : 'var(--text3)',
}}>
{c.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TradesPanel({ sessionId }: { sessionId: string }) {
const { data } = useQuery({
queryKey: ['orb-trades', sessionId],
queryFn: () => orbTradingApi.trades(sessionId, 100),
refetchInterval: 60000,
});
const trades = data?.trades ?? [];
if (trades.length === 0) {
return <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}> </div>;
}
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
{['날짜', '티커', '방향', '진입가', '청산가', '수량', 'P&L', 'R', '사유'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', fontWeight: 500, color: 'var(--text3)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{trades.map((t: any) => (
<tr key={t.trade_id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.date}</td>
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text1)' }}>{t.ticker}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: t.direction === 'long' ? 'var(--green)' : 'var(--red)' }}>
{t.direction.toUpperCase()}
</span>
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(t.entry_price)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>${fmt(t.exit_price)}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)' }}>{t.shares}</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', fontWeight: 600, color: t.pnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
{fmtDollars(t.pnl)}
</td>
<td style={{ padding: '8px 12px', fontFamily: 'var(--font-mono)', color: t.r_multiple >= 0 ? 'var(--green)' : 'var(--red)' }}>
{t.r_multiple >= 0 ? '+' : ''}{fmt(t.r_multiple)}R
</td>
<td style={{ padding: '8px 12px' }}>
<span style={{
fontSize: 11,
color: t.exit_reason === 'close' ? 'var(--text3)' : t.exit_reason === 'stop_loss' ? 'var(--red)' : t.exit_reason === 'trailing_stop' ? 'var(--cyan)' : 'var(--orange)',
}}>
{t.exit_reason}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EquityPanel({ sessionId, initialEquity }: { sessionId: string; initialEquity: number }) {
const { data } = useQuery({
queryKey: ['orb-equity', sessionId],
queryFn: () => orbTradingApi.equity(sessionId),
refetchInterval: 60000,
});
const snapshots = data?.snapshots ?? [];
if (snapshots.length === 0) {
return <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}> </div>;
}
const chartData = [
{ date: '시작', equity: initialEquity },
...snapshots.map((s: any) => ({ date: s.date, equity: s.equity, pnl: s.daily_pnl })),
];
const latest = snapshots[snapshots.length - 1];
const totalReturn = ((latest.equity - initialEquity) / initialEquity) * 100;
return (
<div style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', gap: 20, marginBottom: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}> </div>
<div style={{ fontSize: 22, fontWeight: 700, fontFamily: 'var(--font-mono)', color: totalReturn >= 0 ? 'var(--green)' : 'var(--red)' }}>
{fmtPct(totalReturn)}
</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}> </div>
<div style={{ fontSize: 22, fontWeight: 700, fontFamily: 'var(--font-mono)', color: 'var(--text1)' }}>
${fmt(latest.equity)}
</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}> </div>
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: 'var(--font-mono)', color: 'var(--red)' }}>
{fmt(Math.min(...snapshots.map((s: any) => s.drawdown_pct)))}%
</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}> </div>
<div style={{ fontSize: 18, fontWeight: 600, fontFamily: 'var(--font-mono)', color: 'var(--text1)' }}>
{snapshots.reduce((a: number, s: any) => a + s.trades_taken, 0)}
</div>
</div>
</div>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: 'var(--text3)' }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: 'var(--text3)' }} domain={['auto', 'auto']} tickFormatter={v => `$${v.toFixed(0)}`} />
<Tooltip
formatter={(v) => [`$${(v as number).toFixed(2)}`, '에쿼티']}
contentStyle={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 12 }}
/>
<ReferenceLine y={initialEquity} stroke="var(--text3)" strokeDasharray="4 2" />
<Line dataKey="equity" stroke="var(--cyan)" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
type TabKey = 'positions' | 'candidates' | 'trades' | 'equity';
export function OrbTradingPage() {
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>('positions');
const { data, isLoading, error } = useQuery({
queryKey: ['orb-sessions'],
queryFn: orbTradingApi.sessions,
refetchInterval: 60000,
});
const pauseMut = useMutation({
mutationFn: orbTradingApi.pauseSession,
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-sessions'] }),
});
const resumeMut = useMutation({
mutationFn: orbTradingApi.resumeSession,
onSuccess: () => qc.invalidateQueries({ queryKey: ['orb-sessions'] }),
});
const runTodayMut = useMutation({
mutationFn: orbTradingApi.runToday,
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
qc.invalidateQueries({ queryKey: ['orb-auto-status'] });
alert(`${result.session_name}: ${result.note}`);
},
onError: (e: Error) => alert(`지금 시작 실패: ${e.message}`),
});
const deleteMut = useMutation({
mutationFn: orbTradingApi.closeSession,
onSuccess: (result, id) => {
qc.invalidateQueries({ queryKey: ['orb-sessions'] });
if (selectedId === id) setSelectedId(null);
if (result.close_errors && result.close_errors.length > 0) {
alert(
`세션 삭제 완료 (포지션 ${result.positions_closed}개 청산)\n\n` +
`청산 실패 (수동 처리 필요):\n${result.close_errors.join('\n')}`
);
}
},
onError: (e: Error) => alert(`세션 삭제 실패: ${e.message}`),
});
const sessions = data?.sessions ?? [];
const selectedSession = sessions.find(s => s.session_id === selectedId || s.session_name === selectedId);
// Collect active session names for scheduler
const activeSessions = sessions.filter(s => s.status === 'active').map(s => s.session_name);
if (isLoading) return <div style={{ padding: 32 }}><Loading /></div>;
if (error) return <div style={{ padding: 32 }}><ErrorState error={error} /></div>;
const tabs: { key: TabKey; label: string }[] = [
{ key: 'positions', label: '포지션' },
{ key: 'candidates', label: 'ORB 후보' },
{ key: 'trades', label: '트레이드' },
{ key: 'equity', label: '에쿼티' },
];
return (
<div style={{ padding: '24px 28px', maxWidth: 1300, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Zap size={20} color="var(--cyan)" />
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text1)' }}>
ORB
</h1>
</div>
<button style={btn('primary')} onClick={() => setShowCreate(true)}>
<Plus size={14} />
</button>
</div>
{/* Auto Scheduler */}
<SchedulerPanel selectedSessions={activeSessions} />
{/* Sessions grid */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
({sessions.length})
</div>
{sessions.length === 0 ? (
<div style={{ ...card, padding: '28px', textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
. "세션 생성" .
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
{sessions.map(session => (
<SessionCard
key={session.session_id}
session={session}
selected={selectedId === session.session_id || selectedId === session.session_name}
onSelect={() => setSelectedId(session.session_id)}
onDelete={() => {
if (window.confirm(`"${session.session_name}" 세션을 삭제하시겠습니까? 오픈 포지션이 청산됩니다.`)) {
deleteMut.mutate(session.session_id);
}
}}
onPause={() => pauseMut.mutate(session.session_id)}
onResume={() => resumeMut.mutate(session.session_id)}
onRunToday={() => {
if (window.confirm(`"${session.session_name}" 세션을 현재 가격 기준으로 지금 시작하시겠습니까?\n(스케줄러가 실행 중이어야 합니다)`)) {
runTodayMut.mutate(session.session_id);
}
}}
runTodayPending={runTodayMut.isPending && runTodayMut.variables === session.session_id}
/>
))}
</div>
)}
</div>
{/* Session detail */}
{selectedSession && (
<div style={card}>
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text1)' }}>
{selectedSession.session_name}
</span>
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>
${fmt(selectedSession.initial_equity)} ${fmt(selectedSession.current_equity ?? selectedSession.initial_equity)}
</span>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', background: 'var(--bg2)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 18px', fontSize: 13, fontWeight: 500, cursor: 'pointer',
background: 'none', border: 'none', borderBottom: `2px solid ${activeTab === tab.key ? 'var(--cyan)' : 'transparent'}`,
color: activeTab === tab.key ? 'var(--cyan)' : 'var(--text3)',
transition: 'all 0.12s',
}}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'positions' && <PositionsPanel sessionId={selectedSession.session_id} />}
{activeTab === 'candidates' && <CandidatesPanel sessionId={selectedSession.session_id} />}
{activeTab === 'trades' && <TradesPanel sessionId={selectedSession.session_id} />}
{activeTab === 'equity' && (
<EquityPanel sessionId={selectedSession.session_id} initialEquity={selectedSession.initial_equity} />
)}
</div>
)}
{showCreate && <CreateSessionModal onClose={() => setShowCreate(false)} />}
</div>
);
}
Loading…
Cancel
Save