Merge branch 'dev' into main — 데이트레이딩 상태 연결
This commit is contained in:
@@ -777,22 +777,36 @@ async def restore_positions_from_db() -> None:
|
|||||||
|
|
||||||
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
|
# ── 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")
|
@router.get("/watchlist")
|
||||||
async def get_watchlist():
|
async def get_watchlist():
|
||||||
"""감시 종목 목록 조회"""
|
"""감시 종목 목록 조회 (실제 엔진 기준)"""
|
||||||
_reset_if_new_day()
|
_reset_if_new_day()
|
||||||
|
_es, _st = _engine()
|
||||||
|
_st.reset_if_new_day()
|
||||||
|
wl = _es.get_watchlist()
|
||||||
return {
|
return {
|
||||||
"watchlist": [
|
"watchlist": [
|
||||||
{"code": k, "name": v, "has_orb": k in _orb_cache}
|
{"code": k, "name": v, "has_orb": _st.has_orb(k)}
|
||||||
for k, v in _watchlist.items()
|
for k, v in wl.items()
|
||||||
],
|
],
|
||||||
"count": len(_watchlist),
|
"count": len(wl),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/watchlist")
|
@router.post("/watchlist")
|
||||||
async def set_watchlist(req: WatchlistRequest):
|
async def set_watchlist(req: WatchlistRequest):
|
||||||
"""감시 종목 설정 (최대 20종목)
|
"""감시 종목 설정 (최대 20종목) — 실제 엔진과 이 모듈 양쪽에 반영
|
||||||
|
|
||||||
body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]}
|
body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]}
|
||||||
"""
|
"""
|
||||||
@@ -801,6 +815,8 @@ async def set_watchlist(req: WatchlistRequest):
|
|||||||
raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.")
|
raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.")
|
||||||
|
|
||||||
_watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks}
|
_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())}")
|
logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}")
|
||||||
return {"status": "ok", "watchlist": list(_watchlist.keys())}
|
return {"status": "ok", "watchlist": list(_watchlist.keys())}
|
||||||
|
|
||||||
@@ -921,36 +937,40 @@ async def execute_day_trade(req: ExecuteRequest):
|
|||||||
|
|
||||||
@router.get("/positions")
|
@router.get("/positions")
|
||||||
async def get_positions():
|
async def get_positions():
|
||||||
"""활성 데이트레이딩 포지션 조회"""
|
"""활성 데이트레이딩 포지션 조회 (실제 엔진 기준)"""
|
||||||
_reset_if_new_day()
|
_reset_if_new_day()
|
||||||
|
_, _st = _engine()
|
||||||
|
_st.reset_if_new_day()
|
||||||
result = []
|
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:
|
try:
|
||||||
current_price = await _get_current_price(code)
|
current_price = await _get_current_price(code)
|
||||||
except Exception:
|
except Exception:
|
||||||
current_price = pos["entry_price"]
|
current_price = entry_price
|
||||||
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
pnl = (current_price - entry_price) * quantity
|
||||||
pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100
|
pnl_pct = ((current_price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
|
||||||
result.append({
|
result.append({
|
||||||
"stock_code": code,
|
"stock_code": code,
|
||||||
"stock_name": pos["stock_name"],
|
"stock_name": pos.get("stock_name", code),
|
||||||
"strategy": pos["strategy"],
|
"strategy": pos.get("strategy", ""),
|
||||||
"entry_price": pos["entry_price"],
|
"entry_price": entry_price,
|
||||||
"current_price": current_price,
|
"current_price": current_price,
|
||||||
"stop_loss": pos["stop_loss"],
|
"stop_loss": pos.get("stop_loss"),
|
||||||
"target_price": pos["target_price"],
|
"target_price": pos.get("target_price"),
|
||||||
"quantity": pos["quantity"],
|
"quantity": quantity,
|
||||||
"pnl": round(pnl, 0),
|
"pnl": round(pnl, 0),
|
||||||
"pnl_pct": round(pnl_pct, 2),
|
"pnl_pct": round(pnl_pct, 2),
|
||||||
"entry_time": pos["entry_time"],
|
"entry_time": str(pos.get("entry_time", "")),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
"positions": result,
|
"positions": result,
|
||||||
"count": len(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(
|
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
||||||
_last_total_assets,
|
_st.last_total_assets,
|
||||||
_daily_pnl,
|
_st.daily_pnl,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,11 +999,15 @@ async def manual_cycle():
|
|||||||
async def get_status():
|
async def get_status():
|
||||||
"""데이트레이딩 시스템 상태 요약"""
|
"""데이트레이딩 시스템 상태 요약"""
|
||||||
_reset_if_new_day()
|
_reset_if_new_day()
|
||||||
|
_es, _st = _engine()
|
||||||
|
_st.reset_if_new_day()
|
||||||
return {
|
return {
|
||||||
"watchlist_count": len(_watchlist),
|
"watchlist_count": len(_es.get_watchlist()),
|
||||||
"active_positions": len(_positions),
|
"active_positions": _st.position_count(),
|
||||||
"orb_collected": len(_orb_cache),
|
"orb_collected": len(_st.orb_cache),
|
||||||
"daily_pnl": round(_daily_pnl, 0),
|
"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_market_open": is_market_open(),
|
||||||
"is_opening_range": is_opening_range_period(),
|
"is_opening_range": is_opening_range_period(),
|
||||||
"is_entry_window": is_day_trade_entry_window(),
|
"is_entry_window": is_day_trade_entry_window(),
|
||||||
|
|||||||
81
frontend/src/components/DayTradingStatusBar.tsx
Normal file
81
frontend/src/components/DayTradingStatusBar.tsx
Normal 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
|
||||||
@@ -18,6 +18,7 @@ import { Activity, TrendingUp, Clock } from 'lucide-react'
|
|||||||
import { Position } from '../types/trading'
|
import { Position } from '../types/trading'
|
||||||
import { isUsStock } from '../utils/stockUtils'
|
import { isUsStock } from '../utils/stockUtils'
|
||||||
import AccountSetupNotice from './AccountSetupNotice'
|
import AccountSetupNotice from './AccountSetupNotice'
|
||||||
|
import DayTradingStatusBar from './DayTradingStatusBar'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||||
@@ -257,6 +258,9 @@ const PortfolioPanel = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 데이트레이딩 엔진 상태 (감시/ORB/포지션/당일손익) */}
|
||||||
|
{!accountSetupRequired && <DayTradingStatusBar />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user