Compare commits

..

2 Commits

Author SHA1 Message Date
5c2a31348a Merge branch 'dev' into main — 데이트레이딩 상태 연결 2026-09-22 11:23:15 +09:00
b2a1b884d4 feat: 데이트레이딩 조회 API를 실제 엔진 상태에 연결 + 대시보드 엔진 상태 바
/daytrading/{status,watchlist,positions}(v1)는 main.py가 실행하는 엔진
(services/daytrading)과 공유되지 않는 별개 인메모리 dict를 읽어 항상 0을 반환했음.
조회는 entry_service 감시목록·day_trading_state 기준으로 응답하고, POST /watchlist는
엔진에도 반영. 프론트 보유종목 패널 하단에 감시/ORB/포지션/청산/당일손익·현재 단계
(ORB 수집중·신호 대기·쿨다운 등)를 30초 폴링으로 표시.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:23:13 +09:00
3 changed files with 133 additions and 24 deletions

View File

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

View File

@@ -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<DayTradingStatus>({
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 (
<div className="px-3 py-1.5 border-t border-[#152035] bg-[#091018] flex-shrink-0">
<div className="flex items-center gap-1.5 mb-1">
<Zap className="w-2.5 h-2.5 text-[#f97316]" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-[#f97316]">데이트레이딩 엔진</span>
<span className={`ml-auto text-[9px] font-mono ${phase.color}`}>{phase.text}</span>
</div>
<div className="grid grid-cols-4 gap-1 text-center">
{[
{ label: '감시', value: data.watchlist_count },
{ label: 'ORB', value: data.orb_collected },
{ label: '포지션', value: data.active_positions },
{ label: '청산', value: data.closed_today ?? 0 },
].map((c) => (
<div key={c.label} className="bg-[#0c1522] rounded-sm py-0.5">
<div className="text-[8px] text-[#4a6580]">{c.label}</div>
<div className="text-[10px] font-mono text-[#c9d7e5]">{c.value}</div>
</div>
))}
</div>
<div className="mt-1 flex justify-between text-[8px] font-mono">
<span className="text-[#4a6580]">당일 손익</span>
<span className={pnlColor}>{pnl.toLocaleString('ko-KR')}원</span>
</div>
</div>
)
})
DayTradingStatusBar.displayName = 'DayTradingStatusBar'
export default DayTradingStatusBar

View File

@@ -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(({
</div>
)}
</div>
{/* 데이트레이딩 엔진 상태 (감시/ORB/포지션/당일손익) */}
{!accountSetupRequired && <DayTradingStatusBar />}
</div>
)
})