수정본
This commit is contained in:
731
backend/app/api/endpoints/day_trading.py
Normal file
731
backend/app/api/endpoints/day_trading.py
Normal file
@@ -0,0 +1,731 @@
|
||||
"""
|
||||
데이트레이딩 API 엔드포인트
|
||||
|
||||
라우터 prefix: /api/daytrading
|
||||
|
||||
엔드포인트:
|
||||
GET /watchlist — 감시 종목 목록 조회
|
||||
POST /watchlist — 감시 종목 추가/교체
|
||||
GET /signals — 현재 진입 신호 조회
|
||||
POST /execute — 신호 기반 데이트레이딩 실행
|
||||
GET /positions — 활성 데이트레이딩 포지션 조회
|
||||
POST /close-all — 전체 포지션 강제 청산 (EOD 수동)
|
||||
POST /cycle — 1분 주기 사이클 수동 실행 (디버그용)
|
||||
|
||||
상태 (인메모리, 일일 초기화):
|
||||
_watchlist — 감시 종목 코드+이름 딕셔너리
|
||||
_positions — 활성 포지션 {stock_code: {...}}
|
||||
_orb_cache — 종목별 ORB 데이터
|
||||
_daily_pnl — 오늘 실현 손익
|
||||
_last_date — 마지막 초기화 날짜 (날짜 바뀌면 자동 리셋)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, date, time as dtime
|
||||
import asyncio
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.day_trading_strategy import (
|
||||
MinuteCandle, OpeningRange, DayTradeSignal,
|
||||
parse_minute_candles, aggregate_to_5min,
|
||||
calculate_vwap, calculate_opening_range,
|
||||
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||||
check_day_trade_exit,
|
||||
calculate_day_trade_position_size, is_daily_loss_exceeded,
|
||||
ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS,
|
||||
)
|
||||
from app.core.market_schedule import (
|
||||
is_market_open, is_opening_range_period,
|
||||
is_day_trade_entry_window, is_eod_flat_time,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.json_helpers import _dumps
|
||||
from app.services.websocket_manager import websocket_manager
|
||||
from app.core.database import get_db, AsyncSessionLocal
|
||||
from app.models.models import TradeHistory
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
|
||||
# ── 인메모리 상태 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _kst_now() -> datetime:
|
||||
return datetime.now(KST).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _kst_time() -> dtime:
|
||||
return datetime.now(KST).time()
|
||||
|
||||
|
||||
def _today_kst() -> date:
|
||||
return datetime.now(KST).date()
|
||||
|
||||
|
||||
# 감시 종목 (code → name)
|
||||
_watchlist: Dict[str, str] = {}
|
||||
|
||||
# 활성 포지션
|
||||
# {stock_code: {
|
||||
# "stock_name", "strategy", "entry_price", "stop_loss", "target_price",
|
||||
# "quantity", "highest_price", "entry_time", "reason"
|
||||
# }}
|
||||
_positions: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# ORB 캐시 (당일, stock_code → OpeningRange)
|
||||
_orb_cache: Dict[str, OpeningRange] = {}
|
||||
|
||||
# 일일 실현 손익 (원)
|
||||
_daily_pnl: float = 0.0
|
||||
|
||||
# 마지막 초기화 날짜
|
||||
_last_reset_date: Optional[date] = None
|
||||
|
||||
# 매매 실행 lock
|
||||
_trade_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _reset_if_new_day() -> None:
|
||||
"""날짜가 바뀌면 일일 상태 초기화"""
|
||||
global _daily_pnl, _orb_cache, _last_reset_date
|
||||
today = _today_kst()
|
||||
if _last_reset_date != today:
|
||||
_daily_pnl = 0.0
|
||||
_orb_cache = {}
|
||||
_last_reset_date = today
|
||||
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
|
||||
|
||||
|
||||
# ── Pydantic 모델 ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WatchlistRequest(BaseModel):
|
||||
stocks: List[Dict[str, str]] # [{"code": "005930", "name": "삼성전자"}, ...]
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
stock_code: str
|
||||
dry_run: bool = False # True면 주문 없이 신호만 반환
|
||||
|
||||
|
||||
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _fetch_candles(stock_code: str) -> List[MinuteCandle]:
|
||||
"""당일 전체 분봉 데이터 조회 및 파싱"""
|
||||
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
|
||||
return parse_minute_candles(raw)
|
||||
|
||||
|
||||
async def _get_current_price(stock_code: str) -> float:
|
||||
"""현재가 조회"""
|
||||
try:
|
||||
result = await korea_investment_api.get_current_price(stock_code)
|
||||
return float(result.get("output", {}).get("stck_prpr", 0) or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
async def _get_portfolio_summary() -> Dict[str, float]:
|
||||
"""포트폴리오 요약 (총자산, 가용 현금)"""
|
||||
try:
|
||||
balance = await korea_investment_api.get_balance()
|
||||
output2 = balance.get("output2", [{}])
|
||||
summary = output2[0] if output2 else {}
|
||||
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
|
||||
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
|
||||
return {"total_assets": total_assets, "available_cash": available_cash}
|
||||
except Exception as e:
|
||||
logger.error(f"포트폴리오 조회 실패: {e}")
|
||||
return {"total_assets": 0.0, "available_cash": 0.0}
|
||||
|
||||
|
||||
async def _place_buy_order(
|
||||
stock_code: str,
|
||||
quantity: int,
|
||||
price: int,
|
||||
) -> Optional[str]:
|
||||
"""매수 주문 실행, 주문번호 반환 (실패 시 None)"""
|
||||
try:
|
||||
result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="BUY",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
if result.get("rt_cd") == "0":
|
||||
return result.get("output", {}).get("ODNO", "")
|
||||
logger.warning(f"매수 주문 실패 [{stock_code}]: {result.get('msg1')}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"매수 주문 오류 [{stock_code}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _place_sell_order(
|
||||
stock_code: str,
|
||||
quantity: int,
|
||||
price: int = 0, # 0 = 시장가
|
||||
) -> bool:
|
||||
"""매도 주문 실행"""
|
||||
try:
|
||||
result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
return result.get("rt_cd") == "0"
|
||||
except Exception as e:
|
||||
logger.error(f"매도 주문 오류 [{stock_code}]: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _record_trade(
|
||||
db: AsyncSession,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
order_type: str,
|
||||
price: float,
|
||||
quantity: int,
|
||||
reason: str,
|
||||
profit_loss: float = 0.0,
|
||||
) -> None:
|
||||
"""TradeHistory DB 기록"""
|
||||
try:
|
||||
trade = TradeHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type=order_type,
|
||||
executed_price=price,
|
||||
executed_quantity=quantity,
|
||||
total_amount=price * quantity,
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0,
|
||||
status="COMPLETED",
|
||||
entry_reason=reason if order_type == "BUY" else None,
|
||||
exit_reason=reason if order_type == "SELL" else None,
|
||||
executed_at=_kst_now(),
|
||||
)
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"TradeHistory 기록 실패: {e}")
|
||||
|
||||
|
||||
# ── 핵심 사이클 로직 ──────────────────────────────────────────────────────────
|
||||
|
||||
async def run_day_trading_cycle() -> Dict[str, Any]:
|
||||
"""1분 주기 데이트레이딩 사이클
|
||||
|
||||
흐름:
|
||||
1. 장 미개장 → 스킵
|
||||
2. 일일 상태 초기화 체크
|
||||
3. Opening Range 수집 (09:00~09:30)
|
||||
4. 활성 포지션 청산 체크
|
||||
5. 신규 진입 신호 체크 (09:30~15:00)
|
||||
6. 결과 반환
|
||||
"""
|
||||
_reset_if_new_day()
|
||||
now_time = _kst_time()
|
||||
result: Dict[str, Any] = {
|
||||
"time": _kst_now().strftime("%H:%M:%S"),
|
||||
"phase": "",
|
||||
"exits": [],
|
||||
"entries": [],
|
||||
"signals": [],
|
||||
}
|
||||
|
||||
if not is_market_open():
|
||||
result["phase"] = "market_closed"
|
||||
return result
|
||||
|
||||
# ── 1. EOD 강제 청산 ──
|
||||
if is_eod_flat_time():
|
||||
result["phase"] = "eod_flat"
|
||||
await _execute_eod_flat()
|
||||
return result
|
||||
|
||||
# ── 2. ORB 수집 단계 (09:00~09:30) ──
|
||||
if is_opening_range_period():
|
||||
result["phase"] = "opening_range"
|
||||
await _collect_opening_range()
|
||||
result["orb_count"] = len(_orb_cache)
|
||||
return result
|
||||
|
||||
# ── 3. 매매 가능 구간 (09:30~15:00) ──
|
||||
if not is_day_trade_entry_window():
|
||||
result["phase"] = "outside_window"
|
||||
return result
|
||||
|
||||
result["phase"] = "active"
|
||||
|
||||
# ── 4. 활성 포지션 청산 체크 ──
|
||||
exits = await _check_exits()
|
||||
result["exits"] = exits
|
||||
|
||||
# ── 5. 신규 진입 신호 체크 ──
|
||||
if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED:
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
|
||||
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
signals = await _scan_signals(
|
||||
total_assets=total_assets,
|
||||
available_cash=available_cash,
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
result["signals"] = [
|
||||
{
|
||||
"stock_code": s["signal"].stock_code,
|
||||
"strategy": s["signal"].strategy,
|
||||
"entry_price": s["signal"].entry_price,
|
||||
"stop_loss": s["signal"].stop_loss,
|
||||
"target_price": s["signal"].target_price,
|
||||
"reason": s["signal"].reason,
|
||||
"quantity": s["quantity"],
|
||||
}
|
||||
for s in signals
|
||||
]
|
||||
|
||||
for s in signals:
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
break
|
||||
entry_result = await _execute_entry(s["signal"], s["quantity"])
|
||||
if entry_result:
|
||||
result["entries"].append(entry_result)
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ 일일 손실 한도 도달 ({_daily_pnl:,.0f}원) → 신규 진입 중단"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _collect_opening_range() -> None:
|
||||
"""Opening Range 집계 (09:00~09:30)"""
|
||||
for code in list(_watchlist.keys()):
|
||||
if code in _orb_cache:
|
||||
continue
|
||||
try:
|
||||
candles = await _fetch_candles(code)
|
||||
orb = calculate_opening_range(candles)
|
||||
if orb:
|
||||
_orb_cache[code] = orb
|
||||
logger.info(
|
||||
f"📊 ORB 수집 [{code}]: "
|
||||
f"H={orb.high:.0f}, L={orb.low:.0f}, "
|
||||
f"분당거래량={orb.avg_vol_per_min:.0f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ORB 수집 실패 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
|
||||
async def _check_exits() -> List[Dict[str, Any]]:
|
||||
"""활성 포지션 청산 조건 체크 및 실행"""
|
||||
global _daily_pnl
|
||||
exits = []
|
||||
now_time = _kst_time()
|
||||
|
||||
for code, pos in list(_positions.items()):
|
||||
try:
|
||||
current_price = await _get_current_price(code)
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
# 최고가 갱신
|
||||
if current_price > pos["highest_price"]:
|
||||
_positions[code]["highest_price"] = current_price
|
||||
|
||||
candles = await _fetch_candles(code)
|
||||
candles_5m = aggregate_to_5min(candles)
|
||||
|
||||
exit_signal = check_day_trade_exit(
|
||||
entry_price=pos["entry_price"],
|
||||
current_price=current_price,
|
||||
stop_loss=pos["stop_loss"],
|
||||
target_price=pos["target_price"],
|
||||
highest_since_entry=pos["highest_price"],
|
||||
candles_5min=candles_5m,
|
||||
current_time=now_time,
|
||||
)
|
||||
|
||||
if exit_signal:
|
||||
quantity = pos["quantity"]
|
||||
ok = await _place_sell_order(code, quantity, price=0) # 시장가
|
||||
if ok:
|
||||
pnl = (current_price - pos["entry_price"]) * quantity
|
||||
_daily_pnl += pnl
|
||||
del _positions[code]
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _record_trade(
|
||||
db, code, pos["stock_name"], "SELL",
|
||||
current_price, quantity, exit_signal.reason, pnl,
|
||||
)
|
||||
|
||||
exits.append({
|
||||
"stock_code": code,
|
||||
"reason": exit_signal.reason,
|
||||
"pnl": round(pnl, 0),
|
||||
"price": current_price,
|
||||
})
|
||||
logger.info(
|
||||
f"✅ 데이트레이딩 청산 [{code}]: "
|
||||
f"{exit_signal.reason}, PNL={pnl:+,.0f}원"
|
||||
)
|
||||
await websocket_manager.broadcast(_dumps({
|
||||
"type": "day_trade_exit",
|
||||
"stock_code": code,
|
||||
"reason": exit_signal.reason,
|
||||
"pnl": pnl,
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.error(f"청산 체크 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
return exits
|
||||
|
||||
|
||||
async def _scan_signals(
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_day_trade_amount: float,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""감시 종목에서 진입 신호 스캔"""
|
||||
results = []
|
||||
for code, name in list(_watchlist.items()):
|
||||
if code in _positions:
|
||||
continue
|
||||
try:
|
||||
candles_1m = await _fetch_candles(code)
|
||||
if not candles_1m:
|
||||
continue
|
||||
candles_5m = aggregate_to_5min(candles_1m)
|
||||
current_price = candles_1m[-1].close if candles_1m else 0
|
||||
if current_price < settings.MIN_PRICE_KRW:
|
||||
continue
|
||||
|
||||
vwap = calculate_vwap(candles_1m)
|
||||
orb = _orb_cache.get(code)
|
||||
|
||||
signal: Optional[DayTradeSignal] = None
|
||||
|
||||
# ORB 우선
|
||||
if orb:
|
||||
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
|
||||
|
||||
# VWAP 반등
|
||||
if signal is None and vwap:
|
||||
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
|
||||
|
||||
# RSI+BB
|
||||
if signal is None:
|
||||
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
|
||||
|
||||
if signal:
|
||||
position_size = calculate_day_trade_position_size(
|
||||
total_assets, available_cash, current_day_trade_amount
|
||||
)
|
||||
if position_size < signal.entry_price:
|
||||
continue # 최소 1주도 살 수 없으면 스킵
|
||||
|
||||
quantity = max(1, int(position_size // signal.entry_price))
|
||||
results.append({"signal": signal, "quantity": quantity})
|
||||
logger.info(
|
||||
f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: "
|
||||
f"{signal.reason}, 수량={quantity}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"신호 스캔 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
# confidence 내림차순 정렬
|
||||
results.sort(key=lambda x: x["signal"].confidence, reverse=True)
|
||||
return results[:MAX_POSITIONS - len(_positions)]
|
||||
|
||||
|
||||
async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]:
|
||||
"""진입 주문 실행 및 포지션 등록"""
|
||||
global _trade_lock
|
||||
async with _trade_lock:
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
return None
|
||||
if signal.stock_code in _positions:
|
||||
return None
|
||||
|
||||
order_no = await _place_buy_order(
|
||||
signal.stock_code, quantity, int(signal.entry_price)
|
||||
)
|
||||
if not order_no:
|
||||
return None
|
||||
|
||||
_positions[signal.stock_code] = {
|
||||
"stock_name": signal.stock_name,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"stop_loss": signal.stop_loss,
|
||||
"target_price": signal.target_price,
|
||||
"quantity": quantity,
|
||||
"highest_price": signal.entry_price,
|
||||
"entry_time": _kst_now().strftime("%H:%M:%S"),
|
||||
"reason": signal.reason,
|
||||
}
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _record_trade(
|
||||
db, signal.stock_code, signal.stock_name, "BUY",
|
||||
signal.entry_price, quantity, signal.reason,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🛒 데이트레이딩 진입 [{signal.stock_code}] "
|
||||
f"{signal.strategy}: {signal.entry_price:.0f}원 × {quantity}주"
|
||||
)
|
||||
await websocket_manager.broadcast(_dumps({
|
||||
"type": "day_trade_entry",
|
||||
"stock_code": signal.stock_code,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"quantity": quantity,
|
||||
}))
|
||||
return {
|
||||
"stock_code": signal.stock_code,
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"quantity": quantity,
|
||||
"reason": signal.reason,
|
||||
}
|
||||
|
||||
|
||||
async def _execute_eod_flat() -> None:
|
||||
"""EOD 전체 포지션 강제 청산"""
|
||||
global _daily_pnl
|
||||
for code, pos in list(_positions.items()):
|
||||
try:
|
||||
current_price = await _get_current_price(code)
|
||||
ok = await _place_sell_order(code, pos["quantity"], price=0)
|
||||
if ok:
|
||||
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
||||
_daily_pnl += pnl
|
||||
del _positions[code]
|
||||
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
|
||||
except Exception as e:
|
||||
logger.error(f"EOD 청산 오류 [{code}]: {e}")
|
||||
await asyncio.sleep(0.6)
|
||||
|
||||
|
||||
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/watchlist")
|
||||
async def get_watchlist():
|
||||
"""감시 종목 목록 조회"""
|
||||
_reset_if_new_day()
|
||||
return {
|
||||
"watchlist": [
|
||||
{"code": k, "name": v, "has_orb": k in _orb_cache}
|
||||
for k, v in _watchlist.items()
|
||||
],
|
||||
"count": len(_watchlist),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/watchlist")
|
||||
async def set_watchlist(req: WatchlistRequest):
|
||||
"""감시 종목 설정 (최대 20종목)
|
||||
|
||||
body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]}
|
||||
"""
|
||||
global _watchlist
|
||||
if len(req.stocks) > 20:
|
||||
raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.")
|
||||
|
||||
_watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks}
|
||||
logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}")
|
||||
return {"status": "ok", "watchlist": list(_watchlist.keys())}
|
||||
|
||||
|
||||
@router.get("/signals")
|
||||
async def get_current_signals():
|
||||
"""현재 진입 가능 신호 조회 (주문 미실행)"""
|
||||
_reset_if_new_day()
|
||||
if not is_day_trade_entry_window():
|
||||
return {
|
||||
"phase": "outside_window",
|
||||
"message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).",
|
||||
"signals": [],
|
||||
}
|
||||
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
|
||||
signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount)
|
||||
return {
|
||||
"phase": "active",
|
||||
"signals": [
|
||||
{
|
||||
"stock_code": s["signal"].stock_code,
|
||||
"stock_name": s["signal"].stock_name,
|
||||
"strategy": s["signal"].strategy,
|
||||
"entry_price": s["signal"].entry_price,
|
||||
"stop_loss": s["signal"].stop_loss,
|
||||
"target_price": s["signal"].target_price,
|
||||
"reason": s["signal"].reason,
|
||||
"confidence": s["signal"].confidence,
|
||||
"quantity": s["quantity"],
|
||||
}
|
||||
for s in signals
|
||||
],
|
||||
"count": len(signals),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_day_trade(req: ExecuteRequest):
|
||||
"""특정 종목 데이트레이딩 신호 스캔 후 진입"""
|
||||
_reset_if_new_day()
|
||||
code = req.stock_code
|
||||
name = _watchlist.get(code, code)
|
||||
|
||||
if code in _positions:
|
||||
raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.")
|
||||
if len(_positions) >= MAX_POSITIONS:
|
||||
raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.")
|
||||
if not is_day_trade_entry_window():
|
||||
raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).")
|
||||
|
||||
portfolio = await _get_portfolio_summary()
|
||||
total_assets = portfolio["total_assets"]
|
||||
available_cash = portfolio["available_cash"]
|
||||
|
||||
if is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||||
raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.")
|
||||
|
||||
candles_1m = await _fetch_candles(code)
|
||||
candles_5m = aggregate_to_5min(candles_1m)
|
||||
current_price = candles_1m[-1].close if candles_1m else 0
|
||||
vwap = calculate_vwap(candles_1m)
|
||||
orb = _orb_cache.get(code)
|
||||
|
||||
signal = None
|
||||
if orb:
|
||||
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
|
||||
if signal is None and vwap:
|
||||
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
|
||||
if signal is None:
|
||||
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
|
||||
|
||||
if not signal:
|
||||
return {"status": "no_signal", "message": "현재 진입 조건을 충족하는 신호가 없습니다."}
|
||||
|
||||
current_day_trade_amount = sum(
|
||||
p["entry_price"] * p["quantity"] for p in _positions.values()
|
||||
)
|
||||
position_size = calculate_day_trade_position_size(
|
||||
total_assets, available_cash, current_day_trade_amount
|
||||
)
|
||||
quantity = max(1, int(position_size // signal.entry_price))
|
||||
|
||||
if req.dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"signal": {
|
||||
"strategy": signal.strategy,
|
||||
"entry_price": signal.entry_price,
|
||||
"stop_loss": signal.stop_loss,
|
||||
"target_price": signal.target_price,
|
||||
"reason": signal.reason,
|
||||
"quantity": quantity,
|
||||
},
|
||||
}
|
||||
|
||||
entry = await _execute_entry(signal, quantity)
|
||||
if not entry:
|
||||
raise HTTPException(500, "주문 실행에 실패했습니다.")
|
||||
return {"status": "ok", "entry": entry}
|
||||
|
||||
|
||||
@router.get("/positions")
|
||||
async def get_positions():
|
||||
"""활성 데이트레이딩 포지션 조회"""
|
||||
_reset_if_new_day()
|
||||
result = []
|
||||
for code, pos in _positions.items():
|
||||
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
|
||||
result.append({
|
||||
"stock_code": code,
|
||||
"stock_name": pos["stock_name"],
|
||||
"strategy": pos["strategy"],
|
||||
"entry_price": pos["entry_price"],
|
||||
"current_price": current_price,
|
||||
"stop_loss": pos["stop_loss"],
|
||||
"target_price": pos["target_price"],
|
||||
"quantity": pos["quantity"],
|
||||
"pnl": round(pnl, 0),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"entry_time": pos["entry_time"],
|
||||
})
|
||||
return {
|
||||
"positions": result,
|
||||
"count": len(result),
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
||||
sum(p["entry_price"] * p["quantity"] for p in _positions.values()),
|
||||
_daily_pnl,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/close-all")
|
||||
async def close_all_positions():
|
||||
"""전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)"""
|
||||
_reset_if_new_day()
|
||||
await _execute_eod_flat()
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "전체 데이트레이딩 포지션 청산 완료",
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cycle")
|
||||
async def manual_cycle():
|
||||
"""1분 사이클 수동 실행 (테스트/디버그용)"""
|
||||
_reset_if_new_day()
|
||||
result = await run_day_trading_cycle()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
"""데이트레이딩 시스템 상태 요약"""
|
||||
_reset_if_new_day()
|
||||
return {
|
||||
"watchlist_count": len(_watchlist),
|
||||
"active_positions": len(_positions),
|
||||
"orb_collected": len(_orb_cache),
|
||||
"daily_pnl": round(_daily_pnl, 0),
|
||||
"is_market_open": is_market_open(),
|
||||
"is_opening_range": is_opening_range_period(),
|
||||
"is_entry_window": is_day_trade_entry_window(),
|
||||
"is_eod_flat": is_eod_flat_time(),
|
||||
"today": _today_kst().isoformat(),
|
||||
}
|
||||
37
backend/app/api/endpoints/debug.py
Normal file
37
backend/app/api/endpoints/debug.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import settings
|
||||
from app.core.market_schedule import is_market_open
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/debug/autotrade-status")
|
||||
async def get_autotrade_status():
|
||||
"""자동매매 상태 진단"""
|
||||
KST = pytz.timezone('Asia/Seoul')
|
||||
now = datetime.now(KST)
|
||||
market_open = is_market_open()
|
||||
|
||||
return {
|
||||
"timestamp": now.isoformat(),
|
||||
"current_time_kst": now.strftime('%Y-%m-%d %H:%M:%S %A'),
|
||||
"weekday": now.weekday(),
|
||||
"current_minutes": now.hour * 60 + now.minute,
|
||||
"market_hours": "540-930 (09:00-15:30)",
|
||||
"market_open": market_open,
|
||||
"auto_trade_enabled": settings.AUTO_TRADE_ENABLED,
|
||||
"can_trade": market_open and settings.AUTO_TRADE_ENABLED,
|
||||
"settings": {
|
||||
"stop_loss_percent": settings.STOP_LOSS_PERCENT,
|
||||
"take_profit_percent": settings.TAKE_PROFIT_PERCENT,
|
||||
"max_daily_trades": settings.MAX_DAILY_TRADES,
|
||||
"trend_filter_enabled": settings.TREND_FILTER_ENABLED,
|
||||
},
|
||||
"api_configured": bool(settings.KOREA_INVESTMENT_APP_KEY),
|
||||
"diagnosis": {
|
||||
"market_status": "OPEN - 자동매매 가능" if market_open else "CLOSED - 장 마감",
|
||||
"config_status": "ENABLED" if settings.AUTO_TRADE_ENABLED else "DISABLED",
|
||||
"api_status": "CONFIGURED" if settings.KOREA_INVESTMENT_APP_KEY else "NOT CONFIGURED",
|
||||
}
|
||||
}
|
||||
78
backend/app/api/endpoints/journals.py
Normal file
78
backend/app/api/endpoints/journals.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
매매일지 API 엔드포인트
|
||||
|
||||
- GET /: 최근 AI 생성 매매일지 목록
|
||||
- GET /{journal_id}: 특정 일지 상세
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import TradeJournal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_journals(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
stock_code: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""최근 AI 생성 매매일지 목록 조회"""
|
||||
try:
|
||||
query = select(TradeJournal).order_by(desc(TradeJournal.journal_date))
|
||||
if stock_code:
|
||||
query = query.where(TradeJournal.stock_code == stock_code)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
journals = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": j.id,
|
||||
"trade_id": j.trade_id,
|
||||
"stock_code": j.stock_code,
|
||||
"stock_name": j.stock_name,
|
||||
"journal_date": j.journal_date.isoformat() if j.journal_date else None,
|
||||
"content": j.content,
|
||||
"strategy_name": j.strategy_name,
|
||||
"mood": j.mood,
|
||||
"tags": j.tags,
|
||||
"created_at": j.created_at.isoformat() if j.created_at else None,
|
||||
}
|
||||
for j in journals
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"journals list error: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{journal_id}")
|
||||
async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""특정 매매일지 상세 조회"""
|
||||
result = await db.execute(select(TradeJournal).where(TradeJournal.id == journal_id))
|
||||
journal = result.scalar_one_or_none()
|
||||
if not journal:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Journal not found")
|
||||
return {
|
||||
"id": journal.id,
|
||||
"trade_id": journal.trade_id,
|
||||
"stock_code": journal.stock_code,
|
||||
"stock_name": journal.stock_name,
|
||||
"journal_date": journal.journal_date.isoformat() if journal.journal_date else None,
|
||||
"content": journal.content,
|
||||
"strategy_name": journal.strategy_name,
|
||||
"mood": journal.mood,
|
||||
"tags": journal.tags,
|
||||
"created_at": journal.created_at.isoformat() if journal.created_at else None,
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
"""
|
||||
시세 데이터 API 엔드포인트
|
||||
|
||||
- GET /price/{stock_code}: 실시간 현재가 조회
|
||||
- GET /chart/{stock_code}: 차트 데이터 (일봉/주봉/월봉)
|
||||
- GET /orderbook/{stock_code}: 호가 정보 조회
|
||||
- GET /ai-analysis/{stock_code}: AI 기반 종목 분석
|
||||
"""
|
||||
# 필요한 라이브러리 및 모듈 임포트
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
# 내부 모듈 임포트
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
# APIRouter 인스턴스 생성
|
||||
router = APIRouter()
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 주식 가격 응답을 위한 Pydantic 모델 정의
|
||||
class StockPriceResponse(BaseModel):
|
||||
stock_code: str
|
||||
current_price: float
|
||||
@@ -24,15 +41,18 @@ class StockPriceResponse(BaseModel):
|
||||
|
||||
@router.get("/price/{stock_code}")
|
||||
async def get_stock_price(stock_code: str):
|
||||
"""주식 현재가 조회"""
|
||||
"""주식 현재가 조회 엔드포인트"""
|
||||
try:
|
||||
# 한국투자증권 API를 통해 현재가 데이터 조회
|
||||
data = await korea_investment_api.get_current_price(stock_code)
|
||||
|
||||
# API 응답에 'output' 필드가 없는 경우 예외 처리
|
||||
if "output" not in data:
|
||||
raise HTTPException(status_code=404, detail="Stock data not found")
|
||||
|
||||
output = data["output"]
|
||||
|
||||
# 필요한 데이터를 추출하여 딕셔너리 형태로 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"current_price": float(output.get("stck_prpr", 0)),
|
||||
@@ -44,59 +64,115 @@ async def get_stock_price(stock_code: str):
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 예외 발생 시 500 에러와 함께 상세 내용 반환
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
|
||||
|
||||
|
||||
def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
"""로컬 개발 환경을 위한 샘플 차트 데이터를 생성"""
|
||||
@router.get("/prices")
|
||||
async def get_stock_prices(codes: str):
|
||||
"""여러 종목의 현재가를 일괄 조회하는 엔드포인트
|
||||
|
||||
Query:
|
||||
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
|
||||
"""
|
||||
try:
|
||||
# codes 파라미터가 없는 경우 빈 데이터 반환
|
||||
if not codes:
|
||||
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
|
||||
|
||||
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환
|
||||
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
|
||||
prices: Dict[str, float] = {}
|
||||
|
||||
# 각 종목 코드에 대해 순차적으로 현재가 조회
|
||||
# 클래스 내부에 API 호출 간격을 조절하는 쓰로틀링 기능이 내장되어 있음
|
||||
for code in code_list:
|
||||
try:
|
||||
data = await korea_investment_api.get_current_price(code)
|
||||
if data and data.get("output"):
|
||||
o = data["output"]
|
||||
# 콤마가 포함된 가격 문자열을 float으로 변환
|
||||
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
|
||||
prices[code] = current_price
|
||||
except Exception as e:
|
||||
# 특정 종목 조회 실패 시 경고 로그 기록
|
||||
logger.warning(f"Failed to fetch price for {code}: {e}")
|
||||
|
||||
# 조회 결과를 딕셔너리 형태로 반환
|
||||
return {
|
||||
"prices": prices,
|
||||
"count": len(prices),
|
||||
"requested": len(code_list),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
# 예외 발생 시 500 에러와 함께 상세 내용 반환
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch prices: {str(e)}")
|
||||
|
||||
|
||||
def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> dict:
|
||||
"""로컬 개발 환경 및 API 키가 없을 때 사용할 샘플 차트 데이터를 생성하는 함수"""
|
||||
# 유효한 주기(period)인지 확인, 아니면 'D'(일봉)으로 설정
|
||||
if period not in {"D", "W", "M"}:
|
||||
period = "D"
|
||||
|
||||
# 주기에 따라 데이터 포인트 수와 날짜 간격 설정
|
||||
if period == "D":
|
||||
points = 60
|
||||
points = min(limit, 240) # 일봉은 최대 240개
|
||||
step = 1
|
||||
elif period == "W":
|
||||
points = 52
|
||||
points = min(limit, 240) # 주봉도 최대 240개
|
||||
step = 7
|
||||
else: # "M"
|
||||
points = 36
|
||||
else: # "M" (월봉)
|
||||
points = min(limit, 240) # 월봉도 최대 240개
|
||||
step = 30
|
||||
|
||||
# 차트 데이터의 시작 날짜 계산
|
||||
today = datetime.now()
|
||||
start_date = today - timedelta(days=(points - 1) * step)
|
||||
|
||||
# 종목코드와 주기에 기반하여 일관된 랜덤 데이터 생성을 위한 시드 설정
|
||||
rng = random.Random(f"{stock_code}:{period}")
|
||||
# 랜덤 가격 및 변동성 파라미터 초기화
|
||||
base_price = rng.randint(30000, 90000)
|
||||
drift = rng.uniform(-0.04, 0.06)
|
||||
volatility = rng.uniform(0.004, 0.018)
|
||||
volume_base = rng.randint(80000, 180000)
|
||||
|
||||
# 가격을 10단위로 반올림하는 헬퍼 함수
|
||||
def round_price(value: float) -> int:
|
||||
return max(1000, int(round(value / 10)) * 10)
|
||||
|
||||
price = float(base_price)
|
||||
chart_data = []
|
||||
|
||||
# 설정된 포인트 수만큼 반복하여 캔들 데이터 생성
|
||||
for idx in range(points):
|
||||
current_date = start_date + timedelta(days=idx * step)
|
||||
# 일일 가격 변동률 계산
|
||||
daily_change = drift / points + rng.uniform(-volatility, volatility)
|
||||
|
||||
# 시가, 종가, 고가, 저가 생성
|
||||
open_price = price
|
||||
close_price = max(1000.0, open_price * (1 + daily_change))
|
||||
|
||||
high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8))
|
||||
low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8))
|
||||
|
||||
# 저가가 0 이하로 내려가지 않도록 보정
|
||||
if low_price <= 0:
|
||||
low_price = min(open_price, close_price) * 0.98
|
||||
|
||||
# 거래량 생성
|
||||
volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35)))
|
||||
|
||||
# 가격 반올림 처리
|
||||
open_value = round_price(open_price)
|
||||
close_value = round_price(close_price)
|
||||
high_value = max(round_price(high_price), open_value, close_value)
|
||||
low_value = min(round_price(low_price), open_value, close_value)
|
||||
|
||||
# 생성된 캔들 데이터를 리스트에 추가
|
||||
chart_data.append({
|
||||
"date": current_date.strftime("%Y%m%d"),
|
||||
"open": float(open_value),
|
||||
@@ -106,8 +182,10 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
"volume": max(1000, volume),
|
||||
})
|
||||
|
||||
# 다음 캔들의 시가를 위해 현재 종가 저장
|
||||
price = close_price
|
||||
|
||||
# 최종 샘플 차트 데이터를 딕셔너리 형태로 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
@@ -116,63 +194,99 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리
|
||||
chart_cache = {}
|
||||
|
||||
@router.get("/chart/{stock_code}")
|
||||
async def get_stock_chart(
|
||||
stock_code: str,
|
||||
period: str = "D",
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환")
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
|
||||
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
|
||||
):
|
||||
"""주식 차트 데이터 조회
|
||||
|
||||
"""주식 차트 데이터 조회 엔드포인트 (캐싱 적용)
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
period: D(일봉), W(주봉), M(월봉)
|
||||
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
|
||||
"""
|
||||
try:
|
||||
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
|
||||
return _generate_sample_chart(stock_code, period)
|
||||
|
||||
data = await korea_investment_api.get_daily_chart(stock_code, period)
|
||||
|
||||
if "output" not in data:
|
||||
raise HTTPException(status_code=404, detail="Chart data not found")
|
||||
|
||||
# 캐시 키 생성 (예: "005930__D")
|
||||
cache_key = f"{stock_code}__{period}"
|
||||
current_time = datetime.now()
|
||||
|
||||
# 캐시에서 데이터 조회
|
||||
cached_data = chart_cache.get(cache_key)
|
||||
use_cache = False
|
||||
chart_data = []
|
||||
for item in data["output"]:
|
||||
# KIS API 가격 데이터 안전하게 파싱
|
||||
try:
|
||||
close_str = str(item.get("stck_clpr", "0"))
|
||||
open_str = str(item.get("stck_oprc", "0"))
|
||||
high_str = str(item.get("stck_hgpr", "0"))
|
||||
low_str = str(item.get("stck_lwpr", "0"))
|
||||
|
||||
# 소수점이 포함되어 있으면 제거하고 정수로 변환
|
||||
close_val = float(close_str.replace(",", ""))
|
||||
open_val = float(open_str.replace(",", ""))
|
||||
high_val = float(high_str.replace(",", ""))
|
||||
low_val = float(low_str.replace(",", ""))
|
||||
|
||||
chart_data.append({
|
||||
"date": item.get("stck_bsop_date"),
|
||||
"open": open_val,
|
||||
"high": high_val,
|
||||
"low": low_val,
|
||||
"close": close_val,
|
||||
"volume": int(item.get("acml_vol", 0))
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"가격 파싱 오류: {item} - {e}")
|
||||
continue
|
||||
|
||||
# 캐시 데이터가 있고, 10분(600초) 이내에 생성된 경우 캐시 사용
|
||||
if cached_data:
|
||||
last_updated = cached_data.get("timestamp")
|
||||
if last_updated and (current_time - last_updated).total_seconds() < 600:
|
||||
chart_data = list(cached_data.get("data", [])) # 원본 수정을 방지하기 위해 데이터 복사
|
||||
use_cache = True
|
||||
# logger.debug(f"Chart cache hit for {stock_code}")
|
||||
|
||||
# KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리
|
||||
# 캐시를 사용하지 않는 경우 (캐시가 없거나 만료됨)
|
||||
if not use_cache:
|
||||
# API 키가 설정되지 않은 경우 샘플 데이터 생성
|
||||
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
|
||||
return _generate_sample_chart(stock_code, period, limit)
|
||||
|
||||
# 한국투자증권 API를 통해 차트 데이터 조회
|
||||
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
|
||||
|
||||
if "output2" not in data:
|
||||
raise HTTPException(status_code=404, detail="Chart data not found")
|
||||
|
||||
chart_data = [] # 데이터 리스트 초기화
|
||||
for item in data["output2"]:
|
||||
# KIS API 응답에서 가격 데이터를 안전하게 파싱
|
||||
try:
|
||||
# 콤마가 포함될 수 있는 문자열 형태의 가격을 가져옴
|
||||
close_str = str(item.get("stck_clpr", "0"))
|
||||
open_str = str(item.get("stck_oprc", "0"))
|
||||
high_str = str(item.get("stck_hgpr", "0"))
|
||||
low_str = str(item.get("stck_lwpr", "0"))
|
||||
|
||||
# 콤마를 제거하고 float으로 변환
|
||||
close_val = float(close_str.replace(",", ""))
|
||||
open_val = float(open_str.replace(",", ""))
|
||||
high_val = float(high_str.replace(",", ""))
|
||||
low_val = float(low_str.replace(",", ""))
|
||||
|
||||
# 파싱된 데이터를 딕셔너리 형태로 리스트에 추가
|
||||
chart_data.append({
|
||||
"date": item.get("stck_bsop_date"),
|
||||
"open": open_val,
|
||||
"high": high_val,
|
||||
"low": low_val,
|
||||
"close": close_val,
|
||||
"volume": int(item.get("acml_vol", 0))
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
# 가격 파싱 중 오류 발생 시 로그 기록하고 해당 데이터는 건너뜀
|
||||
logger.error(f"가격 파싱 오류: {item} - {e}")
|
||||
continue
|
||||
|
||||
# 조회한 데이터를 캐시에 저장
|
||||
chart_cache[cache_key] = {
|
||||
"timestamp": current_time,
|
||||
"data": chart_data
|
||||
}
|
||||
|
||||
# KIS API는 최신 데이터가 가장 먼저 오는 경향이 있으므로, 프론트엔드에서 날짜순으로 정렬(reverse)하여 사용
|
||||
|
||||
latest_candle = None
|
||||
# 'realtime' 쿼리 파라미터가 True인 경우, 현재가를 반영한 실시간 캔들 생성
|
||||
if realtime:
|
||||
try:
|
||||
price_data = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_data and price_data.get("output"):
|
||||
o = price_data["output"]
|
||||
# 현재가를 마지막(최신) 캔들로 보정 (지표 계산 시 영향 최소화 위해 기존 첫 캔들과 가격 다르면 prepend)
|
||||
# 현재가, 고가, 저가, 시가 등의 정보를 파싱하여 실시간 캔들 데이터 구성
|
||||
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
|
||||
high_price = float(str(o.get("stck_hgpr", "0")).replace(",", ""))
|
||||
low_price = float(str(o.get("stck_lwpr", "0")).replace(",", ""))
|
||||
@@ -188,14 +302,16 @@ async def get_stock_chart(
|
||||
"volume": volume,
|
||||
"realtime": True
|
||||
}
|
||||
# 기존 첫 번째 캔들의 날짜와 같으면 교체, 다르면 맨 앞에 추가
|
||||
# 기존 차트 데이터의 첫 번째(가장 최신) 캔들이 오늘 날짜와 같으면 교체, 아니면 맨 앞에 추가
|
||||
if chart_data and chart_data[0]["date"] == latest_candle["date"]:
|
||||
chart_data[0] = latest_candle
|
||||
else:
|
||||
chart_data.insert(0, latest_candle)
|
||||
except Exception as e:
|
||||
# 실시간 캔들 생성 실패 시 경고 로그 기록
|
||||
logger.warning(f"실시간 현재가 캔들 생성 실패: {e}")
|
||||
|
||||
# 최종 차트 데이터를 딕셔너리 형태로 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
@@ -206,9 +322,9 @@ async def get_stock_chart(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함)
|
||||
# API 호출 등에서 예외 발생 시, 개발 편의를 위해 샘플 데이터를 반환
|
||||
sample = _generate_sample_chart(stock_code, period)
|
||||
sample["error"] = str(e)
|
||||
sample["error"] = str(e) # 에러 메시지 포함
|
||||
sample["is_sample"] = True
|
||||
sample["realtime"] = False
|
||||
sample["latest"] = None
|
||||
@@ -217,19 +333,19 @@ async def get_stock_chart(
|
||||
|
||||
@router.get("/news")
|
||||
async def get_market_news(limit: int = 10):
|
||||
"""시장 뉴스 조회 (네이버 금융 RSS)"""
|
||||
import feedparser
|
||||
"""시장 뉴스 조회 엔드포인트 (Google News RSS 활용)"""
|
||||
import feedparser # RSS 파싱을 위한 라이브러리
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# 네이버 금융 증권 뉴스 RSS
|
||||
feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title"
|
||||
# Google News에서 '한국 증시 주식' 키워드로 검색한 결과의 RSS 주소
|
||||
rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko"
|
||||
|
||||
# RSS 피드 파싱
|
||||
feed = feedparser.parse(rss_url)
|
||||
|
||||
news_list = []
|
||||
# 조회할 개수(limit)만큼 반복하여 뉴스 항목 추출
|
||||
for idx, entry in enumerate(feed.entries[:limit]):
|
||||
news_list.append({
|
||||
"id": idx + 1,
|
||||
@@ -243,7 +359,7 @@ async def get_market_news(limit: int = 10):
|
||||
return news_list
|
||||
|
||||
except Exception as e:
|
||||
# 에러 발생 시 샘플 데이터 반환
|
||||
# 에러 발생 시 미리 정의된 샘플 뉴스 데이터 반환
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -270,3 +386,290 @@ async def get_market_news(limit: int = 10):
|
||||
"url": "#"
|
||||
}
|
||||
][:limit]
|
||||
|
||||
|
||||
@router.get("/orderbook/{stock_code}")
|
||||
async def get_orderbook(stock_code: str):
|
||||
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
|
||||
try:
|
||||
logger.info(f"🔍 Fetching orderbook for stock: {stock_code}")
|
||||
# 한국투자증권 API를 통해 호가 데이터 조회
|
||||
data = await korea_investment_api.get_orderbook(stock_code)
|
||||
|
||||
# API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리
|
||||
if data.get("rt_cd") != "0":
|
||||
logger.error(f"❌ Orderbook API error for {stock_code}: rt_cd={data.get('rt_cd')}, msg={data.get('msg1')}")
|
||||
raise HTTPException(status_code=404, detail=data.get("msg1", "Orderbook data not found"))
|
||||
|
||||
# API 응답에서 output1(호가), output2(체결가) 데이터 추출
|
||||
output1 = data.get("output1", {})
|
||||
output2 = data.get("output2", {})
|
||||
|
||||
# 현재가와 누적 거래량 정보 추출
|
||||
current_price = 0
|
||||
current_volume = 0
|
||||
if output2 and output2.get("stck_prpr"):
|
||||
current_price = int(output2.get("stck_prpr", 0))
|
||||
current_volume = int(output2.get("acml_vol", 0) or 0)
|
||||
elif output1.get("stck_prpr"):
|
||||
current_price = int(output1.get("stck_prpr", 0))
|
||||
current_volume = int(output1.get("acml_vol", 0) or 0)
|
||||
|
||||
# 호가 데이터를 담을 딕셔너리 초기화
|
||||
orderbook = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output1.get("hts_kor_isnm", ""),
|
||||
"current_price": current_price,
|
||||
"current_volume": current_volume,
|
||||
"asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse)
|
||||
"bids": [], # 매수 호가 (높은 가격 -> 낮은 가격 순)
|
||||
"total_ask_volume": 0,
|
||||
"total_bid_volume": 0,
|
||||
}
|
||||
|
||||
# 디버깅을 위한 원본 매도호가 데이터 로그
|
||||
logger.info(f"📊 [{stock_code}] 매도호가 원본 데이터:")
|
||||
for i in range(1, 11):
|
||||
price = output1.get(f"askp{i}", 0)
|
||||
volume = output1.get(f"askp_rsqn{i}", 0)
|
||||
logger.info(f" askp{i}: {price} ({volume}주)")
|
||||
|
||||
# 디버깅을 위한 원본 매수호가 데이터 로그
|
||||
logger.info(f"📊 [{stock_code}] 매수호가 원본 데이터:")
|
||||
for i in range(1, 11):
|
||||
price = output1.get(f"bidp{i}", 0)
|
||||
volume = output1.get(f"bidp_rsqn{i}", 0)
|
||||
logger.info(f" bidp{i}: {price} ({volume}주)")
|
||||
|
||||
# 매도호가 10단계 파싱 (askp1 ~ askp10)
|
||||
temp_asks = []
|
||||
for i in range(1, 11):
|
||||
price = int(output1.get(f"askp{i}", 0) or 0)
|
||||
volume = int(output1.get(f"askp_rsqn{i}", 0) or 0)
|
||||
if price > 0:
|
||||
temp_asks.append({"price": price, "volume": volume})
|
||||
orderbook["total_ask_volume"] += volume
|
||||
|
||||
# UI 표기를 위해 매도호가는 높은 가격이 위로 가도록 리스트를 뒤집음
|
||||
orderbook["asks"] = list(reversed(temp_asks))
|
||||
|
||||
# 매수호가 10단계 파싱 (bidp1 ~ bidp10)
|
||||
for i in range(1, 11):
|
||||
price = int(output1.get(f"bidp{i}", 0) or 0)
|
||||
volume = int(output1.get(f"bidp_rsqn{i}", 0) or 0)
|
||||
if price > 0:
|
||||
orderbook["bids"].append({"price": price, "volume": volume})
|
||||
orderbook["total_bid_volume"] += volume
|
||||
|
||||
# 최종 파싱 결과 로그
|
||||
logger.info(f"✅ Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
|
||||
logger.info(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
|
||||
logger.info(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
|
||||
if orderbook['asks']:
|
||||
logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
|
||||
if orderbook['bids']:
|
||||
logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
|
||||
|
||||
return orderbook
|
||||
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 다시 발생시킴
|
||||
raise
|
||||
except Exception as e:
|
||||
# 그 외 예외는 500 에러로 처리
|
||||
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/ai-analysis/{stock_code}")
|
||||
async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
"""AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)"""
|
||||
try:
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
logger.info(f"🤖 AI 분석 요청: {stock_code}")
|
||||
|
||||
# 1. 최신 시장 데이터(현재가 등) 가져오기
|
||||
price_data = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_data.get("rt_cd") != "0":
|
||||
raise HTTPException(status_code=404, detail="시장 데이터를 가져올 수 없습니다")
|
||||
|
||||
output = price_data.get("output", {})
|
||||
current_price = int(output.get("stck_prpr", 0))
|
||||
high_price = int(output.get("stck_hgpr", 0))
|
||||
low_price = int(output.get("stck_lwpr", 0))
|
||||
volume = int(output.get("acml_vol", 0))
|
||||
|
||||
# 2. DB에서 가장 최근의 기술적 지표 분석 데이터 가져오기
|
||||
from app.models.models import StockAnalysis
|
||||
result = await db.execute(
|
||||
select(StockAnalysis)
|
||||
.filter(StockAnalysis.stock_code == stock_code)
|
||||
.order_by(StockAnalysis.analysis_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
analysis_record = result.scalar_one_or_none()
|
||||
|
||||
indicators = {}
|
||||
if analysis_record:
|
||||
indicators = {
|
||||
"rsi": float(analysis_record.rsi) if analysis_record.rsi else None,
|
||||
"ma5": float(analysis_record.ma5) if analysis_record.ma5 else None,
|
||||
"ma20": float(analysis_record.ma20) if analysis_record.ma20 else None,
|
||||
"ma60": float(analysis_record.ma60) if analysis_record.ma60 else None,
|
||||
"stochastic_k": float(analysis_record.stochastic_k) if analysis_record.stochastic_k else None,
|
||||
"stochastic_d": float(analysis_record.stochastic_d) if analysis_record.stochastic_d else None,
|
||||
"bollinger_upper": float(analysis_record.bollinger_upper) if analysis_record.bollinger_upper else None,
|
||||
"bollinger_middle": float(analysis_record.bollinger_middle) if analysis_record.bollinger_middle else None,
|
||||
"bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None,
|
||||
}
|
||||
|
||||
# 3. 규칙 기반 분석 로직
|
||||
recommendation = "HOLD" # 기본 추천: 보유
|
||||
confidence = 0.0 # 신뢰도
|
||||
trend = "SIDEWAYS" # 추세
|
||||
analysis_text = "" # 분석 요약 텍스트
|
||||
|
||||
# 매수/매도 신호 점수 계산
|
||||
buy_signals = 0
|
||||
sell_signals = 0
|
||||
total_signals = 0
|
||||
|
||||
# 기술적 지표 데이터가 있는 경우 분석 시작
|
||||
if indicators and any(indicators.values()):
|
||||
signals = [] # 분석된 신호들을 담을 리스트
|
||||
|
||||
# RSI 분석
|
||||
if indicators.get("rsi") is not None:
|
||||
rsi = indicators["rsi"]
|
||||
total_signals += 1
|
||||
if rsi < 30: # 과매도
|
||||
buy_signals += 1
|
||||
signals.append(f"RSI 과매도({rsi:.1f})")
|
||||
elif rsi > 70: # 과매수
|
||||
sell_signals += 1
|
||||
signals.append(f"RSI 과매수({rsi:.1f})")
|
||||
elif rsi < 40: # 매수 고려
|
||||
buy_signals += 0.5
|
||||
signals.append(f"RSI 매수권장({rsi:.1f})")
|
||||
elif rsi > 60: # 매도 고려
|
||||
sell_signals += 0.5
|
||||
signals.append(f"RSI 매도권장({rsi:.1f})")
|
||||
|
||||
# 이동평균선(MA) 분석
|
||||
ma5 = indicators.get("ma5")
|
||||
ma20 = indicators.get("ma20")
|
||||
ma60 = indicators.get("ma60")
|
||||
|
||||
if ma5 and ma20:
|
||||
# 현재가와 20일 이동평균선의 괴리율을 계산하여 데이터의 신선도 체크
|
||||
ma_deviation = abs(current_price - ma20) / ma20 if ma20 > 0 else 999
|
||||
|
||||
if ma_deviation < 0.5: # 괴리율이 50% 미만일 때만 유효한 데이터로 간주
|
||||
total_signals += 1
|
||||
|
||||
if ma60 and ma5 > ma20 > ma60: # 정배열 (강한 상승 추세)
|
||||
buy_signals += 1
|
||||
trend = "UP"
|
||||
signals.append("이동평균 정배열(상승)")
|
||||
elif ma60 and ma5 < ma20 < ma60: # 역배열 (강한 하락 추세)
|
||||
sell_signals += 1
|
||||
trend = "DOWN"
|
||||
signals.append("이동평균 역배열(하락)")
|
||||
elif ma5 < ma20 and current_price < ma20: # 데드크로스 (하락 신호)
|
||||
sell_signals += 0.5
|
||||
trend = "DOWN"
|
||||
signals.append("데드크로스 신호")
|
||||
else:
|
||||
logger.debug(f"이동평균선 데이터 오래됨 (현재가: {current_price}, MA20: {ma20}, 괴리율: {ma_deviation*100:.1f}%)")
|
||||
|
||||
# 볼린저 밴드 분석
|
||||
bb_upper = indicators.get("bollinger_upper")
|
||||
bb_lower = indicators.get("bollinger_lower")
|
||||
bb_middle = indicators.get("bollinger_middle")
|
||||
|
||||
if bb_upper and bb_lower and bb_middle and bb_upper > bb_lower:
|
||||
# 현재가가 볼린저 밴드 내 어느 위치에 있는지 계산 (0~1)
|
||||
bb_position = (current_price - bb_lower) / (bb_upper - bb_lower)
|
||||
|
||||
# 밴드 위치가 -0.2 ~ 1.2 범위 내에 있을 때만 유효한 데이터로 간주 (괴리율 체크)
|
||||
if -0.2 <= bb_position <= 1.2:
|
||||
total_signals += 1
|
||||
if bb_position > 0.8: # 상단 근접 (과매수)
|
||||
sell_signals += 1
|
||||
signals.append(f"볼린저 밴드 상단근접({bb_position*100:.0f}%)")
|
||||
elif bb_position < 0.2: # 하단 근접 (과매도)
|
||||
buy_signals += 1
|
||||
signals.append(f"볼린저 밴드 하단근접({bb_position*100:.0f}%)")
|
||||
else:
|
||||
logger.debug(f"볼린저 밴드 데이터 오래됨 (현재가: {current_price}, 밴드: {bb_lower:.0f}~{bb_upper:.0f}, 위치: {bb_position*100:.0f}%)")
|
||||
|
||||
# 스토캐스틱 분석
|
||||
stoch_k = indicators.get("stochastic_k")
|
||||
stoch_d = indicators.get("stochastic_d")
|
||||
|
||||
if stoch_k is not None and stoch_d is not None:
|
||||
total_signals += 1
|
||||
if stoch_k < 20 and stoch_d < 20: # 과매도
|
||||
buy_signals += 1
|
||||
signals.append(f"스토캐스틱 과매도({stoch_k:.1f})")
|
||||
elif stoch_k > 80 and stoch_d > 80: # 과매수
|
||||
sell_signals += 1
|
||||
signals.append(f"스토캐스틱 과매수({stoch_k:.1f})")
|
||||
elif stoch_k > stoch_d and stoch_k < 50: # 상승 전환 신호
|
||||
buy_signals += 0.3
|
||||
signals.append("스토캐스틱 상승전환")
|
||||
elif stoch_k < stoch_d and stoch_k > 50: # 하락 전환 신호
|
||||
sell_signals += 0.3
|
||||
signals.append("스토캐스틱 하락전환")
|
||||
|
||||
# 최종 추천 결정
|
||||
if total_signals > 0:
|
||||
# (매수 신호 - 매도 신호) / 전체 신호 수로 점수 계산
|
||||
signal_score = (buy_signals - sell_signals) / total_signals
|
||||
confidence = min(abs(signal_score), 1.0) # 신뢰도는 점수의 절대값
|
||||
|
||||
if signal_score > 0.3: # 점수가 0.3보다 크면 '매수'
|
||||
recommendation = "BUY"
|
||||
elif signal_score < -0.3: # 점수가 -0.3보다 작으면 '매도'
|
||||
recommendation = "SELL"
|
||||
else: # 그 외에는 '보유'
|
||||
recommendation = "HOLD"
|
||||
|
||||
analysis_text = " | ".join(signals) if signals else "충분한 신호 없음"
|
||||
else:
|
||||
analysis_text = "기술적 지표 데이터 부족"
|
||||
else:
|
||||
analysis_text = "기술적 지표 데이터 없음"
|
||||
|
||||
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
|
||||
if trend == "SIDEWAYS" and high_price and low_price and current_price:
|
||||
daily_change_pct = ((current_price - low_price) / low_price) * 100 if low_price > 0 else 0
|
||||
if daily_change_pct > 10:
|
||||
trend = "UP"
|
||||
elif daily_change_pct < -10:
|
||||
trend = "DOWN"
|
||||
|
||||
# 4. 종합 결과 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output.get("stck_prdt_name", ""),
|
||||
"current_price": current_price,
|
||||
"high_price": high_price,
|
||||
"low_price": low_price,
|
||||
"volume": volume,
|
||||
"indicators": indicators,
|
||||
"ai_analysis": analysis_text,
|
||||
"recommendation": recommendation,
|
||||
"confidence": confidence,
|
||||
"trend": trend
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 다시 발생시킴
|
||||
raise
|
||||
except Exception as e:
|
||||
# 그 외 예외는 500 에러로 처리
|
||||
logger.error(f"Error in AI analysis for {stock_code}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI 분석 실패: {str(e)}")
|
||||
|
||||
@@ -25,10 +25,13 @@ async def get_mcp_status():
|
||||
response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0)
|
||||
|
||||
if response.status_code == 200:
|
||||
health_data = response.json()
|
||||
return {
|
||||
"status": "connected",
|
||||
"mcp_server_url": settings.MCP_SERVER_URL,
|
||||
"details": response.json()
|
||||
"claude_configured": health_data.get("claude_configured", False),
|
||||
"ai_provider": health_data.get("ai_provider", "unknown"),
|
||||
"details": health_data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
||||
170
backend/app/api/endpoints/ml_ensemble.py
Normal file
170
backend/app/api/endpoints/ml_ensemble.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
ML 앙상블 API 엔드포인트
|
||||
|
||||
- POST /train: 모델 학습
|
||||
- GET /predict/{stock_code}: 개별 종목 예측
|
||||
- GET /status: 모델 상태 조회
|
||||
- GET /backtest: 백테스트 결과
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from app.services.ml_ensemble import get_ensemble
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_model_status():
|
||||
"""앙상블 모델 상태 조회"""
|
||||
ensemble = get_ensemble()
|
||||
status = ensemble.get_status()
|
||||
status["config"] = {
|
||||
"enabled": settings.ML_ENSEMBLE_ENABLED,
|
||||
"rule_weight": settings.ML_ENSEMBLE_RULE_WEIGHT,
|
||||
"ml_weight": settings.ML_ENSEMBLE_ML_WEIGHT,
|
||||
"buy_threshold": settings.ML_ENSEMBLE_BUY_THRESHOLD,
|
||||
"confidence_min": settings.ML_ENSEMBLE_CONFIDENCE_MIN,
|
||||
}
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/predict/{stock_code}")
|
||||
async def predict_stock(stock_code: str):
|
||||
"""개별 종목 ML 앙상블 예측"""
|
||||
ensemble = get_ensemble()
|
||||
|
||||
if not ensemble.is_trained:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="모델이 학습되지 않았습니다. POST /ml-ensemble/train 으로 학습을 먼저 실행하세요."
|
||||
)
|
||||
|
||||
try:
|
||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||||
chart_data = chart_resp.get("output", [])
|
||||
|
||||
if not chart_data or len(chart_data) < 61:
|
||||
raise HTTPException(status_code=400, detail=f"차트 데이터 부족: {len(chart_data) if chart_data else 0}일 (최소 61일 필요)")
|
||||
|
||||
result = ensemble.predict(chart_data)
|
||||
result["stock_code"] = stock_code
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"예측 실패 ({stock_code}): {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _train_task(limit: int):
|
||||
"""백그라운드 학습 작업"""
|
||||
import numpy as np
|
||||
from app.services.feature_engineering import (
|
||||
chart_data_to_dataframe, extract_features, prepare_training_data
|
||||
)
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
# 종목 조회
|
||||
stocks = []
|
||||
try:
|
||||
kospi = await kis_api.get_volume_rank("J")
|
||||
if kospi and kospi.get("output"):
|
||||
for item in kospi["output"]:
|
||||
name = item.get("hts_kor_isnm", "")
|
||||
skip = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
|
||||
if not any(k in name for k in skip):
|
||||
stocks.append((item["mksc_shrn_iscd"], name))
|
||||
except Exception as e:
|
||||
logger.error(f"종목 조회 실패: {e}")
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
try:
|
||||
kosdaq = await kis_api.get_volume_rank("Q")
|
||||
if kosdaq and kosdaq.get("output"):
|
||||
for item in kosdaq["output"]:
|
||||
name = item.get("hts_kor_isnm", "")
|
||||
skip = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
|
||||
if not any(k in name for k in skip):
|
||||
stocks.append((item["mksc_shrn_iscd"], name))
|
||||
except Exception as e:
|
||||
logger.error(f"종목 조회 실패: {e}")
|
||||
|
||||
stocks = stocks[:limit]
|
||||
logger.info(f"학습 대상: {len(stocks)}개 종목")
|
||||
|
||||
# 데이터 수집
|
||||
all_X_tab, all_X_seq, all_y = [], [], []
|
||||
for i, (code, name) in enumerate(stocks):
|
||||
try:
|
||||
chart_resp = await kis_api.get_daily_chart(code, "D")
|
||||
chart_data = chart_resp.get("output", [])
|
||||
if not chart_data or len(chart_data) < 80:
|
||||
continue
|
||||
|
||||
df = chart_data_to_dataframe(chart_data)
|
||||
feat = extract_features(df)
|
||||
X_tab, X_seq, y, _ = prepare_training_data(df, feat)
|
||||
if len(X_tab) > 0:
|
||||
all_X_tab.append(X_tab)
|
||||
all_X_seq.append(X_seq)
|
||||
all_y.append(y)
|
||||
logger.info(f"[{i+1}/{len(stocks)}] {code}: {len(X_tab)}개 샘플")
|
||||
except Exception as e:
|
||||
logger.warning(f"[{i+1}] {code} 실패: {e}")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not all_X_tab:
|
||||
logger.error("학습 데이터 없음")
|
||||
return
|
||||
|
||||
X_tabular = np.vstack(all_X_tab)
|
||||
X_seq = np.vstack(all_X_seq)
|
||||
y = np.concatenate(all_y)
|
||||
|
||||
logger.info(f"총 {len(X_tabular)}개 샘플로 학습 시작")
|
||||
|
||||
ensemble = get_ensemble()
|
||||
metrics = ensemble.train(X_tabular, X_seq, y)
|
||||
logger.info(f"학습 완료: {metrics}")
|
||||
|
||||
|
||||
@router.post("/train")
|
||||
async def train_model(
|
||||
background_tasks: BackgroundTasks,
|
||||
limit: int = Query(50, description="학습 종목 수"),
|
||||
):
|
||||
"""앙상블 모델 학습 (백그라운드 실행)"""
|
||||
background_tasks.add_task(_train_task, limit)
|
||||
return {
|
||||
"message": f"학습이 백그라운드에서 시작되었습니다 ({limit}개 종목)",
|
||||
"status": "training_started",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/backtest")
|
||||
async def get_backtest():
|
||||
"""모델 학습 메트릭 및 백테스트 결과 조회"""
|
||||
ensemble = get_ensemble()
|
||||
if not ensemble.is_trained:
|
||||
raise HTTPException(status_code=503, detail="모델이 학습되지 않았습니다")
|
||||
|
||||
return {
|
||||
"is_trained": True,
|
||||
"metrics": ensemble.training_metrics,
|
||||
"weights": {
|
||||
"lgbm": ensemble.weights[0],
|
||||
"lstm": ensemble.weights[1],
|
||||
"rf": ensemble.weights[2],
|
||||
},
|
||||
}
|
||||
@@ -1,18 +1,47 @@
|
||||
"""
|
||||
포트폴리오 API 엔드포인트
|
||||
|
||||
- GET /: 보유 종목 목록 조회 (캐시 활용)
|
||||
- GET /summary: 포트폴리오 요약 (총자산, 평가손익)
|
||||
- GET /sync: 실제 계좌와 동기화
|
||||
- POST /sell: 수동 매도 (청산)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
import pytz
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import Portfolio
|
||||
from app.core.database import get_db, AsyncSessionLocal
|
||||
from app.models.models import Portfolio, TradeHistory, ActiveTrade, StockAnalysis, TradeJournal
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.mean_reversion_strategy import (
|
||||
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
|
||||
)
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
from app.services.websocket_manager import websocket_manager
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한국 시간대
|
||||
KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
# Request/Response Models
|
||||
class SellRequest(BaseModel):
|
||||
stock_code: str
|
||||
quantity: int
|
||||
price: Optional[int] = None
|
||||
|
||||
def _get_kst_now():
|
||||
"""현재 한국 시간 반환 (timezone-naive datetime)"""
|
||||
return datetime.now(KST).replace(tzinfo=None)
|
||||
|
||||
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
|
||||
class PortfolioCache:
|
||||
def __init__(self):
|
||||
@@ -28,6 +57,7 @@ class PortfolioCache:
|
||||
self.failure_count: int = 0
|
||||
self.next_retry_at: datetime | None = None
|
||||
self.last_sync_success: bool = False
|
||||
self.last_trade_id: int | None = None
|
||||
|
||||
def mark_changed(self):
|
||||
self.change_flag = True
|
||||
@@ -45,7 +75,7 @@ class PortfolioCache:
|
||||
return False
|
||||
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
|
||||
return True
|
||||
if (now - self.last_synced) > timedelta(minutes=5): # 주기적 신선도 보장
|
||||
if (now - self.last_synced) > timedelta(seconds=5): # 실시간: 5초마다 갱신
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -68,19 +98,102 @@ class PortfolioCache:
|
||||
self.next_retry_at = None
|
||||
self.last_sync_success = True
|
||||
|
||||
def cache_summary(self):
|
||||
def cache_summary(self, balance_data: dict = None):
|
||||
holdings = self.holdings
|
||||
total_stocks = len(holdings)
|
||||
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
|
||||
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
|
||||
total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings)
|
||||
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
|
||||
|
||||
# KIS API output2에서 예수금 및 총자산 정보 추출
|
||||
available_cash = 0.0
|
||||
total_assets = 0.0
|
||||
if balance_data:
|
||||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
|
||||
if "output2" in balance_data:
|
||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||||
logger.info(f"📊 output2 keys: {output2.keys()}")
|
||||
logger.info(f"💰 output2 data: {output2}")
|
||||
else:
|
||||
logger.warning("⚠️ No 'output2' in balance_data!")
|
||||
|
||||
if balance_data and "output2" in balance_data:
|
||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||||
|
||||
# 가급적 증권사 화면과 동일 기준 사용:
|
||||
# - 현금: D+2 정산 기준 예수금(d2_depo_amt)
|
||||
# - 총자산: 총평가금액(tot_evlu_amt) 우선, 없으면 유가평가+현금 계산
|
||||
# 필드명이 환경에 따라 다를 수 있어 다중 키 체킹
|
||||
def pick_float(d: dict, keys: list[str]) -> float:
|
||||
for k in keys:
|
||||
if k in d and d.get(k) not in (None, ""):
|
||||
try:
|
||||
return float(d.get(k))
|
||||
except Exception:
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
# 현금: D+2 정산액 우선 -> 예수금총액 -> 순자산금액 순
|
||||
available_cash = pick_float(output2, [
|
||||
"d2_depo_amt", # D+2 정산액
|
||||
"d2_deposit", # 일부 응답 변형 키
|
||||
"dnca_tot_amt", # 예수금 총액
|
||||
"nass_amt" # 순자산금액 (fallback)
|
||||
])
|
||||
|
||||
# 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확)
|
||||
# tot_evlu_amt는 D+2 정산액 + 유가평가액을 포함한 실제 총자산
|
||||
kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
|
||||
|
||||
if kis_total_assets > 0:
|
||||
# KIS API 값 우선 사용
|
||||
total_assets = kis_total_assets
|
||||
calculated_assets = total_value + available_cash
|
||||
|
||||
# 차이가 크면 로그 (정상 - D+2 정산액 때문)
|
||||
if abs(kis_total_assets - calculated_assets) > 1000:
|
||||
logger.debug(
|
||||
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
|
||||
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
|
||||
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
|
||||
)
|
||||
else:
|
||||
# KIS API 값이 없으면 계산 (fallback)
|
||||
total_assets = total_value + available_cash
|
||||
|
||||
# 디버깅 로그(요약): 실제 사용된 값과 원본 일부 키 노출
|
||||
try:
|
||||
logger.debug(
|
||||
"Portfolio summary calc - cash=%s, total_assets=%s, keys=%s",
|
||||
f"{available_cash:,.0f}",
|
||||
f"{total_assets:,.0f}",
|
||||
{k: output2.get(k) for k in [
|
||||
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
|
||||
] if k in output2}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# D+2 정산액 및 기타 상세 정보 추출
|
||||
d2_settlement = 0.0
|
||||
stock_value_from_api = 0.0
|
||||
|
||||
if balance_data and "output2" in balance_data:
|
||||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||||
d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
|
||||
stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
|
||||
|
||||
self.summary = {
|
||||
"total_stocks": total_stocks,
|
||||
"total_value": total_value,
|
||||
"total_investment": total_investment,
|
||||
"total_profit_loss": total_profit_loss,
|
||||
"total_profit_loss_rate": total_profit_loss_rate
|
||||
"total_profit_loss_rate": total_profit_loss_rate,
|
||||
"available_cash": available_cash,
|
||||
"total_assets": total_assets,
|
||||
"d2_settlement": d2_settlement, # D+2 정산액
|
||||
"stock_value_from_api": stock_value_from_api # API의 유가평가액
|
||||
}
|
||||
|
||||
portfolio_cache = PortfolioCache()
|
||||
@@ -103,19 +216,37 @@ class PortfolioResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(func.max(TradeHistory.id)).where(TradeHistory.status.in_(['EXECUTED', 'COMPLETED']))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch latest trade id: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=List[PortfolioResponse])
|
||||
async def get_portfolio(db: AsyncSession = Depends(get_db)):
|
||||
"""포트폴리오 조회 (캐시 활용)"""
|
||||
# 거래 이력 기준으로 캐시 무효화
|
||||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||||
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||||
portfolio_cache.mark_changed()
|
||||
|
||||
if portfolio_cache.holdings and not portfolio_cache.should_sync():
|
||||
logger.debug("Portfolio cache hit (holdings)")
|
||||
return portfolio_cache.holdings
|
||||
# 수량 0인 종목 필터링 (안전장치)
|
||||
return [h for h in portfolio_cache.holdings if h.quantity > 0]
|
||||
# 필요하면 동기화 시도
|
||||
try:
|
||||
await sync_portfolio(db, force=False)
|
||||
except HTTPException:
|
||||
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
|
||||
logger.warning("get_portfolio: sync failed, returning cached holdings")
|
||||
return portfolio_cache.holdings
|
||||
# 수량 0인 종목 필터링 (안전장치)
|
||||
return [h for h in portfolio_cache.holdings if h.quantity > 0]
|
||||
|
||||
|
||||
@router.get("/sync")
|
||||
@@ -126,11 +257,71 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
if not force and not portfolio_cache.should_sync():
|
||||
logger.debug("Skip sync: cache fresh & no change flag")
|
||||
return {"message": "Skipped (cache fresh)", "cached": True}
|
||||
async with portfolio_cache.sync_lock:
|
||||
# 락 획득에 30초 타임아웃 적용 (이전 sync hang 시 데드락 방지)
|
||||
try:
|
||||
await asyncio.wait_for(portfolio_cache.sync_lock.acquire(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
portfolio_cache.record_failure("Sync lock timeout (30s)")
|
||||
logger.error("Portfolio sync lock timeout — 이전 sync가 30초 이상 hang 중")
|
||||
if force:
|
||||
raise HTTPException(status_code=503, detail="Portfolio sync timeout")
|
||||
return {"message": "Sync timeout (using cache)", "cached": True}
|
||||
try:
|
||||
portfolio_cache.last_attempt = datetime.now()
|
||||
try:
|
||||
balance_data = await korea_investment_api.get_balance()
|
||||
|
||||
# 디버깅: KIS API 전체 응답 로깅
|
||||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
||||
|
||||
# Balance API 오류 시 기존 DB 보유종목을 삭제하지 않고 캐시 유지
|
||||
if not balance_data or balance_data.get('rt_cd') != '0':
|
||||
error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response'
|
||||
logger.warning(f"⚠️ Balance API 오류 - 기존 포트폴리오 유지: {error_msg}")
|
||||
# 캐시가 비어있으면 DB에서 로드
|
||||
if not portfolio_cache.holdings:
|
||||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
portfolio_cache.holdings = list(result.scalars().all())
|
||||
logger.info(f"📦 DB에서 포트폴리오 로드: {len(portfolio_cache.holdings)}종목")
|
||||
return {"message": f"Balance API error: {error_msg}", "cached": True, "count": len(portfolio_cache.holdings)}
|
||||
|
||||
portfolio_cache.record_success()
|
||||
|
||||
if "output1" in balance_data and balance_data["output1"]:
|
||||
logger.info(f"🔍 output1 count: {len(balance_data['output1'])}")
|
||||
|
||||
# 1. 오늘 발생한 모든 매매 이력 조회 (Ghost Stock 방지 - 시간 범위 확대)
|
||||
now = _get_kst_now()
|
||||
# 오늘의 시작 시간 (00:00:00) 계산
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# 오늘 포함 최근 24시간 (혹시 자정 근처 이슈 방지)이지만, 주로 '최근 행동'이 중요함.
|
||||
# 안전하게 '오늘 0시 이후'의 모든 거래를 가져옴.
|
||||
recent_point = today_start
|
||||
|
||||
logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection")
|
||||
|
||||
# 복잡한 SQL 대신 단순 조회 후 애플리케이션 레벨에서 필터링
|
||||
trades_stmt = select(TradeHistory).where(
|
||||
TradeHistory.executed_at >= recent_point,
|
||||
TradeHistory.status.in_(['EXECUTED', 'COMPLETED'])
|
||||
).order_by(TradeHistory.executed_at.asc())
|
||||
|
||||
trade_result = await db.execute(trades_stmt)
|
||||
recent_trades = trade_result.scalars().all()
|
||||
|
||||
# 종목별 최신 상태 추적
|
||||
stock_status_map = {}
|
||||
for t in recent_trades:
|
||||
stock_status_map[t.stock_code] = t.order_type # 덮어쓰기 (마지막이 최신)
|
||||
logger.debug(f"Trade trace: {t.stock_name}({t.stock_code}) -> {t.order_type} at {t.executed_at}")
|
||||
|
||||
# 최신 상태가 'SELL'인 종목 식별
|
||||
ghost_candidates = {code for code, status in stock_status_map.items() if status == "SELL"}
|
||||
|
||||
if ghost_candidates:
|
||||
logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}")
|
||||
|
||||
# 기존 레코드 로드 (삭제 대신 업데이트)
|
||||
result = await db.execute(select(Portfolio))
|
||||
existing_all = {p.stock_code: p for p in result.scalars().all()}
|
||||
@@ -139,14 +330,53 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
if "output1" in balance_data:
|
||||
for item in balance_data["output1"]:
|
||||
stock_code = item.get("pdno")
|
||||
|
||||
# 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리)
|
||||
# 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음.
|
||||
if stock_code in ghost_candidates:
|
||||
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
|
||||
# DB에서도 명시적 삭제
|
||||
if stock_code in existing_all:
|
||||
await db.delete(existing_all[stock_code])
|
||||
logger.info(f"🗑️ Force deleted ghost stock from DB: {stock_code}")
|
||||
continue
|
||||
|
||||
stock_name = item.get("prdt_name")
|
||||
quantity = int(item.get("hldg_qty", 0))
|
||||
if quantity <= 0:
|
||||
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
|
||||
continue
|
||||
average_price = Decimal(item.get("pchs_avg_pric", 0))
|
||||
current_price = Decimal(item.get("prpr", 0))
|
||||
profit_loss = Decimal(item.get("evlu_pfls_amt", 0))
|
||||
profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0))
|
||||
|
||||
# KIS API 필드명 정확히 매핑 (로깅 추가)
|
||||
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
|
||||
|
||||
# 🔥 실시간 현재가 조회 (잔고 조회의 prpr은 실시간이 아님)
|
||||
try:
|
||||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_resp and "output" in price_resp:
|
||||
realtime_price = int(price_resp["output"].get("stck_prpr", 0))
|
||||
if realtime_price > 0:
|
||||
current_price = Decimal(str(realtime_price))
|
||||
else:
|
||||
current_price = Decimal(str(item.get("prpr", 0)))
|
||||
else:
|
||||
current_price = Decimal(str(item.get("prpr", 0)))
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {e}, fallback to prpr")
|
||||
current_price = Decimal(str(item.get("prpr", 0)))
|
||||
|
||||
# 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용
|
||||
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
|
||||
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
|
||||
|
||||
# 디버깅: 실제 KIS API 응답 로깅
|
||||
logger.info(
|
||||
f"Portfolio sync - {stock_name}({stock_code}): "
|
||||
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, "
|
||||
f"평가손익={profit_loss}, 수익률={profit_loss_rate}% | "
|
||||
f"원본 필드={item}"
|
||||
)
|
||||
|
||||
processed_codes.add(stock_code)
|
||||
if stock_code in existing_all:
|
||||
h = existing_all[stock_code]
|
||||
@@ -169,15 +399,25 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
)
|
||||
db.add(new_holding)
|
||||
new_holdings.append(new_holding)
|
||||
# 제거된 종목 반영 (수량 0 처리)
|
||||
# 제거된 종목 반영 (DB에서 완전 삭제)
|
||||
for code, h in existing_all.items():
|
||||
if code not in processed_codes:
|
||||
h.quantity = 0
|
||||
# 보호 로직: 최근 매수 종목이면 API 미반영 상태로 간주하고 유지
|
||||
# (KIS API가 체결 직후 잔고 반영이 느릴 수 있음)
|
||||
last_action = stock_status_map.get(code)
|
||||
if last_action == "BUY" and h.quantity > 0:
|
||||
logger.info(f"🛡️ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists")
|
||||
new_holdings.append(h)
|
||||
continue
|
||||
|
||||
logger.info(f"🗑️ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)")
|
||||
await db.delete(h)
|
||||
await db.commit()
|
||||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
||||
portfolio_cache.cache_summary()
|
||||
portfolio_cache.cache_summary(balance_data) # balance_data 전달
|
||||
portfolio_cache.last_synced = datetime.now()
|
||||
portfolio_cache.change_flag = False
|
||||
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
|
||||
logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings")
|
||||
return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False}
|
||||
except Exception as e:
|
||||
@@ -188,11 +428,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
|
||||
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
|
||||
# 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용
|
||||
return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)}
|
||||
finally:
|
||||
portfolio_cache.sync_lock.release()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
||||
"""포트폴리오 요약 (캐시 활용)"""
|
||||
# 거래 이력 기준으로 캐시 무효화
|
||||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||||
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||||
portfolio_cache.mark_changed()
|
||||
|
||||
if portfolio_cache.summary and not portfolio_cache.should_sync():
|
||||
logger.debug("Portfolio cache hit (summary)")
|
||||
resp = dict(portfolio_cache.summary)
|
||||
@@ -211,7 +458,9 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
||||
"total_value": 0,
|
||||
"total_investment": 0,
|
||||
"total_profit_loss": 0,
|
||||
"total_profit_loss_rate": 0
|
||||
"total_profit_loss_rate": 0,
|
||||
"available_cash": 0,
|
||||
"total_assets": 0
|
||||
}
|
||||
resp = dict(base)
|
||||
resp.update({
|
||||
@@ -226,3 +475,402 @@ async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
|
||||
"""강제 캐시 무효화 및 재동기화"""
|
||||
portfolio_cache.change_flag = True
|
||||
return await sync_portfolio(db, force=True)
|
||||
|
||||
|
||||
async def check_and_execute_exit_trades(db: AsyncSession):
|
||||
"""
|
||||
보유 종목의 손절/익절 조건을 체크하고 자동 매도 실행
|
||||
CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
|
||||
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -8%)
|
||||
- 1차 익절(50%): RSI ≥ 50 + BB 중앙선 상회
|
||||
- 2차 익절(전량): RSI 60~70 + BB 상단선
|
||||
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
await sync_portfolio(db, force=True)
|
||||
|
||||
# MDD 업데이트 (async — thread-safe lock 내부)
|
||||
summary = await _get_portfolio_summary(db)
|
||||
total_assets = float(summary.get("total_assets", 0)) if summary else 0
|
||||
mdd_action = await mdd_manager.update(total_assets)
|
||||
if mdd_action["action"] != "NONE":
|
||||
logger.warning(f"MDD 조치: {mdd_action}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(Portfolio.quantity > 0)
|
||||
)
|
||||
holdings = result.scalars().all()
|
||||
|
||||
if not holdings:
|
||||
logger.debug("No holdings to check for exit conditions")
|
||||
return
|
||||
|
||||
executed_exits = []
|
||||
|
||||
for holding in holdings:
|
||||
try:
|
||||
stock_code = holding.stock_code
|
||||
stock_name = holding.stock_name
|
||||
quantity = holding.quantity
|
||||
avg_price = float(holding.average_price or 0)
|
||||
|
||||
# 실시간 가격 확인
|
||||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||||
latest_price = float(holding.current_price or 0)
|
||||
if price_resp and price_resp.get("output"):
|
||||
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
|
||||
|
||||
if latest_price <= 0 or avg_price <= 0:
|
||||
continue
|
||||
|
||||
profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100
|
||||
|
||||
# ActiveTrade 조회 (분할 청산 상태 추적)
|
||||
at_result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active"
|
||||
)
|
||||
)
|
||||
active_trade = at_result.scalar_one_or_none()
|
||||
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0
|
||||
|
||||
# ATR 기반 청산 시도 (차트 데이터 필요)
|
||||
exit_signal = None
|
||||
try:
|
||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
|
||||
chart_data = chart_resp.get("output2", [])
|
||||
if len(chart_data) >= 20:
|
||||
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
|
||||
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
|
||||
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
|
||||
|
||||
atr = calculate_atr(highs, lows, closes)
|
||||
rsi = calculate_rsi(closes)
|
||||
bb = calculate_bollinger(closes)
|
||||
|
||||
if atr and rsi is not None and bb:
|
||||
bb_upper, bb_middle, bb_lower = bb
|
||||
lowest_since = min(lows[-5:]) if lows else avg_price
|
||||
exit_signal = check_exit_signal(
|
||||
rsi=rsi,
|
||||
close=latest_price,
|
||||
bb_upper=bb_upper,
|
||||
bb_middle=bb_middle,
|
||||
bb_lower=bb_lower,
|
||||
entry_price=avg_price,
|
||||
atr=atr,
|
||||
lowest_since_entry=lowest_since,
|
||||
remaining_pct=remaining_pct,
|
||||
)
|
||||
except Exception as chart_err:
|
||||
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||||
|
||||
should_exit = False
|
||||
exit_reason = ""
|
||||
sell_quantity = quantity
|
||||
order_price = latest_price
|
||||
|
||||
if exit_signal:
|
||||
should_exit = True
|
||||
exit_reason = exit_signal.reason
|
||||
if exit_signal.action == "SELL_PARTIAL":
|
||||
sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
|
||||
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
|
||||
elif profit_loss_rate <= settings.STOP_LOSS_PERCENT:
|
||||
# 폴백: 고정 비율 손절 (-6%)
|
||||
should_exit = True
|
||||
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)"
|
||||
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
|
||||
should_exit = True
|
||||
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
|
||||
|
||||
if should_exit:
|
||||
logger.info(f"{exit_reason} - {stock_name}({stock_code})")
|
||||
try:
|
||||
order_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=sell_quantity,
|
||||
price=order_price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"지정가 실패, 시장가 대체: {e}")
|
||||
order_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=sell_quantity,
|
||||
price=None
|
||||
)
|
||||
|
||||
executed_price = Decimal(str(order_result.get("output", {}).get("ord_prc", order_price)))
|
||||
total_amount = Decimal(str(executed_price)) * sell_quantity
|
||||
profit_loss = (float(executed_price) - avg_price) * sell_quantity
|
||||
|
||||
trade = TradeHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type="SELL",
|
||||
order_price=Decimal(str(order_price)),
|
||||
order_quantity=sell_quantity,
|
||||
executed_price=executed_price,
|
||||
executed_quantity=sell_quantity,
|
||||
total_amount=total_amount,
|
||||
status="COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED",
|
||||
order_id=order_result.get("odno"),
|
||||
message=f"자동 청산: {profit_loss_rate:.2f}%",
|
||||
exit_reason=f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
|
||||
created_at=_get_kst_now(),
|
||||
executed_at=_get_kst_now() if order_result.get("rt_cd") == "0" else None
|
||||
)
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
|
||||
if order_result.get("rt_cd") == "0":
|
||||
# 연속 손실 추적 (섹션 7.4) — 인메모리 + DB 영속화
|
||||
is_loss = profit_loss < 0
|
||||
loss_tracker.record_trade_result(stock_code, is_loss)
|
||||
await loss_tracker.save_to_db(db, stock_code)
|
||||
|
||||
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
|
||||
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade:
|
||||
active_trade.remaining_pct = Decimal(str(remaining_pct - exit_signal.sell_pct))
|
||||
await db.commit()
|
||||
else:
|
||||
# 전량 매도
|
||||
holding.quantity = max(0, quantity - sell_quantity)
|
||||
holding.current_price = Decimal(str(latest_price))
|
||||
if active_trade:
|
||||
active_trade.status = "closed"
|
||||
await db.commit()
|
||||
|
||||
portfolio_cache.mark_changed()
|
||||
executed_exits.append({
|
||||
"stock": stock_name,
|
||||
"reason": exit_reason,
|
||||
"profit_loss": profit_loss_rate,
|
||||
"sell_quantity": sell_quantity,
|
||||
})
|
||||
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
|
||||
|
||||
# 매매일지 백그라운드 생성
|
||||
asyncio.create_task(_create_journal_background(
|
||||
trade_id=trade.id,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type="SELL",
|
||||
executed_price=float(executed_price),
|
||||
quantity=sell_quantity,
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_rate=profit_loss_rate,
|
||||
exit_reason=exit_reason,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Exit trade error for {holding.stock_code}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
if executed_exits:
|
||||
logger.info(f"Executed {len(executed_exits)} exit trades: {executed_exits}")
|
||||
else:
|
||||
logger.debug("No exit conditions met for current holdings")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"check_and_execute_exit_trades error: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _create_journal_background(
|
||||
trade_id: int,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
order_type: str,
|
||||
executed_price: float,
|
||||
quantity: int,
|
||||
profit_loss: float,
|
||||
profit_loss_rate: float,
|
||||
exit_reason: str,
|
||||
):
|
||||
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크)"""
|
||||
try:
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
trade_info = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": order_type,
|
||||
"executed_price": executed_price,
|
||||
"quantity": quantity,
|
||||
"profit_loss": profit_loss,
|
||||
"profit_loss_rate": profit_loss_rate,
|
||||
"exit_reason": exit_reason,
|
||||
}
|
||||
|
||||
content = None
|
||||
mood = None
|
||||
tags = []
|
||||
|
||||
# MCP 서버로 AI 일지 생성 요청 (실패 시 규칙 기반 폴백)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{settings.MCP_SERVER_URL}/analyze",
|
||||
json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info},
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
result = resp.json().get("result", {})
|
||||
content = result.get("content") or result.get("journal") or result.get("analysis")
|
||||
mood = result.get("mood")
|
||||
tags = result.get("tags", [])
|
||||
except Exception as mcp_err:
|
||||
logger.debug(f"MCP 일지 생성 실패 ({stock_code}): {mcp_err}")
|
||||
|
||||
# 폴백: 규칙 기반 간단 일지
|
||||
if not content:
|
||||
direction = "익절" if profit_loss_rate > 0 else "손절"
|
||||
sign = "+" if profit_loss > 0 else ""
|
||||
content = (
|
||||
f"{stock_name}({stock_code}) 매도 체결.\n"
|
||||
f"매도가: {executed_price:,.0f}원 × {quantity}주\n"
|
||||
f"손익: {sign}{profit_loss:,.0f}원 ({sign}{profit_loss_rate:.2f}%)\n"
|
||||
f"사유: {exit_reason}"
|
||||
)
|
||||
mood = "좋음" if profit_loss > 0 else "나쁨"
|
||||
tags = [direction, stock_code]
|
||||
|
||||
# DB 저장 (별도 세션 사용 — 호출 시점의 세션은 이미 닫혔을 수 있음)
|
||||
async with AsyncSessionLocal() as session:
|
||||
journal = TradeJournal(
|
||||
trade_id=trade_id,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
journal_date=datetime.utcnow(),
|
||||
content=content,
|
||||
strategy_name="AUTO_TRADE",
|
||||
mood=mood,
|
||||
tags=tags if tags else None,
|
||||
)
|
||||
session.add(journal)
|
||||
await session.commit()
|
||||
logger.info(f"매매일지 생성 완료: {stock_name} (trade_id={trade_id})")
|
||||
|
||||
# WebSocket으로 프론트엔드에 즉시 알림
|
||||
await websocket_manager.broadcast_json({
|
||||
"type": "journal_created",
|
||||
"data": {"stock_code": stock_code, "stock_name": stock_name},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"매매일지 생성 오류 ({stock_code}): {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _get_portfolio_summary(db: AsyncSession) -> dict:
|
||||
"""포트폴리오 요약 (MDD 계산용)"""
|
||||
try:
|
||||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
holdings = result.scalars().all()
|
||||
total_eval = sum(float(h.current_price or 0) * h.quantity for h in holdings)
|
||||
total_invest = sum(float(h.average_price or 0) * h.quantity for h in holdings)
|
||||
return {"total_assets": total_eval, "total_invest": total_invest}
|
||||
except Exception:
|
||||
return {"total_assets": 0, "total_invest": 0}
|
||||
|
||||
|
||||
@router.post("/sell")
|
||||
async def sell_stock(
|
||||
request: SellRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""보유 종목 수동 매도 (청산 버튼)"""
|
||||
try:
|
||||
stock_code = request.stock_code
|
||||
quantity = request.quantity
|
||||
price = request.price
|
||||
|
||||
logger.info(f"🔵 수동 매도 요청: {stock_code} {quantity}주 @ {price or '시장가'}")
|
||||
|
||||
# 보유 여부 확인
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||||
)
|
||||
holding = result.scalar_one_or_none()
|
||||
|
||||
if not holding:
|
||||
raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}")
|
||||
|
||||
if holding.quantity < quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청"
|
||||
)
|
||||
|
||||
# 매도 주문 실행
|
||||
order_result = await korea_investment_api.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type='SELL',
|
||||
quantity=quantity,
|
||||
price=price # None이면 시장가
|
||||
)
|
||||
|
||||
if not order_result or order_result.get('rt_cd') != '0':
|
||||
error_msg = order_result.get('msg1', '알 수 없는 오류') if order_result else 'API 호출 실패'
|
||||
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
|
||||
|
||||
# TradeHistory 기록
|
||||
executed_price = price if price else holding.current_price # 체결가는 나중에 업데이트
|
||||
profit_loss = (executed_price - holding.average_price) * quantity
|
||||
profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100
|
||||
|
||||
trade = TradeHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=holding.stock_name,
|
||||
order_type='SELL',
|
||||
order_price=Decimal(str(price)) if price else None,
|
||||
order_quantity=quantity,
|
||||
executed_price=Decimal(str(executed_price)),
|
||||
executed_quantity=quantity,
|
||||
total_amount=Decimal(str(executed_price * quantity)),
|
||||
status='EXECUTED',
|
||||
order_id=order_result.get('output', {}).get('ODNO'),
|
||||
message=f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
|
||||
exit_reason=f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
|
||||
created_at=_get_kst_now(),
|
||||
executed_at=_get_kst_now()
|
||||
)
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
|
||||
# 포트폴리오 캐시 무효화
|
||||
portfolio_cache.mark_changed()
|
||||
|
||||
logger.info(f"✅ 수동 매도 완료: {holding.stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
|
||||
|
||||
# 매매일지 백그라운드 생성
|
||||
asyncio.create_task(_create_journal_background(
|
||||
trade_id=trade.id,
|
||||
stock_code=stock_code,
|
||||
stock_name=holding.stock_name,
|
||||
order_type="SELL",
|
||||
executed_price=float(executed_price),
|
||||
quantity=quantity,
|
||||
profit_loss=float(profit_loss),
|
||||
profit_loss_rate=float(profit_loss_rate),
|
||||
exit_reason=f"수동 청산 (손익: {profit_loss:+,.0f}원, {profit_loss_rate:+.2f}%)",
|
||||
))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{holding.stock_name} {quantity}주 매도 주문 완료",
|
||||
"order_id": order_result.get('output', {}).get('ODNO'),
|
||||
"profit_loss": float(profit_loss),
|
||||
"profit_loss_rate": float(profit_loss_rate)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Manual sell error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"매도 처리 중 오류: {str(e)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
473
backend/app/api/endpoints/risk_management.py
Normal file
473
backend/app/api/endpoints/risk_management.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
🔥 리스크 관리 및 성과 평가 API
|
||||
|
||||
- MDD (Maximum Drawdown) 추적
|
||||
- Sharpe Ratio 계산
|
||||
- 연속 손실 제한
|
||||
- 일일 손실 한도
|
||||
- 포지션 사이징 제한
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func, desc
|
||||
from datetime import datetime, timedelta, date
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import TradeHistory, TradingSession, Portfolio
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 🔥 리스크 관리 설정
|
||||
MAX_POSITION_SIZE_RATIO = 0.20 # 계좌의 20%까지만
|
||||
MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단
|
||||
MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단
|
||||
COMMISSION_RATE = 0.00015 # 수수료 0.015%
|
||||
TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만)
|
||||
TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26%
|
||||
|
||||
|
||||
@router.get("/risk-metrics")
|
||||
async def get_risk_metrics(
|
||||
days: int = Query(30, description="분석 기간 (일)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
📊 리스크 관리 메트릭 조회
|
||||
|
||||
- Maximum Drawdown (MDD): 최대 손실 깊이
|
||||
- 누적 손익 곡선
|
||||
- 현재 계좌 상태
|
||||
"""
|
||||
try:
|
||||
since_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# 매도 완료된 거래만 조회 (실현 손익)
|
||||
result = await db.execute(
|
||||
select(TradeHistory)
|
||||
.where(TradeHistory.created_at >= since_date)
|
||||
.where(TradeHistory.order_type == "SELL")
|
||||
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
|
||||
.order_by(TradeHistory.executed_at)
|
||||
)
|
||||
trades = result.scalars().all()
|
||||
|
||||
if not trades:
|
||||
return {
|
||||
"max_drawdown": 0,
|
||||
"max_drawdown_pct": 0,
|
||||
"current_equity": 0,
|
||||
"peak_equity": 0,
|
||||
"trades_count": 0,
|
||||
"message": "거래 내역이 없습니다"
|
||||
}
|
||||
|
||||
# 누적 손익 계산
|
||||
cumulative_pl = 0
|
||||
peak = 0
|
||||
max_drawdown = 0
|
||||
equity_curve = []
|
||||
drawdown_curve = []
|
||||
|
||||
for trade in trades:
|
||||
# 순손익 사용 (없으면 total_amount 사용)
|
||||
pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0
|
||||
cumulative_pl += pl
|
||||
equity_curve.append({
|
||||
"date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(),
|
||||
"equity": cumulative_pl
|
||||
})
|
||||
|
||||
# 최고점 갱신
|
||||
if cumulative_pl > peak:
|
||||
peak = cumulative_pl
|
||||
|
||||
# Drawdown 계산
|
||||
drawdown = peak - cumulative_pl
|
||||
drawdown_pct = (drawdown / peak * 100) if peak > 0 else 0
|
||||
drawdown_curve.append({
|
||||
"date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(),
|
||||
"drawdown": drawdown,
|
||||
"drawdown_pct": round(drawdown_pct, 2)
|
||||
})
|
||||
|
||||
if drawdown > max_drawdown:
|
||||
max_drawdown = drawdown
|
||||
|
||||
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0
|
||||
|
||||
# MDD 위험 레벨
|
||||
risk_level = "안전"
|
||||
if max_drawdown_pct > 30:
|
||||
risk_level = "위험 (시스템 중지 권장)"
|
||||
elif max_drawdown_pct > 20:
|
||||
risk_level = "경고 (포지션 축소 권장)"
|
||||
elif max_drawdown_pct > 10:
|
||||
risk_level = "주의"
|
||||
|
||||
return {
|
||||
"max_drawdown": round(float(max_drawdown), 2),
|
||||
"max_drawdown_pct": round(max_drawdown_pct, 2),
|
||||
"current_equity": round(cumulative_pl, 2),
|
||||
"peak_equity": round(peak, 2),
|
||||
"trades_count": len(trades),
|
||||
"risk_level": risk_level,
|
||||
"equity_curve": equity_curve[-50:], # 최근 50개만
|
||||
"drawdown_curve": drawdown_curve[-50:]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating risk metrics: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sharpe-ratio")
|
||||
async def get_sharpe_ratio(
|
||||
days: int = Query(30, description="분석 기간 (일)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
📈 Sharpe Ratio 계산
|
||||
|
||||
수익률 대비 변동성(리스크)을 측정
|
||||
- > 2.0: 매우 우수
|
||||
- > 1.0: 우수
|
||||
- < 1.0: 개선 필요
|
||||
"""
|
||||
try:
|
||||
since_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(TradeHistory)
|
||||
.where(TradeHistory.created_at >= since_date)
|
||||
.where(TradeHistory.order_type == "SELL")
|
||||
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
|
||||
.where(TradeHistory.profit_loss_rate.isnot(None))
|
||||
)
|
||||
trades = result.scalars().all()
|
||||
|
||||
if len(trades) < 2:
|
||||
return {
|
||||
"sharpe_ratio": None,
|
||||
"avg_return": 0,
|
||||
"std_return": 0,
|
||||
"trades_count": len(trades),
|
||||
"message": "거래가 부족합니다 (최소 2건 필요)"
|
||||
}
|
||||
|
||||
# 각 거래의 수익률 추출
|
||||
returns = [float(trade.profit_loss_rate) / 100 for trade in trades if trade.profit_loss_rate]
|
||||
|
||||
if not returns:
|
||||
return {
|
||||
"sharpe_ratio": None,
|
||||
"message": "수익률 데이터가 없습니다"
|
||||
}
|
||||
|
||||
avg_return = np.mean(returns)
|
||||
std_return = np.std(returns)
|
||||
|
||||
# Sharpe Ratio 계산 (연간화: 252 거래일 기준)
|
||||
# 무위험 수익률은 0으로 가정
|
||||
sharpe = (avg_return * np.sqrt(252)) / std_return if std_return > 0 else 0
|
||||
|
||||
# 평가
|
||||
if sharpe > 2:
|
||||
interpretation = "Excellent (매우 우수)"
|
||||
elif sharpe > 1:
|
||||
interpretation = "Good (우수)"
|
||||
elif sharpe > 0:
|
||||
interpretation = "Fair (보통)"
|
||||
else:
|
||||
interpretation = "Poor (개선 필요)"
|
||||
|
||||
return {
|
||||
"sharpe_ratio": round(sharpe, 3),
|
||||
"avg_return": round(avg_return * 100, 2), # %
|
||||
"std_return": round(std_return * 100, 2), # %
|
||||
"annualized_return": round(avg_return * 252 * 100, 2), # %
|
||||
"interpretation": interpretation,
|
||||
"trades_count": len(returns)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating Sharpe ratio: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/session/today")
|
||||
async def get_today_session(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
📅 오늘의 트레이딩 세션 조회
|
||||
|
||||
- 연속 손실/승리 추적
|
||||
- 일일 손익
|
||||
- 거래 중지 상태
|
||||
"""
|
||||
try:
|
||||
today = date.today()
|
||||
|
||||
result = await db.execute(
|
||||
select(TradingSession)
|
||||
.where(TradingSession.session_date == today)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
# 오늘 세션이 없으면 생성
|
||||
session = TradingSession(
|
||||
session_date=today,
|
||||
consecutive_losses=0,
|
||||
consecutive_wins=0,
|
||||
daily_profit_loss=0,
|
||||
daily_trades_count=0,
|
||||
is_paused=False
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return {
|
||||
"session_date": session.session_date.isoformat(),
|
||||
"consecutive_losses": session.consecutive_losses,
|
||||
"consecutive_wins": session.consecutive_wins,
|
||||
"daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0,
|
||||
"daily_trades_count": session.daily_trades_count,
|
||||
"is_paused": session.is_paused,
|
||||
"pause_reason": session.pause_reason,
|
||||
"starting_balance": float(session.starting_balance) if session.starting_balance else None,
|
||||
"ending_balance": float(session.ending_balance) if session.ending_balance else None,
|
||||
"peak_balance": float(session.peak_balance) if session.peak_balance else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting today's session: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/check-risk-limits")
|
||||
async def check_risk_limits(
|
||||
order_value: float = Query(..., description="주문 금액"),
|
||||
account_balance: float = Query(..., description="계좌 잔고"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
⚠️ 주문 전 리스크 제한 확인
|
||||
|
||||
- 포지션 사이즈 제한
|
||||
- 일일 손실 한도
|
||||
- 연속 손실 제한
|
||||
"""
|
||||
try:
|
||||
# 1. 오늘 세션 확인
|
||||
today = date.today()
|
||||
result = await db.execute(
|
||||
select(TradingSession)
|
||||
.where(TradingSession.session_date == today)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance)))
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
# 2. 거래 중지 상태 확인
|
||||
if session.is_paused:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": session.pause_reason or "거래가 중지되었습니다",
|
||||
"risk_level": "BLOCKED"
|
||||
}
|
||||
|
||||
# 3. 일일 손실 한도 확인
|
||||
daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0
|
||||
if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO:
|
||||
# 자동 중지
|
||||
session.is_paused = True
|
||||
session.pause_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": session.pause_reason,
|
||||
"risk_level": "CRITICAL",
|
||||
"daily_loss": float(session.daily_profit_loss),
|
||||
"daily_loss_ratio": round(daily_loss_ratio * 100, 2)
|
||||
}
|
||||
|
||||
# 4. 연속 손실 제한 확인
|
||||
if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES:
|
||||
session.is_paused = True
|
||||
session.pause_reason = f"연속 {session.consecutive_losses}회 손실 발생"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": session.pause_reason,
|
||||
"risk_level": "CRITICAL",
|
||||
"consecutive_losses": session.consecutive_losses
|
||||
}
|
||||
|
||||
# 5. 포지션 사이즈 제한 확인
|
||||
position_ratio = order_value / account_balance if account_balance > 0 else 0
|
||||
if position_ratio > MAX_POSITION_SIZE_RATIO:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)",
|
||||
"risk_level": "HIGH",
|
||||
"position_ratio": round(position_ratio * 100, 2),
|
||||
"max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2)
|
||||
}
|
||||
|
||||
# 모든 제한 통과
|
||||
warnings = []
|
||||
if session.consecutive_losses >= 2:
|
||||
warnings.append(f"연속 {session.consecutive_losses}회 손실 중 (주의)")
|
||||
if daily_loss_ratio > MAX_DAILY_LOSS_RATIO * 0.5:
|
||||
warnings.append(f"일일 손실 {daily_loss_ratio*100:.1f}% (주의)")
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"risk_level": "WARNING" if warnings else "OK",
|
||||
"warnings": warnings,
|
||||
"position_ratio": round(position_ratio * 100, 2),
|
||||
"daily_loss_ratio": round(daily_loss_ratio * 100, 2),
|
||||
"consecutive_losses": session.consecutive_losses
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking risk limits: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/update-session")
|
||||
async def update_trading_session(
|
||||
trade_result: str = Query(..., description="win 또는 loss"),
|
||||
profit_loss: float = Query(..., description="거래 손익"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
🔄 거래 결과에 따라 세션 업데이트
|
||||
|
||||
- 연속 손실/승리 카운터
|
||||
- 일일 손익 누적
|
||||
"""
|
||||
try:
|
||||
today = date.today()
|
||||
result = await db.execute(
|
||||
select(TradingSession)
|
||||
.where(TradingSession.session_date == today)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
session = TradingSession(session_date=today)
|
||||
db.add(session)
|
||||
|
||||
# 손익 누적
|
||||
current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0
|
||||
session.daily_profit_loss = Decimal(str(current_pl + profit_loss))
|
||||
session.daily_trades_count += 1
|
||||
|
||||
# 연속 손실/승리 업데이트
|
||||
if trade_result == "loss":
|
||||
session.consecutive_losses += 1
|
||||
session.consecutive_wins = 0
|
||||
elif trade_result == "win":
|
||||
session.consecutive_wins += 1
|
||||
session.consecutive_losses = 0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return {
|
||||
"session_date": session.session_date.isoformat(),
|
||||
"consecutive_losses": session.consecutive_losses,
|
||||
"consecutive_wins": session.consecutive_wins,
|
||||
"daily_profit_loss": float(session.daily_profit_loss),
|
||||
"daily_trades_count": session.daily_trades_count,
|
||||
"is_paused": session.is_paused
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating session: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/win-rate")
|
||||
async def get_win_rate(
|
||||
days: int = Query(30, description="분석 기간 (일)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
📊 승률 및 손익 통계
|
||||
"""
|
||||
try:
|
||||
since_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(TradeHistory)
|
||||
.where(TradeHistory.created_at >= since_date)
|
||||
.where(TradeHistory.order_type == "SELL")
|
||||
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
|
||||
)
|
||||
trades = result.scalars().all()
|
||||
|
||||
if not trades:
|
||||
return {
|
||||
"win_rate": 0,
|
||||
"total_trades": 0,
|
||||
"message": "거래 내역이 없습니다"
|
||||
}
|
||||
|
||||
wins = 0
|
||||
losses = 0
|
||||
total_profit = 0
|
||||
total_loss = 0
|
||||
|
||||
for trade in trades:
|
||||
pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0
|
||||
if pl > 0:
|
||||
wins += 1
|
||||
total_profit += pl
|
||||
elif pl < 0:
|
||||
losses += 1
|
||||
total_loss += abs(pl)
|
||||
|
||||
total_trades = wins + losses
|
||||
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0
|
||||
|
||||
# Profit Factor (총 이익 / 총 손실)
|
||||
profit_factor = (total_profit / total_loss) if total_loss > 0 else 0
|
||||
|
||||
# 평균 승리/손실
|
||||
avg_win = total_profit / wins if wins > 0 else 0
|
||||
avg_loss = total_loss / losses if losses > 0 else 0
|
||||
|
||||
# 손익비
|
||||
risk_reward = avg_win / avg_loss if avg_loss > 0 else 0
|
||||
|
||||
return {
|
||||
"win_rate": round(win_rate, 2),
|
||||
"total_trades": total_trades,
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"total_profit": round(total_profit, 2),
|
||||
"total_loss": round(total_loss, 2),
|
||||
"net_profit": round(total_profit - total_loss, 2),
|
||||
"profit_factor": round(profit_factor, 2),
|
||||
"avg_win": round(avg_win, 2),
|
||||
"avg_loss": round(avg_loss, 2),
|
||||
"risk_reward_ratio": round(risk_reward, 2)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating win rate: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
40
backend/app/api/endpoints/tensorflow_predictions.py
Normal file
40
backend/app/api/endpoints/tensorflow_predictions.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_predictor():
|
||||
try:
|
||||
from app.services.tensorflow_predictor import TensorFlowPredictor, TensorFlowNotAvailable
|
||||
try:
|
||||
return TensorFlowPredictor()
|
||||
except TensorFlowNotAvailable as e:
|
||||
raise HTTPException(status_code=503, detail=f"TensorFlow predictor unavailable: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"TensorFlow unavailable: {e}")
|
||||
|
||||
|
||||
@router.get("/tf-predictions")
|
||||
async def get_tensorflow_predictions(
|
||||
codes: str = Query("005930,000660,035420", description="Comma-separated stock codes"),
|
||||
top: int = Query(10, ge=1, le=50, description="Top N by expected return")
|
||||
):
|
||||
predictor = _get_predictor()
|
||||
stock_codes: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
|
||||
results = []
|
||||
for code in stock_codes:
|
||||
pred = await predictor.predict_stock(code)
|
||||
results.append(pred)
|
||||
# Filter out errors, sort by expected_return
|
||||
valid = [r for r in results if isinstance(r, dict) and "expected_return" in r]
|
||||
valid.sort(key=lambda x: x.get("expected_return", 0.0), reverse=True)
|
||||
return {
|
||||
"total": len(valid),
|
||||
"requested": stock_codes,
|
||||
"predictions": valid[:top],
|
||||
"errors": [r for r in results if "error" in r]
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
"""
|
||||
주문/체결 API 엔드포인트
|
||||
|
||||
- GET /: 거래 이력 조회 (손익 포함)
|
||||
- GET /stats/summary: 거래 통계
|
||||
- DELETE /pending: PENDING/FAILED 거래 정리
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import TradeHistory
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TradeHistoryResponse(BaseModel):
|
||||
@@ -22,11 +31,17 @@ class TradeHistoryResponse(BaseModel):
|
||||
order_quantity: int
|
||||
executed_price: Optional[Decimal]
|
||||
executed_quantity: Optional[int]
|
||||
total_amount: Optional[Decimal]
|
||||
status: str
|
||||
order_id: Optional[str]
|
||||
message: Optional[str]
|
||||
entry_reason: Optional[str]
|
||||
exit_reason: Optional[str]
|
||||
signals: Optional[List[str]]
|
||||
created_at: datetime
|
||||
executed_at: Optional[datetime]
|
||||
profit_loss: Optional[Decimal] = None
|
||||
profit_loss_rate: Optional[float] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -42,7 +57,7 @@ async def get_trade_history(
|
||||
days: int = 30,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""거래 이력 조회"""
|
||||
"""거래 이력 조회 (손익 포함)"""
|
||||
query = select(TradeHistory)
|
||||
|
||||
# 날짜 필터
|
||||
@@ -66,7 +81,64 @@ async def get_trade_history(
|
||||
|
||||
result = await db.execute(query)
|
||||
trades = result.scalars().all()
|
||||
return trades
|
||||
|
||||
# 손익 계산을 위해 매수/매도 매칭
|
||||
trade_responses = []
|
||||
buy_trades = {} # stock_code -> list of buy trades
|
||||
|
||||
for trade in trades:
|
||||
response = TradeHistoryResponse.model_validate(trade)
|
||||
|
||||
# 매도 시 손익 정보 설정
|
||||
if trade.order_type == "SELL" and trade.status == "EXECUTED":
|
||||
import re
|
||||
|
||||
# 1. DB에 저장된 값 먼저 사용
|
||||
if hasattr(trade, 'net_profit_loss') and trade.net_profit_loss is not None:
|
||||
response.profit_loss = trade.net_profit_loss
|
||||
logger.debug(f"✅ DB에서 손익 금액 로드: {response.profit_loss}")
|
||||
|
||||
if hasattr(trade, 'profit_loss_rate') and trade.profit_loss_rate is not None:
|
||||
response.profit_loss_rate = float(trade.profit_loss_rate)
|
||||
logger.debug(f"✅ DB에서 손익률 로드: {response.profit_loss_rate}%")
|
||||
|
||||
# 2. DB 값이 없으면 exit_reason/message에서 파싱 (이전 데이터 호환성)
|
||||
if response.profit_loss is None or response.profit_loss_rate is None:
|
||||
text_to_parse = trade.exit_reason or trade.message or ""
|
||||
logger.debug(f"🔍 손익 파싱 시도 - 종목: {trade.stock_name}, 텍스트: {text_to_parse[:100]}")
|
||||
|
||||
# 손익 금액 추출
|
||||
if response.profit_loss is None:
|
||||
profit_match = re.search(r'손익:\s*([+-]?[\d,]+)원', text_to_parse)
|
||||
if profit_match:
|
||||
profit_str = profit_match.group(1).replace(',', '')
|
||||
try:
|
||||
response.profit_loss = Decimal(profit_str)
|
||||
logger.info(f"✅ 파싱으로 손익 금액 추출: {response.profit_loss}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 손익 금액 변환 실패: {e}")
|
||||
|
||||
# 손익률 추출
|
||||
if response.profit_loss_rate is None:
|
||||
message_rate_match = re.search(r'손익률\s*([+-]?[\d.]+)%', trade.message or "")
|
||||
if message_rate_match:
|
||||
try:
|
||||
response.profit_loss_rate = float(message_rate_match.group(1))
|
||||
logger.info(f"✅ 파싱으로 손익률 추출 (message): {response.profit_loss_rate}%")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 손익률 변환 실패: {e}")
|
||||
else:
|
||||
rate_match = re.search(r'([+-]?[\d.]+)%', text_to_parse)
|
||||
if rate_match:
|
||||
try:
|
||||
response.profit_loss_rate = float(rate_match.group(1))
|
||||
logger.info(f"✅ 파싱으로 손익률 추출 (exit_reason): {response.profit_loss_rate}%")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 손익률 변환 실패: {e}")
|
||||
|
||||
trade_responses.append(response)
|
||||
|
||||
return trade_responses
|
||||
|
||||
|
||||
@router.get("/{trade_id}", response_model=TradeHistoryResponse)
|
||||
@@ -116,3 +188,23 @@ async def get_trade_stats(
|
||||
"success_rate": executed_trades / total_trades if total_trades > 0 else 0,
|
||||
"total_amount": total_amount
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pending")
|
||||
async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
|
||||
"""장 마감 후 PENDING과 FAILED 상태의 매수 시도 내역 정리"""
|
||||
result = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.status.in_(['PENDING', 'FAILED'])
|
||||
)
|
||||
)
|
||||
pending_trades = result.scalars().all()
|
||||
count = len(pending_trades)
|
||||
|
||||
for trade in pending_trades:
|
||||
await db.delete(trade)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
|
||||
|
||||
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}
|
||||
|
||||
Reference in New Issue
Block a user