수정본

This commit is contained in:
2026-02-22 17:23:20 +09:00
parent b147498c31
commit 32e2263f0a
109 changed files with 13979 additions and 4580 deletions

View File

@@ -1,5 +1,10 @@
"""
API 라우터 통합 모듈
- 각 엔드포인트 라우터를 prefix/tag와 함께 등록
"""
from fastapi import APIRouter
from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring
from app.api.endpoints import trading_rules, trades, portfolio, market_data, mcp, recommendations, monitoring, tensorflow_predictions, debug, risk_management, ml_ensemble, journals, day_trading
api_router = APIRouter()
@@ -10,3 +15,9 @@ api_router.include_router(market_data.router, prefix="/market-data", tags=["mark
api_router.include_router(mcp.router, prefix="/mcp", tags=["mcp"])
api_router.include_router(recommendations.router, prefix="/recommendations", tags=["recommendations"])
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])
api_router.include_router(tensorflow_predictions.router, prefix="/ml", tags=["ml"])
api_router.include_router(debug.router, prefix="/debug", tags=["debug"])
api_router.include_router(risk_management.router, prefix="/risk", tags=["risk_management"])
api_router.include_router(ml_ensemble.router, prefix="/ml-ensemble", tags=["ml_ensemble"])
api_router.include_router(journals.router, prefix="/journals", tags=["journals"])
api_router.include_router(day_trading.router, prefix="/daytrading", tags=["day_trading"])

View 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(),
}

View 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",
}
}

View 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,
}

View File

@@ -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)}")

View File

@@ -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 {

View 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],
},
}

View File

@@ -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

View 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))

View 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]
}

View File

@@ -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"}