diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 1590190..2e1c096 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -777,22 +777,36 @@ async def restore_positions_from_db() -> None: # ── API 엔드포인트 ──────────────────────────────────────────────────────────── +# ── 실제 매매 엔진(services/daytrading) 상태 연결 ───────────────────────────── +# main.py 는 services/daytrading/scheduler.daytrading_scheduler() 를 실행한다. +# 이 모듈의 _watchlist/_positions/_orb_cache 는 그 엔진과 공유되지 않는 별개 dict라 +# 조회 API가 항상 0을 돌려주는 문제가 있었음(2026-09-22). 조회/설정 엔드포인트는 +# 엔진 상태(entry_service._watchlist, day_trading_state)를 기준으로 응답한다. +def _engine(): + from app.services.daytrading import entry_service as _es + from app.services.daytrading.position_manager import day_trading_state as _st + return _es, _st + + @router.get("/watchlist") async def get_watchlist(): - """감시 종목 목록 조회""" + """감시 종목 목록 조회 (실제 엔진 기준)""" _reset_if_new_day() + _es, _st = _engine() + _st.reset_if_new_day() + wl = _es.get_watchlist() return { "watchlist": [ - {"code": k, "name": v, "has_orb": k in _orb_cache} - for k, v in _watchlist.items() + {"code": k, "name": v, "has_orb": _st.has_orb(k)} + for k, v in wl.items() ], - "count": len(_watchlist), + "count": len(wl), } @router.post("/watchlist") async def set_watchlist(req: WatchlistRequest): - """감시 종목 설정 (최대 20종목) + """감시 종목 설정 (최대 20종목) — 실제 엔진과 이 모듈 양쪽에 반영 body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]} """ @@ -801,6 +815,8 @@ async def set_watchlist(req: WatchlistRequest): raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.") _watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks} + _es, _ = _engine() + _es.set_watchlist(req.stocks) logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}") return {"status": "ok", "watchlist": list(_watchlist.keys())} @@ -921,36 +937,40 @@ async def execute_day_trade(req: ExecuteRequest): @router.get("/positions") async def get_positions(): - """활성 데이트레이딩 포지션 조회""" + """활성 데이트레이딩 포지션 조회 (실제 엔진 기준)""" _reset_if_new_day() + _, _st = _engine() + _st.reset_if_new_day() result = [] - for code, pos in _positions.items(): + for code, pos in _st.positions.items(): + entry_price = float(pos.get("entry_price", 0) or 0) + quantity = int(pos.get("quantity", 0) or 0) try: current_price = await _get_current_price(code) except Exception: - current_price = pos["entry_price"] - pnl = (current_price - pos["entry_price"]) * pos["quantity"] - pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100 + current_price = entry_price + pnl = (current_price - entry_price) * quantity + pnl_pct = ((current_price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 result.append({ "stock_code": code, - "stock_name": pos["stock_name"], - "strategy": pos["strategy"], - "entry_price": pos["entry_price"], + "stock_name": pos.get("stock_name", code), + "strategy": pos.get("strategy", ""), + "entry_price": entry_price, "current_price": current_price, - "stop_loss": pos["stop_loss"], - "target_price": pos["target_price"], - "quantity": pos["quantity"], + "stop_loss": pos.get("stop_loss"), + "target_price": pos.get("target_price"), + "quantity": quantity, "pnl": round(pnl, 0), "pnl_pct": round(pnl_pct, 2), - "entry_time": pos["entry_time"], + "entry_time": str(pos.get("entry_time", "")), }) return { "positions": result, "count": len(result), - "daily_pnl": round(_daily_pnl, 0), + "daily_pnl": round(_st.daily_pnl, 0), "is_daily_loss_exceeded": is_daily_loss_exceeded( - _last_total_assets, - _daily_pnl, + _st.last_total_assets, + _st.daily_pnl, ), } @@ -979,11 +999,15 @@ async def manual_cycle(): async def get_status(): """데이트레이딩 시스템 상태 요약""" _reset_if_new_day() + _es, _st = _engine() + _st.reset_if_new_day() return { - "watchlist_count": len(_watchlist), - "active_positions": len(_positions), - "orb_collected": len(_orb_cache), - "daily_pnl": round(_daily_pnl, 0), + "watchlist_count": len(_es.get_watchlist()), + "active_positions": _st.position_count(), + "orb_collected": len(_st.orb_cache), + "daily_pnl": round(_st.daily_pnl, 0), + "closed_today": len(_st.closed_today), + "cooldown_active": bool(_st.is_cooldown_active()), "is_market_open": is_market_open(), "is_opening_range": is_opening_range_period(), "is_entry_window": is_day_trade_entry_window(), diff --git a/frontend/src/components/DayTradingStatusBar.tsx b/frontend/src/components/DayTradingStatusBar.tsx new file mode 100644 index 0000000..d8d8113 --- /dev/null +++ b/frontend/src/components/DayTradingStatusBar.tsx @@ -0,0 +1,81 @@ +/** + * DayTradingStatusBar.tsx — 데이트레이딩 엔진 상태 요약 바 + * + * 보유 종목 패널 하단에 붙어, 실제 매매 엔진(services/daytrading)의 + * 감시 종목 수 / ORB 수집 수 / 활성 포지션 / 당일 손익 / 진입 가능 여부를 보여준다. + * 데이터: GET /daytrading/status (엔진 상태 기준으로 응답하도록 수정된 v1 엔드포인트) + * 폴링: 30초 (인메모리 조회라 가벼움) + */ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { Zap } from 'lucide-react' +import api from '../lib/api' + +interface DayTradingStatus { + watchlist_count: number + active_positions: number + orb_collected: number + daily_pnl: number + closed_today?: number + cooldown_active?: boolean + is_market_open: boolean + is_opening_range: boolean + is_entry_window: boolean + is_eod_flat: boolean + today: string +} + +/** 현재 엔진 단계 라벨 — 사용자가 "왜 안 사는지"를 한눈에 보게 */ +const phaseLabel = (s: DayTradingStatus): { text: string; color: string } => { + if (!s.is_market_open) return { text: '장 마감', color: 'text-[#4a6580]' } + if (s.is_eod_flat) return { text: 'EOD 청산', color: 'text-[#f97316]' } + if (s.is_opening_range) return { text: 'ORB 수집중', color: 'text-[#60a5fa]' } + if (s.cooldown_active) return { text: '3연패 쿨다운', color: 'text-[#ef4444]' } + if (!s.is_entry_window) return { text: '신규진입 마감', color: 'text-[#4a6580]' } + if (s.watchlist_count === 0) return { text: '감시종목 없음', color: 'text-[#ef4444]' } + return { text: '신호 대기', color: 'text-[#22c55e]' } +} + +const DayTradingStatusBar = React.memo(() => { + const { data } = useQuery({ + queryKey: ['daytrading-status'], + queryFn: async () => (await api.get('/daytrading/status')).data, + refetchInterval: 30000, + staleTime: 15000, + }) + + if (!data) return null + const phase = phaseLabel(data) + const pnl = data.daily_pnl ?? 0 + const pnlColor = pnl > 0 ? 'text-[#ef4444]' : pnl < 0 ? 'text-[#3b82f6]' : 'text-[#8faabf]' + + return ( +
+
+ + 데이트레이딩 엔진 + {phase.text} +
+
+ {[ + { label: '감시', value: data.watchlist_count }, + { label: 'ORB', value: data.orb_collected }, + { label: '포지션', value: data.active_positions }, + { label: '청산', value: data.closed_today ?? 0 }, + ].map((c) => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+
+ 당일 손익 + {pnl.toLocaleString('ko-KR')}원 +
+
+ ) +}) + +DayTradingStatusBar.displayName = 'DayTradingStatusBar' +export default DayTradingStatusBar diff --git a/frontend/src/components/PortfolioPanel.tsx b/frontend/src/components/PortfolioPanel.tsx index 8a9d33b..5879657 100644 --- a/frontend/src/components/PortfolioPanel.tsx +++ b/frontend/src/components/PortfolioPanel.tsx @@ -18,6 +18,7 @@ import { Activity, TrendingUp, Clock } from 'lucide-react' import { Position } from '../types/trading' import { isUsStock } from '../utils/stockUtils' import AccountSetupNotice from './AccountSetupNotice' +import DayTradingStatusBar from './DayTradingStatusBar' /** * 가격 포맷 — 시장에 따라 원화 또는 달러 표시 @@ -257,6 +258,9 @@ const PortfolioPanel = React.memo(({ )} + + {/* 데이트레이딩 엔진 상태 (감시/ORB/포지션/당일손익) */} + {!accountSetupRequired && } ) })