수정본
This commit is contained in:
@@ -2,6 +2,9 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Python 출력 버퍼링 비활성화 (로그 즉시 출력)
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# 시스템 의존성 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Backend
|
||||
|
||||
## Batch daily analysis (DB cache)
|
||||
|
||||
Precompute technical indicators each morning and cache results for fast recommendations.
|
||||
|
||||
### Run once (today)
|
||||
|
||||
```zsh
|
||||
cd /Users/commylife/myproject/java/workspace/auto-stock/backend
|
||||
python3 batch_daily_analysis.py --test # quick: ~10 stocks
|
||||
# or full run
|
||||
python3 batch_daily_analysis.py
|
||||
```
|
||||
|
||||
### Schedule (cron on macOS)
|
||||
|
||||
```zsh
|
||||
crontab -e
|
||||
# weekdays 08:30 run (adjust path)
|
||||
30 8 * * 1-5 cd /Users/commylife/myproject/java/workspace/auto-stock/backend && /usr/bin/python3 batch_daily_analysis.py >> ../logs/batch.log 2>&1
|
||||
```
|
||||
|
||||
### Use DB cache in API
|
||||
|
||||
- Endpoint: `/api/v1/recommendations/rule-based`
|
||||
- Default: uses DB cache; if cache empty, it automatically falls back to realtime analysis.
|
||||
- Force realtime:
|
||||
|
||||
```text
|
||||
/api/v1/recommendations/rule-based?realtime=true
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
- API safety stats: `/api/v1/monitoring/api-stats`
|
||||
- Health: `/api/v1/monitoring/health`
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- No recommendations: ensure `stock_analysis` has rows for today (run batch).
|
||||
- Slow responses: rely on DB cache or reduce realtime stock count via query params.
|
||||
@@ -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"])
|
||||
|
||||
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"}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
애플리케이션 설정 모듈
|
||||
|
||||
- pydantic-settings 기반 .env 파일 로드
|
||||
- DB, Redis, KIS API, AI 프로바이더 등 전역 설정 관리
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
@@ -12,7 +18,7 @@ class Settings(BaseSettings):
|
||||
# Application
|
||||
APP_NAME: str = "Auto Stock Trading System"
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = True
|
||||
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
|
||||
SECRET_KEY: str = "change-this-secret-key"
|
||||
|
||||
# Server
|
||||
@@ -28,35 +34,78 @@ class Settings(BaseSettings):
|
||||
# RabbitMQ
|
||||
RABBITMQ_URL: str = "amqp://autostock:autostock_password@localhost:5672/"
|
||||
|
||||
# Korea Investment API
|
||||
# Korea Investment API - 모드 선택
|
||||
KIS_MODE: str = "virtual" # "virtual" (모의투자) 또는 "real" (실전투자)
|
||||
|
||||
# 모의투자 설정
|
||||
KIS_VIRTUAL_APP_KEY: str = ""
|
||||
KIS_VIRTUAL_APP_SECRET: str = ""
|
||||
KIS_VIRTUAL_ACCOUNT_NO: str = ""
|
||||
KIS_VIRTUAL_URL: str = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
# 실전투자 설정
|
||||
KIS_REAL_APP_KEY: str = ""
|
||||
KIS_REAL_APP_SECRET: str = ""
|
||||
KIS_REAL_ACCOUNT_NO: str = ""
|
||||
KIS_REAL_URL: str = "https://openapi.koreainvestment.com:9443"
|
||||
|
||||
# 실제 사용되는 KIS API 설정 (KIS_MODE에 따라 자동 결정)
|
||||
KOREA_INVESTMENT_APP_KEY: str = ""
|
||||
KOREA_INVESTMENT_APP_SECRET: str = ""
|
||||
KOREA_INVESTMENT_ACCOUNT_NUMBER: str = ""
|
||||
KOREA_INVESTMENT_API_BASE_URL: str = "https://openapi.koreainvestment.com:9443"
|
||||
|
||||
# Alias for backward compatibility with .env file
|
||||
|
||||
# 레거시 호환 (직접 지정 시 우선)
|
||||
KIS_APP_KEY: str = ""
|
||||
KIS_APP_SECRET: str = ""
|
||||
KIS_ACCOUNT_NO: str = ""
|
||||
KIS_BASE_URL: str = ""
|
||||
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# Use KIS_* values if KOREA_INVESTMENT_* are empty
|
||||
if not self.KOREA_INVESTMENT_APP_KEY and self.KIS_APP_KEY:
|
||||
self.KOREA_INVESTMENT_APP_KEY = self.KIS_APP_KEY
|
||||
if not self.KOREA_INVESTMENT_APP_SECRET and self.KIS_APP_SECRET:
|
||||
self.KOREA_INVESTMENT_APP_SECRET = self.KIS_APP_SECRET
|
||||
if not self.KOREA_INVESTMENT_ACCOUNT_NUMBER and self.KIS_ACCOUNT_NO:
|
||||
self.KOREA_INVESTMENT_ACCOUNT_NUMBER = self.KIS_ACCOUNT_NO
|
||||
if self.KIS_BASE_URL:
|
||||
self.KOREA_INVESTMENT_API_BASE_URL = self.KIS_BASE_URL
|
||||
|
||||
self._apply_kis_mode()
|
||||
|
||||
def _apply_kis_mode(self):
|
||||
"""KIS_MODE에 따라 모의/실전 API 설정을 자동 선택"""
|
||||
is_real = self.KIS_MODE.lower() == "real"
|
||||
|
||||
# 1순위: KIS_MODE에 따른 VIRTUAL/REAL 변수
|
||||
if is_real:
|
||||
mode_key = self.KIS_REAL_APP_KEY
|
||||
mode_secret = self.KIS_REAL_APP_SECRET
|
||||
mode_account = self.KIS_REAL_ACCOUNT_NO
|
||||
mode_url = self.KIS_REAL_URL
|
||||
else:
|
||||
mode_key = self.KIS_VIRTUAL_APP_KEY
|
||||
mode_secret = self.KIS_VIRTUAL_APP_SECRET
|
||||
mode_account = self.KIS_VIRTUAL_ACCOUNT_NO
|
||||
mode_url = self.KIS_VIRTUAL_URL
|
||||
|
||||
# 2순위: 레거시 KIS_APP_KEY 등 (직접 지정된 경우)
|
||||
fallback_key = self.KIS_APP_KEY
|
||||
fallback_secret = self.KIS_APP_SECRET
|
||||
fallback_account = self.KIS_ACCOUNT_NO
|
||||
fallback_url = self.KIS_BASE_URL
|
||||
|
||||
# 최종 적용 (mode > legacy > KOREA_INVESTMENT_ 기존값)
|
||||
self.KOREA_INVESTMENT_APP_KEY = mode_key or fallback_key or self.KOREA_INVESTMENT_APP_KEY
|
||||
self.KOREA_INVESTMENT_APP_SECRET = mode_secret or fallback_secret or self.KOREA_INVESTMENT_APP_SECRET
|
||||
self.KOREA_INVESTMENT_ACCOUNT_NUMBER = mode_account or fallback_account or self.KOREA_INVESTMENT_ACCOUNT_NUMBER
|
||||
self.KOREA_INVESTMENT_API_BASE_URL = mode_url or fallback_url or self.KOREA_INVESTMENT_API_BASE_URL
|
||||
|
||||
@property
|
||||
def is_virtual_trading(self) -> bool:
|
||||
"""모의투자 모드 여부"""
|
||||
return self.KIS_MODE.lower() != "real"
|
||||
|
||||
# Claude AI
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
|
||||
# MCP Server
|
||||
MCP_SERVER_URL: str = "http://localhost:8001"
|
||||
# MCP AI 검증 (자동매매 전 AI 최종 판단)
|
||||
MCP_VALIDATION_ENABLED: bool = False # 기본 OFF (.env에서 True로 활성화)
|
||||
MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI 신뢰도 최소값 (0.0~1.0)
|
||||
|
||||
# News API
|
||||
NEWS_API_KEY: str = ""
|
||||
@@ -64,11 +113,78 @@ class Settings(BaseSettings):
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Trading Settings
|
||||
MAX_POSITION_SIZE: float = 0.1 # 10% of portfolio
|
||||
MAX_DAILY_TRADES: int = 10
|
||||
STOP_LOSS_PERCENT: float = -5.0
|
||||
TAKE_PROFIT_PERCENT: float = 15.0
|
||||
# Trading Settings (CLAUDE.md 섹션 6 기반)
|
||||
MAX_POSITION_SIZE: float = 0.04 # 1종목당 최대 3-4% (섹션 6.1)
|
||||
MAX_DAILY_TRADES: int = 5
|
||||
STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2)
|
||||
TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값
|
||||
AUTO_TRADE_ENABLED: bool = True
|
||||
|
||||
# 포지션 규칙 (섹션 6.1)
|
||||
MAX_HOLDINGS_COUNT: int = 20 # 동시 보유 최대 20종목
|
||||
LEVERAGE_ETF_MAX_RATIO: float = 0.20 # 레버리지/인버스 ETF 20% 이내
|
||||
|
||||
# ATR 기반 손절/트레일링 (섹션 6.2)
|
||||
STOP_LOSS_HIGH_VOL: float = -10.0 # 고변동 종목 손절 -10%
|
||||
ATR_STOP_MULTIPLIER: float = 2.5 # 2.5 × ATR 손절
|
||||
ATR_TRAILING_MULTIPLIER: float = 1.5 # 1.5 × ATR 트레일링
|
||||
|
||||
# MDD 관리 (섹션 6.3)
|
||||
MDD_PAUSE_THRESHOLD: float = -10.0 # -10% MDD: 신규진입 중단 + 30% 축소
|
||||
MDD_FORCE_THRESHOLD: float = -20.0 # -20% MDD: 50% 강제 축소
|
||||
|
||||
# 가격 필터 (섹션 6.4)
|
||||
MIN_PRICE_KRW: int = 5000 # 국내 5,000원 이하 매수 금지
|
||||
MIN_PRICE_USD: float = 4.0 # 미국 $4 이하 매수 금지
|
||||
|
||||
# 연속 손실 관리 (섹션 7.4)
|
||||
CONSECUTIVE_LOSS_BAN_COUNT: int = 3 # 3회 연속 손실
|
||||
CONSECUTIVE_LOSS_BAN_DAYS: int = 30 # 30거래일 진입 금지
|
||||
|
||||
# 평균회귀 전략 자본 배분 (섹션 7.5)
|
||||
MEAN_REVERSION_MAX_CAPITAL: float = 0.30 # 전체 자본의 30% 이내
|
||||
|
||||
# Trend filter (MA-based)
|
||||
TREND_FILTER_ENABLED: bool = False
|
||||
TREND_MA_SHORT: int = 20
|
||||
TREND_MA_LONG: int = 60
|
||||
TREND_TOLERANCE_PCT: float = 0.005
|
||||
|
||||
# 급등 과열 필터 (고점 추격 매수 방지)
|
||||
OVERHEAT_FILTER_ENABLED: bool = True
|
||||
OVERHEAT_3DAY_MAX: float = 15.0
|
||||
OVERHEAT_5DAY_MAX: float = 20.0
|
||||
OVERHEAT_10DAY_MAX: float = 30.0
|
||||
OVERHEAT_DISPARITY_MAX: float = 108.0
|
||||
|
||||
# 하락장 방어 필터
|
||||
BEAR_MARKET_FILTER_ENABLED: bool = True
|
||||
BEAR_MARKET_MIN_SCORE: int = 5
|
||||
BEAR_MARKET_MAX_TRADES: int = 3
|
||||
BEAR_MARKET_POSITION_RATIO: float = 0.5
|
||||
|
||||
# 데이트레이딩 설정 (CLAUDE.md 섹션 8.2)
|
||||
DAY_TRADE_ENABLED: bool = True # 데이트레이딩 활성화
|
||||
DAY_TRADE_MAX_POSITIONS: int = 3 # 동시 최대 종목 수
|
||||
DAY_TRADE_CAPITAL_RATIO: float = 0.25 # 전체 자본 대비 최대 25%
|
||||
DAY_TRADE_PER_STOCK_RATIO: float = 0.10 # 종목당 최대 10%
|
||||
DAY_TRADE_STOP_LOSS_PCT: float = 0.02 # 종목 손절 -2%
|
||||
DAY_TRADE_DAILY_LOSS_LIMIT: float = -0.03 # 일일 손실 한도 -3%
|
||||
DAY_TRADE_EOD_FLAT_HOUR: int = 15 # EOD 강제 청산 시 (15시)
|
||||
DAY_TRADE_EOD_FLAT_MIN: int = 20 # EOD 강제 청산 분 (20분)
|
||||
DAY_TRADE_ORB_VOL_MULT: float = 1.5 # ORB 돌파 거래량 배수
|
||||
DAY_TRADE_TRAILING_TRIGGER: float = 0.015 # 트레일링 발동 수익률
|
||||
|
||||
# ML / TensorFlow integration toggle
|
||||
ML_TF_ENABLED: bool = True # ML 모델 기반 보조지표 사용
|
||||
|
||||
# ML Ensemble (LightGBM + LSTM + RF)
|
||||
ML_ENSEMBLE_ENABLED: bool = True
|
||||
ML_ENSEMBLE_MODEL_DIR: str = "models/ensemble"
|
||||
ML_ENSEMBLE_RULE_WEIGHT: float = 0.3 # 룰베이스 스코어 비중
|
||||
ML_ENSEMBLE_ML_WEIGHT: float = 0.7 # ML 스코어 비중
|
||||
ML_ENSEMBLE_BUY_THRESHOLD: int = 60 # 매수 최소 앙상블 스코어 (0~100)
|
||||
ML_ENSEMBLE_CONFIDENCE_MIN: float = 0.5 # 최소 신뢰도
|
||||
|
||||
# API Rate Limiting & Retry Settings (모의투자 API 안전 운영)
|
||||
API_MAX_RETRIES: int = 3 # 최대 재시도 횟수
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""
|
||||
데이터베이스 연결 모듈
|
||||
|
||||
- SQLAlchemy async 엔진 및 세션 팩토리 (PostgreSQL + asyncpg)
|
||||
- Base 모델 선언, get_db 의존성 제공
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -10,14 +16,19 @@ if DATABASE_URL.startswith("postgresql://"):
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
echo=False, # SQL 로그 비활성화 (성능 개선)
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_size=20, # 증가
|
||||
max_overflow=30, # 증가
|
||||
pool_recycle=3600, # 1시간마다 연결 재생성
|
||||
pool_timeout=30, # 타임아웃 설정
|
||||
connect_args={
|
||||
"timeout": 60,
|
||||
"command_timeout": 60,
|
||||
"server_settings": {
|
||||
"jit": "off" # JIT 비활성화로 성능 개선
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -25,14 +36,20 @@ AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""DB 세션 생성 및 관리 (안전한 종료 보장)"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
13
backend/app/core/json_helpers.py
Normal file
13
backend/app/core/json_helpers.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""공용 JSON 직렬화 헬퍼 (CLAUDE.md 코딩 규칙: json.dumps 대신 _dumps() 사용)"""
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def _dumps(obj, **kwargs) -> str:
|
||||
"""토큰 절약 모드에 따라 compact/pretty JSON 직렬화"""
|
||||
compact = os.getenv("JSON_COMPACT", "false").lower() == "true"
|
||||
token_save = os.getenv("TOKEN_SAVE_MODE", "false").lower() == "true"
|
||||
|
||||
if compact or token_save:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(',', ':'), **kwargs)
|
||||
return json.dumps(obj, ensure_ascii=False, indent=2, **kwargs)
|
||||
59
backend/app/core/market_schedule.py
Normal file
59
backend/app/core/market_schedule.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
한국 주식시장 운영시간 관리
|
||||
|
||||
- KST 기준 장 시작/종료 시간 판별
|
||||
- 공휴일, 주말 휴장 체크
|
||||
"""
|
||||
from datetime import datetime, time
|
||||
try:
|
||||
import pytz
|
||||
KST = pytz.timezone('Asia/Seoul')
|
||||
except ImportError:
|
||||
# pytz가 없으면 기본 timezone 사용 (환경변수에 의존)
|
||||
from datetime import timezone, timedelta
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
def is_market_open() -> bool:
|
||||
"""
|
||||
현재 한국 증시 정규장 운영 시간인지 확인
|
||||
(평일 09:00 ~ 15:30, 공휴일 제외 로직은 아직 미포함)
|
||||
"""
|
||||
now = datetime.now(KST)
|
||||
|
||||
# 1. 주말 체크 (월=0, ..., 일=6)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
|
||||
# 2. 시간 체크 (09:00 ~ 15:30)
|
||||
current_time = now.time()
|
||||
market_start = time(9, 0)
|
||||
market_end = time(15, 30)
|
||||
|
||||
return market_start <= current_time <= market_end
|
||||
|
||||
|
||||
def is_opening_range_period() -> bool:
|
||||
"""Opening Range 수집 구간 (09:00~09:29)"""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
t = now.time()
|
||||
return time(9, 0) <= t < time(9, 30)
|
||||
|
||||
|
||||
def is_day_trade_entry_window() -> bool:
|
||||
"""데이트레이딩 신규 진입 가능 시간 (09:30~14:59)"""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
t = now.time()
|
||||
return time(9, 30) <= t < time(15, 0)
|
||||
|
||||
|
||||
def is_eod_flat_time() -> bool:
|
||||
"""EOD 강제 청산 구간 (15:20~15:30)"""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
t = now.time()
|
||||
return time(15, 20) <= t <= time(15, 30)
|
||||
@@ -1,14 +1,26 @@
|
||||
"""
|
||||
FastAPI 애플리케이션 진입점
|
||||
|
||||
- CORS, 미들웨어 설정
|
||||
- WebSocket 실시간 시세 브로드캐스트
|
||||
- 라우터 등록 및 startup/shutdown 이벤트 관리
|
||||
"""
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import contextlib
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api import api_router
|
||||
from app.services.websocket_manager import websocket_manager
|
||||
from app.core.database import engine, Base
|
||||
from app.core.database import engine, Base, AsyncSessionLocal
|
||||
from app.models import models # 모델 임포트
|
||||
from app.core.market_schedule import is_market_open
|
||||
from sqlalchemy import text
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
@@ -19,17 +31,213 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def auto_trade_scheduler():
|
||||
"""1분 간격 자동매매 스케줄러.
|
||||
실시간 분석을 수행하고, 보유 종목 손절/익절을 체크하며, 자동매매를 실행한다.
|
||||
"""
|
||||
# 지연 시작으로 앱 부팅 안정화
|
||||
await asyncio.sleep(2)
|
||||
from app.api.endpoints.recommendations import (
|
||||
get_rule_based_recommendations_realtime,
|
||||
execute_auto_trade_background,
|
||||
)
|
||||
from app.api.endpoints.portfolio import check_and_execute_exit_trades
|
||||
|
||||
logger.info("⏱️ Auto-trade scheduler started (every 60s)")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
# 장 운영 시간 체크
|
||||
if not is_market_open():
|
||||
logger.info("💤 Market is closed. Skipping auto-trade cycle. Next check in 30 minutes.")
|
||||
# 장 종료 시에는 30분(1800초) 대기하여 리소스 절약
|
||||
await asyncio.sleep(1800)
|
||||
continue
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. 보유 종목 손절/익절 체크 (먼저 실행)
|
||||
logger.info("💰 Checking portfolio for stop-loss/take-profit...")
|
||||
try:
|
||||
await check_and_execute_exit_trades(session)
|
||||
except Exception as e:
|
||||
logger.error(f"Exit trade check error: {e}", exc_info=True)
|
||||
|
||||
# 2. 자동매매가 활성화된 경우에만 신규 매수 진행
|
||||
if settings.AUTO_TRADE_ENABLED:
|
||||
logger.info("🔄 Starting real-time analysis cycle...")
|
||||
rec = await get_rule_based_recommendations_realtime()
|
||||
|
||||
if rec and rec.get("recommendations_by_rule"):
|
||||
logger.info(f"📊 Found recommendations, executing auto-trade")
|
||||
await execute_auto_trade_background(rec, session)
|
||||
else:
|
||||
logger.info("⏭️ Auto-trade skipped: no recommendations matched rules")
|
||||
else:
|
||||
logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Auto-trade scheduler error: {e}", exc_info=True)
|
||||
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("⏹️ Auto-trade scheduler stopped")
|
||||
raise
|
||||
|
||||
|
||||
async def day_trade_scheduler():
|
||||
"""30초 간격 데이트레이딩 스케줄러.
|
||||
|
||||
- Opening Range 수집 (09:00~09:30)
|
||||
- 진입 신호 스캔 + 주문 실행 (09:30~15:00)
|
||||
- EOD 강제 청산 (15:20~15:30)
|
||||
- 장 외 시간: 30분 슬립
|
||||
"""
|
||||
await asyncio.sleep(5) # 부팅 안정화 대기
|
||||
from app.api.endpoints.day_trading import run_day_trading_cycle
|
||||
|
||||
logger.info("📈 Day-trade scheduler started (every 30s)")
|
||||
try:
|
||||
while True:
|
||||
if not is_market_open():
|
||||
await asyncio.sleep(1800)
|
||||
continue
|
||||
if not settings.DAY_TRADE_ENABLED:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
try:
|
||||
result = await run_day_trading_cycle()
|
||||
phase = result.get("phase", "")
|
||||
if result.get("entries"):
|
||||
logger.info(f"📈 데이트레이딩 진입: {result['entries']}")
|
||||
if result.get("exits"):
|
||||
logger.info(f"📉 데이트레이딩 청산: {result['exits']}")
|
||||
if phase not in ("market_closed", "outside_window"):
|
||||
logger.debug(f"Day-trade cycle [{phase}]: {result.get('time')}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Day-trade scheduler error: {e}", exc_info=True)
|
||||
await asyncio.sleep(30)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("⏹️ Day-trade scheduler stopped")
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""애플리케이션 시작/종료 시 실행될 코드"""
|
||||
logger.info("Starting Auto Stock Trading System...")
|
||||
|
||||
kis_mode = settings.KIS_MODE.upper()
|
||||
logger.info(f"KIS API Mode: {'모의투자 (VIRTUAL)' if settings.is_virtual_trading else '실전투자 (REAL)'}")
|
||||
logger.info(f"KIS Base URL: {settings.KOREA_INVESTMENT_API_BASE_URL}")
|
||||
logger.info(f"KIS Account: {settings.KOREA_INVESTMENT_ACCOUNT_NUMBER}")
|
||||
|
||||
# DB 테이블 자동 생성
|
||||
try:
|
||||
logger.info(f"Connecting to database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'unknown'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("✅ Database tables created successfully")
|
||||
# Ensure trade_history columns exist (self-heal without external migration)
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE trade_history
|
||||
ADD COLUMN IF NOT EXISTS total_amount NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS entry_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exit_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS signals JSONB,
|
||||
ADD COLUMN IF NOT EXISTS entry_trade_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS slippage NUMERIC(10, 2),
|
||||
ADD COLUMN IF NOT EXISTS commission NUMERIC(10, 2),
|
||||
ADD COLUMN IF NOT EXISTS net_profit_loss NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS profit_loss_rate NUMERIC(10, 4);
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
UPDATE trade_history
|
||||
SET total_amount = executed_price * executed_quantity
|
||||
WHERE total_amount IS NULL AND executed_price IS NOT NULL AND executed_quantity IS NOT NULL;
|
||||
"""))
|
||||
# stock_analysis에 ATR / ADX / ML 컬럼 추가 (CLAUDE.md 섹션 6.2)
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE stock_analysis
|
||||
ADD COLUMN IF NOT EXISTS atr_14 NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS adx_14 NUMERIC(10, 2),
|
||||
ADD COLUMN IF NOT EXISTS ml_score INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS ml_confidence NUMERIC(5, 4),
|
||||
ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10);
|
||||
"""))
|
||||
# active_trades에 분할 청산 컬럼 추가 (CLAUDE.md 섹션 7.3)
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE active_trades
|
||||
ADD COLUMN IF NOT EXISTS remaining_pct NUMERIC(5, 2) DEFAULT 1.0,
|
||||
ADD COLUMN IF NOT EXISTS highest_since_entry NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS strategy_name VARCHAR(100);
|
||||
"""))
|
||||
# Risk Management 테이블 생성
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS trading_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_date DATE UNIQUE NOT NULL,
|
||||
consecutive_losses INTEGER DEFAULT 0,
|
||||
daily_profit_loss NUMERIC(15, 2) DEFAULT 0,
|
||||
is_paused BOOLEAN DEFAULT FALSE,
|
||||
pause_reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""))
|
||||
# 종목별 연속 손실 ban 영속화 테이블 (CLAUDE.md 섹션 7.4)
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS stock_bans (
|
||||
stock_code VARCHAR(20) PRIMARY KEY,
|
||||
consecutive_losses INTEGER DEFAULT 0 NOT NULL,
|
||||
ban_until TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""))
|
||||
logger.info("✅ Database tables created successfully (and columns verified)")
|
||||
|
||||
# 연속 손실 ban 상태 복원 (재시작 후에도 유지 — CLAUDE.md 섹션 7.4)
|
||||
try:
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
async with AsyncSessionLocal() as session:
|
||||
await loss_tracker.load_from_db(session)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ loss_tracker 초기화 실패 (non-critical): {e}")
|
||||
|
||||
# 테스트 데이터 및 오래된 데이터 자동 정리
|
||||
try:
|
||||
from app.models.models import StockAnalysis, TradeHistory, Portfolio
|
||||
from sqlalchemy import delete
|
||||
from datetime import date, timedelta
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 오래된 stock_analysis 삭제 (7일 이전)
|
||||
cutoff_date = date.today() - timedelta(days=7)
|
||||
result = await session.execute(
|
||||
delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🗑️ Deleted {result.rowcount} old stock_analysis records")
|
||||
|
||||
# PENDING/FAILED 오래된 거래 삭제 (1일 이전)
|
||||
cutoff_datetime = date.today() - timedelta(days=1)
|
||||
result = await session.execute(
|
||||
delete(TradeHistory).where(
|
||||
TradeHistory.status.in_(['PENDING', 'FAILED']),
|
||||
TradeHistory.created_at < cutoff_datetime
|
||||
)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🗑️ Deleted {result.rowcount} old PENDING/FAILED trades")
|
||||
|
||||
# 수량 0인 포트폴리오 삭제
|
||||
result = await session.execute(
|
||||
delete(Portfolio).where(Portfolio.quantity <= 0)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🗑️ Deleted {result.rowcount} empty portfolio records")
|
||||
|
||||
await session.commit()
|
||||
logger.info("✅ Test data cleanup completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Test data cleanup failed (non-critical): {e}")
|
||||
|
||||
except ConnectionRefusedError as e:
|
||||
logger.error(f"❌ Database connection refused: {e}")
|
||||
logger.error("Please check if PostgreSQL server is running and accessible")
|
||||
@@ -40,9 +248,24 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
# 스케줄러 시작
|
||||
app.state.auto_trade_task = asyncio.create_task(auto_trade_scheduler())
|
||||
app.state.day_trade_task = asyncio.create_task(day_trade_scheduler())
|
||||
|
||||
yield
|
||||
|
||||
|
||||
# 종료 시 정리 작업
|
||||
# 스케줄러 중단
|
||||
for task_attr in ("auto_trade_task", "day_trade_task"):
|
||||
try:
|
||||
task = getattr(app.state, task_attr, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop {task_attr} gracefully: {e}")
|
||||
|
||||
logger.info("Shutting down Auto Stock Trading System...")
|
||||
await engine.dispose()
|
||||
|
||||
@@ -105,7 +328,16 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
# 메시지 처리
|
||||
# ping/pong 하트비트 처리
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
if msg.get("type") == "ping":
|
||||
await websocket_manager.send_personal_message(
|
||||
json.dumps({"type": "pong"}), client_id
|
||||
)
|
||||
continue
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
await websocket_manager.send_personal_message(
|
||||
f"Echo: {data}", client_id
|
||||
)
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
from datetime import datetime
|
||||
"""
|
||||
SQLAlchemy ORM 모델 정의
|
||||
|
||||
- StockDailyPrice: 일별 주가 데이터
|
||||
- Portfolio: 포트폴리오 보유 종목
|
||||
- TradeHistory: 주문/체결 이력
|
||||
- TradingRule: 매매 규칙 설정
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, DateTime, Text, ARRAY
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, Numeric, Boolean, DateTime, Text, ARRAY, Date, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StockDailyPrice(Base):
|
||||
"""KRX 일봉 히스토리컬 데이터 (pykrx 수집)"""
|
||||
__tablename__ = "stock_daily_prices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('stock_code', 'trade_date', name='uq_stock_daily'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100))
|
||||
trade_date = Column(Date, nullable=False, index=True)
|
||||
open_price = Column(Integer)
|
||||
high_price = Column(Integer)
|
||||
low_price = Column(Integer)
|
||||
close_price = Column(Integer)
|
||||
volume = Column(BigInteger)
|
||||
change_rate = Column(Numeric(10, 4))
|
||||
market_cap = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class TradingSession(Base):
|
||||
"""🔥 일일 트레이딩 세션 관리 (리스크 관리)"""
|
||||
__tablename__ = "trading_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_date = Column(Date, default=date.today, unique=True, index=True)
|
||||
|
||||
# 연속 손실 추적
|
||||
consecutive_losses = Column(Integer, default=0)
|
||||
consecutive_wins = Column(Integer, default=0)
|
||||
|
||||
# 일일 손익
|
||||
daily_profit_loss = Column(Numeric(15, 2), default=0)
|
||||
daily_trades_count = Column(Integer, default=0)
|
||||
|
||||
# 리스크 제한 상태
|
||||
is_paused = Column(Boolean, default=False) # 3연패 또는 일일 손실 한도 도달 시 True
|
||||
pause_reason = Column(String(200), nullable=True)
|
||||
|
||||
# 계좌 정보
|
||||
starting_balance = Column(Numeric(15, 2))
|
||||
ending_balance = Column(Numeric(15, 2))
|
||||
peak_balance = Column(Numeric(15, 2)) # 당일 최고 잔고
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class TradingRule(Base):
|
||||
__tablename__ = "trading_rules"
|
||||
|
||||
@@ -32,9 +88,21 @@ class TradeHistory(Base):
|
||||
order_quantity = Column(Integer, nullable=False)
|
||||
executed_price = Column(Numeric(15, 2))
|
||||
executed_quantity = Column(Integer)
|
||||
total_amount = Column(Numeric(15, 2)) # 총 거래 금액
|
||||
status = Column(String(50), nullable=False)
|
||||
order_id = Column(String(100))
|
||||
message = Column(Text)
|
||||
entry_reason = Column(Text) # 진입 이유 (매수 신호 상세)
|
||||
exit_reason = Column(Text) # 매도 이유 (매도 신호 상세)
|
||||
signals = Column(JSONB) # 기술적 신호 목록
|
||||
|
||||
# 🔥 리스크 관리 필드 추가
|
||||
entry_trade_id = Column(Integer, nullable=True) # 매도 시 얰결된 매수 거래 ID
|
||||
slippage = Column(Numeric(15, 2), nullable=True) # 슬리피지 (체결가 - 주문가)
|
||||
commission = Column(Numeric(15, 2), nullable=True) # 수수료
|
||||
net_profit_loss = Column(Numeric(15, 2), nullable=True) # 수수료 차감 후 순손익
|
||||
profit_loss_rate = Column(Numeric(10, 4), nullable=True) # 수익률 (%)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
executed_at = Column(DateTime)
|
||||
|
||||
@@ -135,7 +203,107 @@ class StockAnalysis(Base):
|
||||
# 분석 결과
|
||||
signals = Column(JSONB) # ["RSI 과매도", "골든크로스" 등]
|
||||
score = Column(Integer, default=0)
|
||||
|
||||
|
||||
# ATR (섹션 6.2 ATR 기반 손절)
|
||||
atr_14 = Column(Numeric(15, 2), nullable=True)
|
||||
|
||||
# ADX (추세 강도 — 평균회귀 필터용)
|
||||
adx_14 = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# ML 앙상블 스코어
|
||||
ml_score = Column(Integer, nullable=True) # 앙상블 스코어 (0~100)
|
||||
ml_confidence = Column(Numeric(5, 4), nullable=True) # 신뢰도 (0~1)
|
||||
ml_signal = Column(String(10), nullable=True) # BUY/HOLD/SELL
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class ActiveTrade(Base):
|
||||
"""현재 활성화된(보유 중인) 거래를 추적하기 위한 모델"""
|
||||
__tablename__ = "active_trades"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False)
|
||||
stock_name = Column(String(100))
|
||||
quantity = Column(Integer, nullable=False)
|
||||
entry_price = Column(Numeric(15, 2), nullable=False)
|
||||
current_price = Column(Numeric(15, 2))
|
||||
profit_loss = Column(Numeric(15, 2))
|
||||
profit_loss_rate = Column(Numeric(10, 4))
|
||||
stop_loss = Column(Numeric(15, 2))
|
||||
take_profit = Column(Numeric(15, 2))
|
||||
order_id = Column(String(100))
|
||||
entry_reason = Column(Text)
|
||||
status = Column(String(20), default="active") # active, closed
|
||||
# 분할 청산 지원 (섹션 6.2, 7.3)
|
||||
remaining_pct = Column(Numeric(5, 2), default=1.0) # 잔여 비율 (1.0=전량, 0.5=절반 청산 후)
|
||||
highest_since_entry = Column(Numeric(15, 2), nullable=True) # 진입 후 최고가 (트레일링 스탑용)
|
||||
strategy_name = Column(String(100), nullable=True) # 전략명 (MEAN_REVERSION 등)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class TradeJournal(Base):
|
||||
__tablename__ = "trade_journals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
trade_id = Column(Integer) # ForeignKey("trade_history.id") - models.py 내 순환 참조 방지 위해 정수로 정의
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100))
|
||||
journal_date = Column(DateTime, default=datetime.utcnow)
|
||||
content = Column(Text)
|
||||
strategy_name = Column(String(100))
|
||||
mood = Column(String(50))
|
||||
tags = Column(JSONB)
|
||||
screenshot_url = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class StockBan(Base):
|
||||
"""종목별 연속 손실 금지 기록 (CLAUDE.md 섹션 7.4)
|
||||
|
||||
재시작 후에도 ban 상태 유지를 위해 DB에 영속화.
|
||||
"""
|
||||
__tablename__ = "stock_bans"
|
||||
|
||||
stock_code = Column(String(20), primary_key=True)
|
||||
consecutive_losses = Column(Integer, default=0, nullable=False)
|
||||
ban_until = Column(DateTime, nullable=True) # None이면 금지 아님
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class WatchlistStock(Base):
|
||||
"""분석종목 — 장중 미니배치가 관리하는 활성 관심 종목.
|
||||
|
||||
아침 배치(batch_daily_analysis)가 score 상위 50개로 초기화하고,
|
||||
장중 미니배치(batch_intraday_analysis)가 10분마다 진입/제거를 관리한다.
|
||||
"""
|
||||
__tablename__ = "watchlist_stocks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100), nullable=False)
|
||||
added_at = Column(DateTime, nullable=False)
|
||||
added_reason = Column(String(50), nullable=False, default="morning_batch")
|
||||
score_at_add = Column(Integer, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
removed_at = Column(DateTime, nullable=True)
|
||||
removed_reason = Column(String(50), nullable=True)
|
||||
|
||||
|
||||
class WatchlistHistory(Base):
|
||||
"""분석종목 스코어 히스토리 — 10분마다 적재되는 시계열 데이터."""
|
||||
__tablename__ = "watchlist_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100), nullable=False)
|
||||
recorded_at = Column(DateTime, nullable=False, index=True)
|
||||
score = Column(Integer, nullable=True)
|
||||
current_price = Column(Numeric(15, 2), nullable=True)
|
||||
change_rate = Column(Numeric(10, 4), nullable=True)
|
||||
rsi = Column(Numeric(10, 4), nullable=True)
|
||||
signals = Column(JSONB, nullable=True)
|
||||
|
||||
472
backend/app/services/day_trading_strategy.py
Normal file
472
backend/app/services/day_trading_strategy.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
데이트레이딩 전략 (CLAUDE.md 섹션 8.2 확장)
|
||||
|
||||
전략 3종:
|
||||
1. ORB — Opening Range Breakout (09:00~09:30 고점 돌파)
|
||||
2. VWAP — VWAP 지지 + 과매도 반등
|
||||
3. RSI_BB — 5분봉 RSI+볼린저 과매도 반등
|
||||
|
||||
공통 규칙:
|
||||
- EOD 강제 청산: 15:20 이후 전량 시장가 매도
|
||||
- 일일 손실 한도: 전체 자본 -3% 초과 시 신규 진입 중단
|
||||
- 동시 최대 3종목, 종목당 최대 10% 자본
|
||||
- 전체 데이트레이딩 자본: 총자산의 최대 25% (CLAUDE.md 8.2)
|
||||
- 수수료+슬리피지 포함 기준: 0.5% (매수 0.015% + 매도 0.3% + 슬리피지 0.2%)
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 장 시간 상수 (KST) ──────────────────────────────────────────────────────
|
||||
MARKET_OPEN = time(9, 0)
|
||||
ORB_END = time(9, 30) # Opening Range 집계 종료
|
||||
ENTRY_CUTOFF = time(15, 0) # 신규 진입 마감 (이후는 청산만)
|
||||
EOD_FLAT = time(15, 20) # EOD 강제 청산 시작
|
||||
MARKET_CLOSE = time(15, 30)
|
||||
|
||||
# ── 리스크 파라미터 ───────────────────────────────────────────────────────────
|
||||
MAX_CAPITAL_RATIO = 0.10 # 종목당 최대 10%
|
||||
MAX_DAY_TRADE_RATIO = 0.25 # 전체 데이트레이딩 최대 25%
|
||||
MAX_POSITIONS = 3 # 동시 최대 3종목
|
||||
DAILY_LOSS_LIMIT = -0.03 # 일일 손실 한도 -3%
|
||||
STOP_LOSS_PCT = 0.02 # 종목 손절 -2%
|
||||
TRAILING_TRIGGER = 0.015 # 트레일링 발동 +1.5%
|
||||
TRAILING_ATR_MULT = 1.0 # 트레일링 ATR 배수
|
||||
ORB_BREAKOUT_MARGIN = 0.002 # ORB 돌파 최소 여유 +0.2%
|
||||
COST_PCT = 0.005 # 수수료+슬리피지 합산 (매수+매도)
|
||||
|
||||
|
||||
# ── 데이터 클래스 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MinuteCandle:
|
||||
"""KIS API output2 데이터 정규화 결과"""
|
||||
date: str # YYYYMMDD
|
||||
time: str # HHMMSS
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: int
|
||||
cum_volume: int # 누적 거래량 (acml_vol)
|
||||
cum_amount: float # 누적 거래대금 원 (acml_tr_pbmn)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpeningRange:
|
||||
high: float
|
||||
low: float
|
||||
total_volume: int
|
||||
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayTradeSignal:
|
||||
stock_code: str
|
||||
stock_name: str
|
||||
strategy: str # "ORB" | "VWAP_BOUNCE" | "RSI_BB"
|
||||
entry_price: float
|
||||
stop_loss: float
|
||||
target_price: float
|
||||
reason: str
|
||||
confidence: float # 0.0 ~ 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayTradeExit:
|
||||
action: str # "SELL_ALL"
|
||||
reason: str
|
||||
sell_pct: float = 1.0
|
||||
|
||||
|
||||
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
|
||||
"""KIS API output2 리스트를 MinuteCandle 리스트로 변환
|
||||
|
||||
KIS 응답 필드:
|
||||
stck_bsop_date / stck_cntg_hour / stck_oprc / stck_hgpr /
|
||||
stck_lwpr / stck_prpr (종가) / cntg_vol / acml_vol / acml_tr_pbmn
|
||||
"""
|
||||
candles: List[MinuteCandle] = []
|
||||
for row in raw_output2:
|
||||
try:
|
||||
candles.append(MinuteCandle(
|
||||
date=row.get("stck_bsop_date", ""),
|
||||
time=row.get("stck_cntg_hour", ""),
|
||||
open=float(row.get("stck_oprc", 0) or 0),
|
||||
high=float(row.get("stck_hgpr", 0) or 0),
|
||||
low=float(row.get("stck_lwpr", 0) or 0),
|
||||
close=float(row.get("stck_prpr", 0) or 0),
|
||||
volume=int(row.get("cntg_vol", 0) or 0),
|
||||
cum_volume=int(row.get("acml_vol", 0) or 0),
|
||||
cum_amount=float(row.get("acml_tr_pbmn", 0) or 0),
|
||||
))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return candles
|
||||
|
||||
|
||||
def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
|
||||
"""1분봉 → 5분봉 집계 (09:00 기준 5분 버킷)"""
|
||||
if not candles:
|
||||
return []
|
||||
|
||||
buckets: Dict[int, List[MinuteCandle]] = {}
|
||||
for c in candles:
|
||||
if not c.time:
|
||||
continue
|
||||
h = int(c.time[:2])
|
||||
m = int(c.time[2:4])
|
||||
key = (h * 60 + m) // 5
|
||||
buckets.setdefault(key, []).append(c)
|
||||
|
||||
result: List[MinuteCandle] = []
|
||||
for key in sorted(buckets):
|
||||
bkt = buckets[key]
|
||||
result.append(MinuteCandle(
|
||||
date=bkt[0].date,
|
||||
time=bkt[0].time,
|
||||
open=bkt[0].open,
|
||||
high=max(b.high for b in bkt),
|
||||
low=min(b.low for b in bkt),
|
||||
close=bkt[-1].close,
|
||||
volume=sum(b.volume for b in bkt),
|
||||
cum_volume=bkt[-1].cum_volume,
|
||||
cum_amount=bkt[-1].cum_amount,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
|
||||
"""VWAP = 누적거래대금 / 누적거래량 (당일 09:00 기준 누적)"""
|
||||
if not candles:
|
||||
return None
|
||||
last = candles[-1]
|
||||
if last.cum_volume <= 0:
|
||||
return None
|
||||
return last.cum_amount / last.cum_volume
|
||||
|
||||
|
||||
def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[OpeningRange]:
|
||||
"""09:00~09:29 분봉으로 Opening Range (H/L) 계산"""
|
||||
orb = [c for c in candles_1min if "090000" <= c.time <= "092959"]
|
||||
if len(orb) < 5:
|
||||
return None
|
||||
total_vol = sum(c.volume for c in orb)
|
||||
return OpeningRange(
|
||||
high=max(c.high for c in orb),
|
||||
low=min(c.low for c in orb),
|
||||
total_volume=total_vol,
|
||||
avg_vol_per_min=total_vol / len(orb),
|
||||
)
|
||||
|
||||
|
||||
def _rsi(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
d = closes[i] - closes[i - 1]
|
||||
gains.append(max(d, 0))
|
||||
losses.append(max(-d, 0))
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
return 100 - (100 / (1 + avg_gain / avg_loss))
|
||||
|
||||
|
||||
def _bollinger(candles: List[MinuteCandle], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||||
closes = [c.close for c in candles]
|
||||
if len(closes) < period:
|
||||
return None
|
||||
window = closes[-period:]
|
||||
mid = sum(window) / period
|
||||
std = (sum((p - mid) ** 2 for p in window) / period) ** 0.5
|
||||
return (mid + 2 * std, mid, mid - 2 * std)
|
||||
|
||||
|
||||
def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
|
||||
if len(candles) < period + 1:
|
||||
return None
|
||||
trs = []
|
||||
for i in range(1, len(candles)):
|
||||
h, l, cp = candles[i].high, candles[i].low, candles[i - 1].close
|
||||
trs.append(max(h - l, abs(h - cp), abs(l - cp)))
|
||||
if len(trs) < period:
|
||||
return None
|
||||
return sum(trs[-period:]) / period
|
||||
|
||||
|
||||
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def check_orb_signal(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
candles_1min: List[MinuteCandle],
|
||||
orb: OpeningRange,
|
||||
current_price: float,
|
||||
) -> Optional[DayTradeSignal]:
|
||||
"""ORB 상향 돌파 매수 신호
|
||||
|
||||
조건:
|
||||
1. 현재가 > ORB 고점 × 1.002 (0.2% 이상 돌파)
|
||||
2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발)
|
||||
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
|
||||
"""
|
||||
if not orb or current_price <= 0:
|
||||
return None
|
||||
|
||||
if current_price < orb.high * (1 + ORB_BREAKOUT_MARGIN):
|
||||
return None
|
||||
|
||||
# 최근 1분봉 거래량 체크
|
||||
if not candles_1min:
|
||||
return None
|
||||
recent_vol = candles_1min[-1].volume
|
||||
if recent_vol < orb.avg_vol_per_min * 1.5:
|
||||
return None
|
||||
|
||||
# 직전 5분봉 양봉 확인
|
||||
candles_5m = aggregate_to_5min(candles_1min)
|
||||
if len(candles_5m) >= 2 and candles_5m[-2].close < candles_5m[-2].open:
|
||||
return None # 직전 5분봉 음봉 → 스킵
|
||||
|
||||
stop_loss = max(orb.low, current_price * (1 - STOP_LOSS_PCT))
|
||||
risk = current_price - stop_loss
|
||||
# 수수료 고려: 목표 = 진입가 + 2R + 수수료 보전
|
||||
target = current_price + max(risk * 2.0, current_price * 0.03)
|
||||
|
||||
vol_ratio = recent_vol / orb.avg_vol_per_min
|
||||
confidence = min(0.85, 0.65 + vol_ratio * 0.05)
|
||||
|
||||
return DayTradeSignal(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
strategy="ORB",
|
||||
entry_price=current_price,
|
||||
stop_loss=round(stop_loss, 0),
|
||||
target_price=round(target, 0),
|
||||
reason=(
|
||||
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
|
||||
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
|
||||
),
|
||||
confidence=round(confidence, 2),
|
||||
)
|
||||
|
||||
|
||||
def check_vwap_bounce_signal(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
candles_5min: List[MinuteCandle],
|
||||
vwap: float,
|
||||
current_price: float,
|
||||
) -> Optional[DayTradeSignal]:
|
||||
"""VWAP 지지 반등 매수 신호
|
||||
|
||||
조건:
|
||||
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
|
||||
2. 5분봉 RSI(14) ≤ 40
|
||||
3. 최근 봉 거래량 ≥ 직전 봉 × 1.3
|
||||
4. 최근 5분봉 양봉 전환 (close > open)
|
||||
"""
|
||||
if not candles_5min or vwap <= 0 or current_price <= 0:
|
||||
return None
|
||||
|
||||
if not (vwap * 0.99 <= current_price <= vwap * 1.005):
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 40:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
return None
|
||||
|
||||
# 최근 봉 양봉 확인
|
||||
last = candles_5min[-1]
|
||||
if last.close <= last.open:
|
||||
return None
|
||||
|
||||
atr = _atr(candles_5min, 14)
|
||||
stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT)
|
||||
target = (current_price + 2.0 * atr) if atr else vwap * 1.03
|
||||
|
||||
return DayTradeSignal(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
strategy="VWAP_BOUNCE",
|
||||
entry_price=current_price,
|
||||
stop_loss=round(stop_loss, 0),
|
||||
target_price=round(target, 0),
|
||||
reason=(
|
||||
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}, "
|
||||
f"현재={current_price:.0f}"
|
||||
),
|
||||
confidence=0.65,
|
||||
)
|
||||
|
||||
|
||||
def check_rsi_bb_signal(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
candles_5min: List[MinuteCandle],
|
||||
current_price: float,
|
||||
) -> Optional[DayTradeSignal]:
|
||||
"""5분봉 RSI + 볼린저 과매도 반등 신호
|
||||
|
||||
조건:
|
||||
1. RSI(14) ≤ 30 (강한 과매도)
|
||||
2. 종가 ≤ BB 하단
|
||||
3. 직전 봉 대비 거래량 ≥ 1.3배
|
||||
4. 최근 봉 양봉 (음봉이면 스킵)
|
||||
"""
|
||||
if not candles_5min or current_price <= 0:
|
||||
return None
|
||||
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is None or rsi > 30:
|
||||
return None
|
||||
|
||||
bb = _bollinger(candles_5min, 20)
|
||||
if bb is None:
|
||||
return None
|
||||
bb_upper, bb_middle, bb_lower = bb
|
||||
|
||||
if current_price > bb_lower * 1.01:
|
||||
return None
|
||||
|
||||
if len(candles_5min) < 2:
|
||||
return None
|
||||
if candles_5min[-1].volume < candles_5min[-2].volume * 1.3:
|
||||
return None
|
||||
|
||||
last = candles_5min[-1]
|
||||
if last.close <= last.open:
|
||||
return None
|
||||
|
||||
atr = _atr(candles_5min, 14)
|
||||
stop_loss = (current_price - 1.5 * atr) if atr else current_price * (1 - STOP_LOSS_PCT)
|
||||
target = bb_middle # BB 중앙선까지 반등 목표
|
||||
|
||||
return DayTradeSignal(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
strategy="RSI_BB",
|
||||
entry_price=current_price,
|
||||
stop_loss=round(stop_loss, 0),
|
||||
target_price=round(target, 0),
|
||||
reason=(
|
||||
f"5분봉 과매도 반등: RSI={rsi:.1f}, "
|
||||
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}"
|
||||
),
|
||||
confidence=0.60,
|
||||
)
|
||||
|
||||
|
||||
# ── 청산 신호 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def check_day_trade_exit(
|
||||
entry_price: float,
|
||||
current_price: float,
|
||||
stop_loss: float,
|
||||
target_price: float,
|
||||
highest_since_entry: float,
|
||||
candles_5min: List[MinuteCandle],
|
||||
current_time: time,
|
||||
) -> Optional[DayTradeExit]:
|
||||
"""데이트레이딩 청산 조건 체크 (우선순위 순)
|
||||
|
||||
1. EOD 강제 청산 (15:20 이후)
|
||||
2. 손절 (현재가 ≤ stop_loss)
|
||||
3. 목표가 달성 (현재가 ≥ target_price)
|
||||
4. 트레일링 스탑 (1.5% 수익 후 ATR 트레일)
|
||||
5. 5분봉 RSI ≥ 70 (과매수 청산)
|
||||
"""
|
||||
# 1. EOD 강제 청산
|
||||
if current_time >= EOD_FLAT:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"EOD 강제 청산 ({current_time.strftime('%H:%M')} ≥ 15:20)",
|
||||
)
|
||||
|
||||
# 2. 손절
|
||||
if current_price <= stop_loss:
|
||||
loss_pct = (current_price - entry_price) / entry_price * 100
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f})",
|
||||
)
|
||||
|
||||
# 3. 목표가 달성
|
||||
if current_price >= target_price:
|
||||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"목표가 달성 +{gain_pct:.1f}% (현재={current_price:.0f} ≥ 목표={target_price:.0f})",
|
||||
)
|
||||
|
||||
# 4. 트레일링 스탑 (1.5% 이상 수익 시 발동)
|
||||
profit_pct = (highest_since_entry - entry_price) / entry_price
|
||||
if profit_pct >= TRAILING_TRIGGER:
|
||||
atr = _atr(candles_5min, 14)
|
||||
if atr:
|
||||
trailing_stop = highest_since_entry - TRAILING_ATR_MULT * atr
|
||||
if current_price <= trailing_stop:
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=(
|
||||
f"트레일링 스탑: 최고={highest_since_entry:.0f}, "
|
||||
f"트레일={trailing_stop:.0f}, 현재={current_price:.0f}"
|
||||
),
|
||||
)
|
||||
|
||||
# 5. 과매수 청산 (RSI ≥ 70)
|
||||
rsi = _rsi(candles_5min, 14)
|
||||
if rsi is not None and rsi >= 70:
|
||||
gain_pct = (current_price - entry_price) / entry_price * 100
|
||||
if gain_pct > COST_PCT * 100: # 수수료 이상 수익인 경우만
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"과매수 청산: RSI={rsi:.1f}≥70, 수익={gain_pct:.1f}%",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── 포지션 사이징 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def calculate_day_trade_position_size(
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_day_trade_amount: float,
|
||||
) -> float:
|
||||
"""데이트레이딩 포지션 크기 계산
|
||||
|
||||
- 종목당 최대: 총자산 × 10%
|
||||
- 전체 데이트레이딩 합산: 총자산 × 25% (CLAUDE.md 8.2)
|
||||
- 동시 최대 3종목 (MAX_POSITIONS)
|
||||
"""
|
||||
max_total = total_assets * MAX_DAY_TRADE_RATIO
|
||||
remaining_budget = max_total - current_day_trade_amount
|
||||
per_position_max = total_assets * MAX_CAPITAL_RATIO
|
||||
return max(0.0, min(per_position_max, remaining_budget, available_cash))
|
||||
|
||||
|
||||
def is_daily_loss_exceeded(
|
||||
total_assets: float,
|
||||
realized_pnl_today: float,
|
||||
) -> bool:
|
||||
"""일일 손실 한도 초과 여부 (전체 자본 -3%)"""
|
||||
if total_assets <= 0:
|
||||
return False
|
||||
return (realized_pnl_today / total_assets) <= DAILY_LOSS_LIMIT
|
||||
381
backend/app/services/feature_engineering.py
Normal file
381
backend/app/services/feature_engineering.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
피처 엔지니어링 모듈
|
||||
|
||||
KIS API 차트 데이터(OHLCV)에서 ML 모델용 피처를 추출합니다.
|
||||
- 기본 피처 12개: RSI, MACD, 스토캐스틱, 볼린저, MA비율, 거래량비율
|
||||
- 파생 피처 8개: 수익률, 변동성, 모멘텀, 크로스, ADX, 이격도
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
|
||||
def _ema(values: np.ndarray, period: int) -> np.ndarray:
|
||||
"""지수이동평균 계산"""
|
||||
result = np.full_like(values, np.nan, dtype=float)
|
||||
if len(values) < period:
|
||||
return result
|
||||
result[period - 1] = np.mean(values[:period])
|
||||
multiplier = 2.0 / (period + 1)
|
||||
for i in range(period, len(values)):
|
||||
result[i] = (values[i] - result[i - 1]) * multiplier + result[i - 1]
|
||||
return result
|
||||
|
||||
|
||||
def chart_data_to_dataframe(chart_data: list) -> pd.DataFrame:
|
||||
"""KIS API 차트 데이터(최신순)를 DataFrame(시간순)으로 변환"""
|
||||
rows = []
|
||||
for item in chart_data:
|
||||
rows.append({
|
||||
"date": item.get("stck_bsop_date", ""),
|
||||
"open": float(item.get("stck_oprc", 0) or 0),
|
||||
"high": float(item.get("stck_hgpr", 0) or 0),
|
||||
"low": float(item.get("stck_lwpr", 0) or 0),
|
||||
"close": float(item.get("stck_clpr", 0) or 0),
|
||||
"volume": float(item.get("acml_vol", 0) or 0),
|
||||
})
|
||||
df = pd.DataFrame(rows)
|
||||
# KIS API는 최신순 → 시간순으로 뒤집기
|
||||
df = df.iloc[::-1].reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def db_data_to_dataframe(rows: list) -> pd.DataFrame:
|
||||
"""DB StockDailyPrice 레코드를 DataFrame(시간순)으로 변환.
|
||||
|
||||
Args:
|
||||
rows: StockDailyPrice 객체 리스트 또는 dict 리스트
|
||||
(trade_date, open_price, high_price, low_price, close_price, volume)
|
||||
|
||||
Returns:
|
||||
chart_data_to_dataframe()과 동일한 형태의 DataFrame
|
||||
columns: date, open, high, low, close, volume
|
||||
"""
|
||||
records = []
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
records.append({
|
||||
"date": str(row["trade_date"]),
|
||||
"open": float(row["open_price"]),
|
||||
"high": float(row["high_price"]),
|
||||
"low": float(row["low_price"]),
|
||||
"close": float(row["close_price"]),
|
||||
"volume": float(row["volume"]),
|
||||
})
|
||||
else:
|
||||
records.append({
|
||||
"date": str(row.trade_date),
|
||||
"open": float(row.open_price),
|
||||
"high": float(row.high_price),
|
||||
"low": float(row.low_price),
|
||||
"close": float(row.close_price),
|
||||
"volume": float(row.volume),
|
||||
})
|
||||
df = pd.DataFrame(records)
|
||||
# trade_date 기준 오름차순 정렬
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
def compute_rsi(close: np.ndarray, period: int = 14) -> np.ndarray:
|
||||
"""RSI 계산"""
|
||||
result = np.full_like(close, np.nan, dtype=float)
|
||||
if len(close) < period + 1:
|
||||
return result
|
||||
delta = np.diff(close)
|
||||
gain = np.where(delta > 0, delta, 0.0)
|
||||
loss = np.where(delta < 0, -delta, 0.0)
|
||||
|
||||
avg_gain = np.mean(gain[:period])
|
||||
avg_loss = np.mean(loss[:period])
|
||||
|
||||
for i in range(period, len(delta)):
|
||||
avg_gain = (avg_gain * (period - 1) + gain[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss[i]) / period
|
||||
if avg_loss == 0:
|
||||
result[i + 1] = 100.0
|
||||
else:
|
||||
rs = avg_gain / avg_loss
|
||||
result[i + 1] = 100.0 - (100.0 / (1.0 + rs))
|
||||
# 첫 번째 유효 값
|
||||
if avg_loss == 0:
|
||||
result[period] = 100.0
|
||||
else:
|
||||
result[period] = 100.0 - (100.0 / (1.0 + np.mean(gain[:period]) / max(np.mean(loss[:period]), 1e-10)))
|
||||
return result
|
||||
|
||||
|
||||
def extract_features(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""DataFrame(OHLCV 시간순)에서 전체 피처를 계산하여 반환.
|
||||
|
||||
반환되는 DataFrame은 입력과 같은 인덱스를 갖되, 각 행마다
|
||||
해당 시점까지의 정보만을 사용하여 계산된 20개 피처 컬럼을 포함합니다.
|
||||
초기 데이터 부족 구간은 NaN이 됩니다.
|
||||
"""
|
||||
close = df["close"].values.astype(float)
|
||||
high = df["high"].values.astype(float)
|
||||
low = df["low"].values.astype(float)
|
||||
volume = df["volume"].values.astype(float)
|
||||
n = len(close)
|
||||
|
||||
features: Dict[str, np.ndarray] = {}
|
||||
|
||||
# --- 기본 피처 (12개) ---
|
||||
|
||||
# 1-2. RSI (14일, 7일)
|
||||
features["rsi_14"] = compute_rsi(close, 14)
|
||||
features["rsi_7"] = compute_rsi(close, 7)
|
||||
|
||||
# 3-5. MACD (12, 26, signal=9)
|
||||
ema12 = _ema(close, 12)
|
||||
ema26 = _ema(close, 26)
|
||||
macd_line = ema12 - ema26
|
||||
macd_signal = _ema(macd_line[~np.isnan(macd_line)], 9)
|
||||
# macd_signal을 원래 길이에 맞추기
|
||||
full_signal = np.full(n, np.nan, dtype=float)
|
||||
valid_start = np.argmax(~np.isnan(macd_line))
|
||||
if len(macd_signal) > 0:
|
||||
full_signal[valid_start:valid_start + len(macd_signal)] = macd_signal
|
||||
features["macd"] = macd_line
|
||||
features["macd_signal"] = full_signal
|
||||
features["macd_hist"] = macd_line - full_signal
|
||||
|
||||
# 6-7. 스토캐스틱 %K, %D (14일)
|
||||
stoch_k = np.full(n, np.nan, dtype=float)
|
||||
stoch_d = np.full(n, np.nan, dtype=float)
|
||||
for i in range(13, n):
|
||||
h14 = np.max(high[i - 13:i + 1])
|
||||
l14 = np.min(low[i - 13:i + 1])
|
||||
if h14 != l14:
|
||||
stoch_k[i] = (close[i] - l14) / (h14 - l14) * 100.0
|
||||
# %D = %K의 3일 SMA
|
||||
for i in range(15, n):
|
||||
vals = stoch_k[i - 2:i + 1]
|
||||
if not np.any(np.isnan(vals)):
|
||||
stoch_d[i] = np.mean(vals)
|
||||
features["stochastic_k"] = stoch_k
|
||||
features["stochastic_d"] = stoch_d
|
||||
|
||||
# 8. 볼린저 밴드 위치 (0~1)
|
||||
bb_pos = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
window = close[i - 19:i + 1]
|
||||
mid = np.mean(window)
|
||||
std = np.std(window, ddof=0)
|
||||
if std > 0:
|
||||
upper = mid + 2 * std
|
||||
lower = mid - 2 * std
|
||||
bb_pos[i] = (close[i] - lower) / (upper - lower)
|
||||
features["bb_position"] = bb_pos
|
||||
|
||||
# 9-11. MA 비율 (현재가 / MA)
|
||||
for period, name in [(5, "ma5_ratio"), (20, "ma20_ratio"), (60, "ma60_ratio")]:
|
||||
ratio = np.full(n, np.nan, dtype=float)
|
||||
for i in range(period - 1, n):
|
||||
ma = np.mean(close[i - period + 1:i + 1])
|
||||
if ma > 0:
|
||||
ratio[i] = close[i] / ma
|
||||
features[name] = ratio
|
||||
|
||||
# 12. 거래량 비율 (현재 / 20일 평균)
|
||||
vol_ratio = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
avg_vol = np.mean(volume[i - 19:i + 1])
|
||||
if avg_vol > 0:
|
||||
vol_ratio[i] = volume[i] / avg_vol
|
||||
features["volume_ratio"] = vol_ratio
|
||||
|
||||
# --- 파생 피처 (8개) ---
|
||||
|
||||
# 13-15. 단기 수익률
|
||||
for days, name in [(1, "change_rate_1d"), (3, "change_rate_3d"), (5, "change_rate_5d")]:
|
||||
cr = np.full(n, np.nan, dtype=float)
|
||||
for i in range(days, n):
|
||||
if close[i - days] > 0:
|
||||
cr[i] = (close[i] - close[i - days]) / close[i - days]
|
||||
features[name] = cr
|
||||
|
||||
# 16. 20일 변동성 (수익률의 표준편차)
|
||||
vol20 = np.full(n, np.nan, dtype=float)
|
||||
returns = np.full(n, np.nan, dtype=float)
|
||||
for i in range(1, n):
|
||||
if close[i - 1] > 0:
|
||||
returns[i] = (close[i] - close[i - 1]) / close[i - 1]
|
||||
for i in range(20, n):
|
||||
window = returns[i - 19:i + 1]
|
||||
valid = window[~np.isnan(window)]
|
||||
if len(valid) >= 10:
|
||||
vol20[i] = np.std(valid, ddof=1)
|
||||
features["volatility_20d"] = vol20
|
||||
|
||||
# 17. 5일 모멘텀 (현재가 / 5일전가)
|
||||
mom = np.full(n, np.nan, dtype=float)
|
||||
for i in range(5, n):
|
||||
if close[i - 5] > 0:
|
||||
mom[i] = close[i] / close[i - 5]
|
||||
features["price_momentum"] = mom
|
||||
|
||||
# 18. MA 크로스 상태 (-1, 0, 1)
|
||||
ma_cross = np.full(n, 0.0, dtype=float)
|
||||
for i in range(59, n):
|
||||
ma5 = np.mean(close[i - 4:i + 1])
|
||||
ma20 = np.mean(close[i - 19:i + 1])
|
||||
ma60 = np.mean(close[i - 59:i + 1])
|
||||
if ma5 > ma20 > ma60:
|
||||
ma_cross[i] = 1.0 # 정배열
|
||||
elif ma5 < ma20 < ma60:
|
||||
ma_cross[i] = -1.0 # 역배열
|
||||
features["ma_cross"] = ma_cross
|
||||
|
||||
# 19. 추세 강도 (간이 ADX, True Range 기반)
|
||||
adx_val = np.full(n, np.nan, dtype=float)
|
||||
tr = np.zeros(n, dtype=float)
|
||||
for i in range(1, n):
|
||||
tr[i] = max(high[i] - low[i], abs(high[i] - close[i - 1]), abs(low[i] - close[i - 1]))
|
||||
for i in range(13, n):
|
||||
avg_tr = np.mean(tr[i - 13:i + 1])
|
||||
current_tr = tr[i]
|
||||
if avg_tr > 0:
|
||||
adx_val[i] = current_tr / avg_tr
|
||||
features["trend_strength"] = adx_val
|
||||
|
||||
# 20. 이격도 (현재가 / 20일 이평선 * 100)
|
||||
disp = np.full(n, np.nan, dtype=float)
|
||||
for i in range(19, n):
|
||||
ma20 = np.mean(close[i - 19:i + 1])
|
||||
if ma20 > 0:
|
||||
disp[i] = close[i] / ma20 * 100.0
|
||||
features["disparity_20"] = disp
|
||||
|
||||
feat_df = pd.DataFrame(features, index=df.index)
|
||||
return feat_df
|
||||
|
||||
|
||||
# 피처 컬럼 순서 (모델 학습/추론 시 일관성 유지)
|
||||
FEATURE_COLUMNS = [
|
||||
"rsi_14", "rsi_7",
|
||||
"macd", "macd_signal", "macd_hist",
|
||||
"stochastic_k", "stochastic_d",
|
||||
"bb_position",
|
||||
"ma5_ratio", "ma20_ratio", "ma60_ratio",
|
||||
"volume_ratio",
|
||||
"change_rate_1d", "change_rate_3d", "change_rate_5d",
|
||||
"volatility_20d",
|
||||
"price_momentum",
|
||||
"ma_cross",
|
||||
"trend_strength",
|
||||
"disparity_20",
|
||||
]
|
||||
|
||||
# LSTM 시퀀스에 사용할 피처 (5개)
|
||||
LSTM_SEQUENCE_FEATURES = ["close", "volume", "rsi_14", "ma20_ratio", "volatility_20d"]
|
||||
|
||||
|
||||
def prepare_training_data(
|
||||
df: pd.DataFrame,
|
||||
features_df: pd.DataFrame,
|
||||
forecast_days: int = 3,
|
||||
up_threshold: float = 0.015,
|
||||
down_threshold: float = -0.015,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""학습 데이터 준비.
|
||||
|
||||
Returns:
|
||||
X_tabular: (N, 20) 탭뷸러 피처
|
||||
X_seq: (N, 60, 5) LSTM 시퀀스 피처
|
||||
y: (N,) 레이블 (0=DOWN, 1=HOLD, 2=UP)
|
||||
valid_indices: 유효한 행 인덱스
|
||||
"""
|
||||
close = df["close"].values.astype(float)
|
||||
n = len(close)
|
||||
|
||||
# 레이블 생성: forecast_days 후 수익률
|
||||
labels = np.full(n, np.nan, dtype=float)
|
||||
for i in range(n - forecast_days):
|
||||
if close[i] > 0:
|
||||
future_return = (close[i + forecast_days] - close[i]) / close[i]
|
||||
if future_return >= up_threshold:
|
||||
labels[i] = 2 # UP
|
||||
elif future_return <= down_threshold:
|
||||
labels[i] = 0 # DOWN
|
||||
else:
|
||||
labels[i] = 1 # HOLD
|
||||
|
||||
# 유효한 행 찾기 (피처와 레이블 모두 NaN 아닌 행)
|
||||
tabular = features_df[FEATURE_COLUMNS].values
|
||||
seq_window = 60
|
||||
|
||||
valid_mask = np.ones(n, dtype=bool)
|
||||
valid_mask[:seq_window] = False # 시퀀스 윈도우 확보
|
||||
valid_mask[np.isnan(labels)] = False
|
||||
# 탭뷸러 피처 NaN 체크
|
||||
for i in range(n):
|
||||
if valid_mask[i] and np.any(np.isnan(tabular[i])):
|
||||
valid_mask[i] = False
|
||||
|
||||
valid_indices = np.where(valid_mask)[0]
|
||||
|
||||
if len(valid_indices) == 0:
|
||||
return np.array([]), np.array([]), np.array([]), np.array([])
|
||||
|
||||
# 탭뷸러 피처
|
||||
X_tabular = tabular[valid_indices]
|
||||
|
||||
# 레이블
|
||||
y = labels[valid_indices].astype(int)
|
||||
|
||||
# LSTM 시퀀스 피처 준비
|
||||
# 시퀀스에 사용할 raw 피처 결합
|
||||
seq_cols = []
|
||||
for col in LSTM_SEQUENCE_FEATURES:
|
||||
if col in df.columns:
|
||||
seq_cols.append(df[col].values.astype(float))
|
||||
elif col in features_df.columns:
|
||||
seq_cols.append(features_df[col].values.astype(float))
|
||||
else:
|
||||
seq_cols.append(np.zeros(n, dtype=float))
|
||||
seq_raw = np.column_stack(seq_cols) # (n, 5)
|
||||
|
||||
X_seq = np.zeros((len(valid_indices), seq_window, len(LSTM_SEQUENCE_FEATURES)), dtype=float)
|
||||
for j, idx in enumerate(valid_indices):
|
||||
X_seq[j] = seq_raw[idx - seq_window:idx]
|
||||
|
||||
return X_tabular, X_seq, y, valid_indices
|
||||
|
||||
|
||||
def extract_single_features(chart_data: list) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
|
||||
"""단일 종목 차트 데이터에서 최신 시점의 피처를 추출.
|
||||
|
||||
Returns:
|
||||
tabular_features: (20,) 탭뷸러 피처 또는 None
|
||||
sequence_features: (60, 5) LSTM 시퀀스 또는 None
|
||||
"""
|
||||
if not chart_data or len(chart_data) < 61:
|
||||
return None, None
|
||||
|
||||
df = chart_data_to_dataframe(chart_data)
|
||||
features_df = extract_features(df)
|
||||
|
||||
# 마지막 행의 탭뷸러 피처
|
||||
last_row = features_df[FEATURE_COLUMNS].iloc[-1].values
|
||||
if np.any(np.isnan(last_row)):
|
||||
return None, None
|
||||
|
||||
# LSTM 시퀀스 (최근 60일)
|
||||
seq_cols = []
|
||||
for col in LSTM_SEQUENCE_FEATURES:
|
||||
if col in df.columns:
|
||||
seq_cols.append(df[col].values.astype(float))
|
||||
elif col in features_df.columns:
|
||||
seq_cols.append(features_df[col].values.astype(float))
|
||||
else:
|
||||
seq_cols.append(np.zeros(len(df), dtype=float))
|
||||
seq_raw = np.column_stack(seq_cols)
|
||||
|
||||
if len(seq_raw) < 60:
|
||||
return None, None
|
||||
|
||||
sequence = seq_raw[-60:] # (60, 5)
|
||||
|
||||
return last_row, sequence
|
||||
@@ -1,9 +1,12 @@
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, Deque
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -65,6 +68,9 @@ class APICallMonitor:
|
||||
|
||||
class KoreaInvestmentAPI:
|
||||
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
|
||||
|
||||
# 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약)
|
||||
TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json"
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
||||
@@ -85,42 +91,148 @@ class KoreaInvestmentAPI:
|
||||
# 동시 요청 제한 (세마포어)
|
||||
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
# 토큰 발급 Lock (중복 발급 방지)
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
# 모니터링
|
||||
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
|
||||
# 시작 시 설정 로깅 (키 마스킹)
|
||||
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
|
||||
f"base_url={self.base_url}, "
|
||||
f"app_key={self.app_key[:6]}..., "
|
||||
f"account={self.account_number[:4]}****{self.account_number[-2:]}")
|
||||
|
||||
# 캐시된 토큰 로드
|
||||
self._load_token_from_cache()
|
||||
|
||||
def _load_token_from_cache(self):
|
||||
"""파일에서 토큰 로드 (앱 시작 시 동기 호출)"""
|
||||
try:
|
||||
cache_file = Path(self.TOKEN_CACHE_FILE)
|
||||
if cache_file.exists():
|
||||
with open(cache_file, 'r') as f:
|
||||
cache = json.load(f)
|
||||
self.access_token = cache.get('access_token')
|
||||
expires_str = cache.get('expires_at')
|
||||
if expires_str:
|
||||
self.token_expires_at = datetime.fromisoformat(expires_str)
|
||||
if self.access_token and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"💾 캐시 파일에서 토큰 로드 성공 (남은 시간: {remaining:.1f}시간)")
|
||||
else:
|
||||
logger.info("💾 캐시 파일의 토큰이 만료됨")
|
||||
self.access_token = None
|
||||
self.token_expires_at = None
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 토큰 캐시 파일 로드 실패: {e}")
|
||||
|
||||
def _write_token_file(self):
|
||||
"""토큰을 파일에 동기 저장 (asyncio.to_thread 내부에서 실행)"""
|
||||
cache_file = Path(self.TOKEN_CACHE_FILE)
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache = {
|
||||
'access_token': self.access_token,
|
||||
'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None,
|
||||
}
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump(cache, f)
|
||||
# 소유자만 읽기/쓰기 가능하도록 퍼미션 설정 (보안)
|
||||
os.chmod(cache_file, 0o600)
|
||||
logger.debug("💾 토큰 캐시 파일 저장 완료")
|
||||
|
||||
async def _save_token_to_cache(self):
|
||||
"""토큰을 파일에 비동기 저장 (이벤트 루프 블로킹 방지)"""
|
||||
try:
|
||||
await asyncio.to_thread(self._write_token_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 토큰 캐시 파일 저장 실패: {e}")
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""접근 토큰 발급"""
|
||||
"""접근 토큰 발급 (Lock으로 중복 발급 방지)"""
|
||||
# 캐시된 토큰이 유효하면 재사용 (23시간 유효)
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 캐시된 토큰 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
from datetime import timedelta
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
# 🔒 Lock 획득 - 동시 요청 시 하나만 토큰 발급
|
||||
async with self._token_lock:
|
||||
# Lock 대기 중 다른 요청이 토큰 발급했을 수 있으므로 재확인
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 대기 중 토큰 발급됨, 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
else:
|
||||
raise Exception(f"Failed to get access token: {response.text}")
|
||||
|
||||
# 토큰 발급 시도 전에 마지막 시도 시간 확인 (1분 제한 대응)
|
||||
if hasattr(self, '_last_token_request_time'):
|
||||
elapsed = (datetime.now() - self._last_token_request_time).total_seconds()
|
||||
if elapsed < 70: # 70초 대기 (여유있게)
|
||||
# 기존 토큰이 있으면 계속 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한 ({elapsed:.0f}초 경과) - 기존 토큰 재사용")
|
||||
# 만료 시간 연장 (추가 1시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
|
||||
wait_time = 70 - elapsed
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한: {wait_time:.1f}초 대기 중...")
|
||||
await asyncio.sleep(wait_time + 1)
|
||||
|
||||
logger.info("🔑 새로운 토큰 발급 요청...")
|
||||
self._last_token_request_time = datetime.now()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
|
||||
# 파일에 저장 (비동기)
|
||||
await self._save_token_to_cache()
|
||||
|
||||
logger.info(f"✅ 새 토큰 발급 완료 (만료: {self.token_expires_at.strftime('%Y-%m-%d %H:%M')})")
|
||||
return self.access_token
|
||||
else:
|
||||
error_msg = response.text
|
||||
logger.error(f"❌ 토큰 발급 실패: {response.status_code} - {error_msg}")
|
||||
|
||||
# 토큰 발급 실패 시에도 기존 토큰이 있다면 계속 사용 시도
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
|
||||
# 만료 시간 연장
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise Exception(f"Failed to get access token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 토큰 요청 중 오류: {e}")
|
||||
# 기존 토큰이 있으면 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
|
||||
|
||||
# 동시 요청 수 제한 (세마포어)
|
||||
async with self.semaphore:
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs)
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
|
||||
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
|
||||
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""재시도 로직을 포함한 실제 요청 실행"""
|
||||
start_time = time.time()
|
||||
retry_count = 0
|
||||
@@ -146,6 +258,8 @@ class KoreaInvestmentAPI:
|
||||
"appsecret": self.app_secret,
|
||||
"tr_id": tr_id,
|
||||
}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
# 타임아웃 설정
|
||||
timeout = httpx.Timeout(self.timeout, connect=10.0)
|
||||
@@ -170,7 +284,7 @@ class KoreaInvestmentAPI:
|
||||
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
|
||||
)
|
||||
|
||||
return response.json()
|
||||
return response.json(), response.headers
|
||||
|
||||
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
|
||||
elif response.status_code in [429, 500, 502, 503, 504]:
|
||||
@@ -199,8 +313,12 @@ class KoreaInvestmentAPI:
|
||||
|
||||
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
|
||||
# 에러 응답도 json과 headers 튜플로 반환 시도
|
||||
try:
|
||||
return response.json(), response.headers
|
||||
except Exception:
|
||||
return {"rt_cd": str(response.status_code), "msg1": error_msg, "output": None}, None
|
||||
|
||||
# 기타 오류
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
@@ -242,7 +360,7 @@ class KoreaInvestmentAPI:
|
||||
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""현재가 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
@@ -256,39 +374,292 @@ class KoreaInvestmentAPI:
|
||||
logger.error(f"Error fetching price for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]:
|
||||
"""일봉/주봉/월봉 조회
|
||||
async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240) -> Dict[str, Any]:
|
||||
"""일봉/주봉/월봉 조회 (페이지네이션 지원으로 최대 240개까지 조회)"""
|
||||
all_output2 = []
|
||||
|
||||
KIS API는 기본적으로 최근 30~100개 데이터를 반환
|
||||
더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음)
|
||||
"""
|
||||
# 1. 첫 요청
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
|
||||
}
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, headers = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
params=params
|
||||
)
|
||||
|
||||
# FHKST01010400은 'output' (배열) 또는 'output2' (배열)로 데이터를 반환
|
||||
# 실전/모의투자 환경에 따라 키 이름이 다를 수 있음
|
||||
data_key = 'output2' if isinstance(result.get('output2'), list) and result.get('output2') else \
|
||||
'output' if isinstance(result.get('output'), list) and result.get('output') else None
|
||||
|
||||
if not result or result.get('rt_cd') != '0' or data_key is None:
|
||||
logger.warning(f"No chart data for {stock_code}: {result.get('msg1', 'Unknown')}")
|
||||
return result
|
||||
|
||||
all_output2.extend(result.get(data_key, []))
|
||||
tr_cont = headers.get('tr_cont')
|
||||
|
||||
# 2. 연속 조회 (필요 시)
|
||||
while tr_cont in ('F', 'M') and len(all_output2) < limit:
|
||||
await asyncio.sleep(self.min_request_interval + 0.1) # Rate limit
|
||||
extra_headers = {'tr_cont': 'Y'}
|
||||
|
||||
result_cont, headers_cont = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||
"FHKST01010400",
|
||||
params=params,
|
||||
extra_headers=extra_headers
|
||||
)
|
||||
|
||||
if result_cont and result_cont.get('rt_cd') == '0':
|
||||
cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output'
|
||||
all_output2.extend(result_cont.get(cont_key, []))
|
||||
|
||||
tr_cont = headers_cont.get('tr_cont') if headers_cont else None
|
||||
|
||||
# 3. 최종 결과 조합
|
||||
final_result = result # 첫 페이지 응답을 기반으로 조합
|
||||
final_result['output2'] = all_output2
|
||||
final_result['msg1'] = f"OK (fetched {len(all_output2)} data points)"
|
||||
|
||||
logger.info(f"✅ Fetched {len(all_output2)} chart data points for {stock_code}")
|
||||
return final_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching paginated chart for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]:
|
||||
"""해외주식 현재가 조회 (NASD, NYSE, AMEX)"""
|
||||
try:
|
||||
# 거래소 코드 보정
|
||||
exch_map = {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"}
|
||||
formatted_exchange = exch_map.get(exchange_code.upper(), exchange_code.upper())
|
||||
|
||||
# 모의투자 여부에 따라 TR_ID가 다를 수 있는지 확인 필요하나,
|
||||
# 시세 조회는 실전/모의 공통인 경우가 많음 (HHDFS00000300)
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/overseas-price/v1/quotations/price",
|
||||
"HHDFS00000300",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
|
||||
"AUTH": "",
|
||||
"EXCD": exchange_code.upper(), # NASD, NYSE, AMEX
|
||||
"SYMB": symbol.upper()
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching chart for {stock_code}: {e}")
|
||||
logger.error(f"Error fetching overseas price for {symbol}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_minute_chart(
|
||||
self,
|
||||
stock_code: str,
|
||||
ref_time: str = "",
|
||||
include_past: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""국내주식 분봉차트 조회 (FHKST03010200)
|
||||
|
||||
KIS API: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice
|
||||
한 번 호출에 최대 30개 분봉 반환. 전체 장 데이터는 여러 번 호출 필요.
|
||||
|
||||
Args:
|
||||
stock_code: 종목코드 (e.g. "005930")
|
||||
ref_time: 조회 기준 시간 (HHMMSS). 빈 문자열이면 현재 시간
|
||||
include_past: True = 과거 데이터 포함 (Y), False = 당일만 (N)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"output1": { 현재가 요약 },
|
||||
"output2": [
|
||||
{
|
||||
"stck_bsop_date": "20260222", # 날짜 YYYYMMDD
|
||||
"stck_cntg_hour": "091500", # 시간 HHMMSS
|
||||
"stck_prpr": "71800", # 종가(현재가)
|
||||
"stck_oprc": "71500", # 시가
|
||||
"stck_hgpr": "72000", # 고가
|
||||
"stck_lwpr": "71400", # 저가
|
||||
"cntg_vol": "12345", # 분봉 거래량
|
||||
"acml_vol": "987654", # 누적 거래량
|
||||
"acml_tr_pbmn": "70896123456", # 누적 거래대금(원)
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if not ref_time:
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
kst = pytz.timezone("Asia/Seoul")
|
||||
ref_time = datetime.now(kst).strftime("%H%M%S")
|
||||
|
||||
params = {
|
||||
"FID_ETC_CLS_CODE": "",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_INPUT_HOUR_1": ref_time,
|
||||
"FID_PW_DATA_INCU_YN": "Y" if include_past else "N",
|
||||
}
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
|
||||
"FHKST03010200",
|
||||
params=params,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching minute chart for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def get_minute_chart_full_day(
|
||||
self,
|
||||
stock_code: str,
|
||||
market_open: str = "090000",
|
||||
) -> list:
|
||||
"""당일 전체 분봉 데이터 수집 (여러 번 호출 집계)
|
||||
|
||||
09:00부터 현재까지의 모든 1분봉을 순서대로 반환.
|
||||
한 번 호출에 30개씩 → 최대 ~13회 호출 필요 (390분/30).
|
||||
시간 역순 데이터를 정순으로 변환해서 반환.
|
||||
|
||||
Returns:
|
||||
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
|
||||
"""
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
kst = pytz.timezone("Asia/Seoul")
|
||||
now_str = datetime.now(kst).strftime("%H%M%S")
|
||||
|
||||
all_candles: list = []
|
||||
ref = now_str
|
||||
seen_times: set = set()
|
||||
|
||||
for _ in range(15): # 최대 15회 (450분, 여유 있게)
|
||||
try:
|
||||
result = await self.get_minute_chart(stock_code, ref_time=ref)
|
||||
except Exception as e:
|
||||
logger.warning(f"Minute chart fetch failed for {stock_code} at {ref}: {e}")
|
||||
break
|
||||
|
||||
candles = result.get("output2") if isinstance(result.get("output2"), list) else []
|
||||
if not candles:
|
||||
break
|
||||
|
||||
new_added = 0
|
||||
for c in candles:
|
||||
t = c.get("stck_cntg_hour", "")
|
||||
if t and t not in seen_times:
|
||||
seen_times.add(t)
|
||||
all_candles.append(c)
|
||||
new_added += 1
|
||||
|
||||
# 가장 오래된 시간 확인
|
||||
times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")]
|
||||
oldest = min(times) if times else "000000"
|
||||
|
||||
if oldest <= market_open or new_added == 0:
|
||||
break
|
||||
|
||||
ref = oldest
|
||||
await asyncio.sleep(self.min_request_interval + 0.05)
|
||||
|
||||
# 오래된 것 → 최신 순으로 정렬
|
||||
all_candles.sort(key=lambda c: c.get("stck_cntg_hour", ""))
|
||||
return all_candles
|
||||
|
||||
async def get_overseas_daily_chart(self, symbol: str, exchange_code: str = "NASD", period: str = "D") -> Dict[str, Any]:
|
||||
"""해외주식 차트 조회"""
|
||||
try:
|
||||
# 기간 코드: O(일), P(주), Q(월) - KIS 해외주식 문서 기준
|
||||
# 또는 0(일), 1(주), 2(월)
|
||||
gubn = "0"
|
||||
if period == "W": gubn = "1"
|
||||
elif period == "M": gubn = "2"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/overseas-price/v1/quotations/dailyprice",
|
||||
"HHDFS76240000",
|
||||
params={
|
||||
"AUTH": "",
|
||||
"EXCD": exchange_code.upper(),
|
||||
"SYMB": symbol.upper(),
|
||||
"GUBN": gubn,
|
||||
"BYMD": "",
|
||||
"MODP": "1"
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching overseas chart for {symbol}: {e}")
|
||||
raise
|
||||
|
||||
async def place_overseas_order(
|
||||
self,
|
||||
symbol: str,
|
||||
order_type: str, # "BUY" or "SELL"
|
||||
quantity: int,
|
||||
price: float, # 해외주식은 지정가(Limit)가 기본
|
||||
exchange_code: str = "NASD"
|
||||
) -> Dict[str, Any]:
|
||||
"""해외주식 주문 (미국주식 기준)"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 모의/실전 구분
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
|
||||
# TR_ID 설정 (미국 매수/매도)
|
||||
if order_type == "BUY":
|
||||
tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" # 미국 매수
|
||||
else:
|
||||
tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" # 미국 매도
|
||||
|
||||
# 해외주식 주문 (미국)
|
||||
# OVRS_EXCG_CD: NASD, NYSE, AMEX
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/overseas-stock/v1/trading/order",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"OVRS_EXCG_CD": exchange_code.upper(),
|
||||
"PDNO": symbol.upper(),
|
||||
"ORD_QTY": str(quantity),
|
||||
"OVRS_ORD_UNPR": str(price),
|
||||
"ORD_SVR_DVSN_CD": "0",
|
||||
"ORD_DVSN": "00" # 00: 지정가
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing overseas order: {e}")
|
||||
raise
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
"""계좌 잔고 조회"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
# 모의투자 환경 확인 (URL에 'vts' 포함 여부로 판단)
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC8434R" if is_virtual else "TTTC8434R"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
"TTTC8434R",
|
||||
tr_id,
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
@@ -303,6 +674,10 @@ class KoreaInvestmentAPI:
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
)
|
||||
logger.info(f"📋 Balance API Full Response: {result}")
|
||||
logger.info(f"📋 Balance API Keys: {result.keys()}")
|
||||
if result.get('rt_cd') != '0':
|
||||
logger.error(f"❌ Balance API error - rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching balance: {e}")
|
||||
@@ -317,20 +692,35 @@ class KoreaInvestmentAPI:
|
||||
) -> Dict[str, Any]:
|
||||
"""주문 실행"""
|
||||
try:
|
||||
# 환경 설정 확인
|
||||
if not self.app_key or not self.app_secret or not self.account_number:
|
||||
error_msg = f"KIS API 설정 누락: app_key={bool(self.app_key)}, secret={bool(self.app_secret)}, account={bool(self.account_number)}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg1": error_msg,
|
||||
"odno": None
|
||||
}
|
||||
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 매수/매도 구분
|
||||
# 모의투자 환경 확인
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
|
||||
# 매수/매도 구분 (모의투자는 V 접두사)
|
||||
if order_type == "BUY":
|
||||
tr_id = "TTTC0802U" # 현금 매수
|
||||
tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" # 현금 매수
|
||||
else:
|
||||
tr_id = "TTTC0801U" # 현금 매도
|
||||
tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" # 현금 매도
|
||||
|
||||
# 주문 유형 (00: 지정가, 01: 시장가)
|
||||
order_dvsn = "01" if price is None else "00"
|
||||
order_price = "0" if price is None else str(int(price))
|
||||
|
||||
result = await self._make_request(
|
||||
logger.info(f"📤 주문 요청: {order_type} {stock_code} {quantity}주 @{order_price}원 (tr_id={tr_id}, is_virtual={is_virtual})")
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-cash",
|
||||
tr_id,
|
||||
@@ -343,10 +733,16 @@ class KoreaInvestmentAPI:
|
||||
"ORD_UNPR": order_price
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing order: {e}")
|
||||
raise
|
||||
logger.error(f"❌ 주문 실행 오류: {e}", exc_info=True)
|
||||
return {
|
||||
"rt_cd": "-1",
|
||||
"msg1": str(e),
|
||||
"odno": None
|
||||
}
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
@@ -354,10 +750,14 @@ class KoreaInvestmentAPI:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
result = await self._make_request(
|
||||
# 모의투자 환경 확인
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC0803U" if is_virtual else "TTTC0803U"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
"TTTC0803U",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
@@ -383,7 +783,7 @@ class KoreaInvestmentAPI:
|
||||
) -> Dict[str, Any]:
|
||||
"""조건 검색으로 종목 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-search",
|
||||
"HHKST03900300",
|
||||
@@ -410,7 +810,7 @@ class KoreaInvestmentAPI:
|
||||
"""전체 종목 리스트 조회"""
|
||||
try:
|
||||
# 거래대금 상위 종목 조회
|
||||
result = await self._make_request(
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/volume-rank",
|
||||
"FHPST01710000",
|
||||
@@ -437,6 +837,32 @@ class KoreaInvestmentAPI:
|
||||
"""거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)"""
|
||||
return await self.get_stock_list(market)
|
||||
|
||||
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""호가 정보 조회 (매수/매도 호가 10단계)"""
|
||||
try:
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
params={
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": stock_code
|
||||
}
|
||||
)
|
||||
|
||||
# 디버깅용 로그
|
||||
logger.info(f"📊 Orderbook raw response for {stock_code}:")
|
||||
logger.info(f" rt_cd: {result.get('rt_cd')}, msg1: {result.get('msg1')}")
|
||||
if result.get('output1'):
|
||||
logger.info(f" output1 keys: {list(result['output1'].keys())[:10]}")
|
||||
if result.get('output2'):
|
||||
logger.info(f" output2 keys: {list(result['output2'].keys())[:10]}")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
def get_monitor_stats(self) -> Dict[str, Any]:
|
||||
"""API 호출 모니터링 통계 반환"""
|
||||
return self.monitor.get_stats()
|
||||
|
||||
150
backend/app/services/loss_tracker.py
Normal file
150
backend/app/services/loss_tracker.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
연속 손실 추적 - CLAUDE.md 섹션 7.4
|
||||
|
||||
동일 종목에서 3회 이상 연속 손실 시 30거래일 동안 신규 진입 금지.
|
||||
재시작 후에도 ban 상태 유지: DB(stock_bans 테이블) 영속화.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsecutiveLossTracker:
|
||||
"""종목별 연속 손실 추적 및 쿨링오프 관리"""
|
||||
|
||||
def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42):
|
||||
"""
|
||||
Args:
|
||||
ban_count: 연속 손실 횟수 임계값 (기본 3)
|
||||
ban_calendar_days: 금지 기간 (30거래일 ≈ 42캘린더일)
|
||||
"""
|
||||
self.ban_count = ban_count
|
||||
self.ban_calendar_days = ban_calendar_days
|
||||
# {stock_code: {"consecutive_losses": int, "ban_until": datetime|None}}
|
||||
self._tracker: Dict[str, dict] = {}
|
||||
|
||||
def record_trade_result(self, stock_code: str, is_loss: bool):
|
||||
"""매매 결과 인메모리 기록 (DB 저장은 save_to_db 별도 호출)
|
||||
|
||||
Args:
|
||||
stock_code: 종목코드
|
||||
is_loss: 손실 여부
|
||||
"""
|
||||
if stock_code not in self._tracker:
|
||||
self._tracker[stock_code] = {"consecutive_losses": 0, "ban_until": None}
|
||||
|
||||
entry = self._tracker[stock_code]
|
||||
if is_loss:
|
||||
entry["consecutive_losses"] += 1
|
||||
if entry["consecutive_losses"] >= self.ban_count:
|
||||
entry["ban_until"] = datetime.now() + timedelta(days=self.ban_calendar_days)
|
||||
logger.warning(
|
||||
f"{stock_code}: {entry['consecutive_losses']}회 연속 손실 → "
|
||||
f"{entry['ban_until'].strftime('%Y-%m-%d')}까지 진입 금지"
|
||||
)
|
||||
else:
|
||||
entry["consecutive_losses"] = 0
|
||||
entry["ban_until"] = None
|
||||
|
||||
async def save_to_db(self, db, stock_code: str):
|
||||
"""인메모리 상태를 DB에 영속화 (record_trade_result 후 호출)"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import StockBan
|
||||
|
||||
entry = self._tracker.get(stock_code)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
result = await db.execute(select(StockBan).where(StockBan.stock_code == stock_code))
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
record = StockBan(
|
||||
stock_code=stock_code,
|
||||
consecutive_losses=entry["consecutive_losses"],
|
||||
ban_until=entry["ban_until"],
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
record.consecutive_losses = entry["consecutive_losses"]
|
||||
record.ban_until = entry["ban_until"]
|
||||
record.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.debug(f"loss_tracker DB 저장 완료: {stock_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 저장 실패 ({stock_code}): {e}")
|
||||
|
||||
async def load_from_db(self, db):
|
||||
"""앱 시작 시 DB에서 ban 상태 복원"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import StockBan
|
||||
|
||||
result = await db.execute(select(StockBan))
|
||||
records = result.scalars().all()
|
||||
|
||||
now = datetime.now()
|
||||
loaded = 0
|
||||
for rec in records:
|
||||
# 만료된 ban은 초기화
|
||||
ban_until = rec.ban_until
|
||||
if ban_until and now >= ban_until:
|
||||
ban_until = None
|
||||
consecutive_losses = 0
|
||||
else:
|
||||
consecutive_losses = rec.consecutive_losses
|
||||
|
||||
self._tracker[rec.stock_code] = {
|
||||
"consecutive_losses": consecutive_losses,
|
||||
"ban_until": ban_until,
|
||||
}
|
||||
if ban_until:
|
||||
loaded += 1
|
||||
logger.info(
|
||||
f"ban 복원: {rec.stock_code} → {ban_until.strftime('%Y-%m-%d')}까지 진입 금지"
|
||||
)
|
||||
|
||||
logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 로드 실패: {e}")
|
||||
|
||||
def is_banned(self, stock_code: str) -> bool:
|
||||
"""해당 종목이 진입 금지 상태인지 확인"""
|
||||
entry = self._tracker.get(stock_code)
|
||||
if not entry or not entry["ban_until"]:
|
||||
return False
|
||||
if datetime.now() >= entry["ban_until"]:
|
||||
# 금지 기간 만료 → 리셋
|
||||
entry["consecutive_losses"] = 0
|
||||
entry["ban_until"] = None
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_ban_info(self, stock_code: str) -> dict:
|
||||
"""종목의 연속 손실 및 금지 정보 조회"""
|
||||
entry = self._tracker.get(stock_code, {"consecutive_losses": 0, "ban_until": None})
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"consecutive_losses": entry["consecutive_losses"],
|
||||
"is_banned": self.is_banned(stock_code),
|
||||
"ban_until": entry["ban_until"].isoformat() if entry["ban_until"] else None,
|
||||
}
|
||||
|
||||
|
||||
# 싱글톤 인스턴스 — settings에서 ban_count 주입
|
||||
def _create_loss_tracker() -> ConsecutiveLossTracker:
|
||||
try:
|
||||
from app.core.config import settings
|
||||
return ConsecutiveLossTracker(
|
||||
ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT,
|
||||
ban_calendar_days=42, # 30거래일 ≈ 42캘린더일
|
||||
)
|
||||
except Exception:
|
||||
return ConsecutiveLossTracker()
|
||||
|
||||
|
||||
loss_tracker = _create_loss_tracker()
|
||||
80
backend/app/services/mdd_manager.py
Normal file
80
backend/app/services/mdd_manager.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
MDD(Maximum Drawdown) 관리 - CLAUDE.md 섹션 6.3
|
||||
|
||||
- 포트폴리오 최고점 대비 하락률 추적
|
||||
- -10% 돌파 시: 신규 진입 중단 + 기존 포지션 30% 축소
|
||||
- -20% 돌파 시: 전체 포지션 50% 강제 축소 (레버리지/인버스 우선)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MDDManager:
|
||||
"""포트폴리오 MDD 관리자 (싱글톤으로 사용)"""
|
||||
|
||||
def __init__(self):
|
||||
self.peak_balance: float = 0.0
|
||||
self.is_paused: bool = False
|
||||
self.reduction_applied: str = None # None, "10pct", "20pct"
|
||||
self._lock = asyncio.Lock() # 스케줄러-sync 동시 접근 방지
|
||||
|
||||
async def update(self, current_balance: float) -> Dict:
|
||||
"""MDD 업데이트 및 필요한 조치 반환 (thread-safe)
|
||||
|
||||
Returns:
|
||||
{"action": "NONE"/"REDUCE_AND_PAUSE"/"FORCE_REDUCE",
|
||||
"drawdown": float, ...}
|
||||
"""
|
||||
async with self._lock:
|
||||
if current_balance > self.peak_balance:
|
||||
self.peak_balance = current_balance
|
||||
self.is_paused = False
|
||||
self.reduction_applied = None
|
||||
|
||||
if self.peak_balance <= 0:
|
||||
return {"action": "NONE", "drawdown": 0.0}
|
||||
|
||||
drawdown_pct = ((current_balance - self.peak_balance) / self.peak_balance) * 100
|
||||
|
||||
# -20% 돌파: 50% 강제 축소
|
||||
if drawdown_pct <= -20 and self.reduction_applied != "20pct":
|
||||
self.is_paused = True
|
||||
self.reduction_applied = "20pct"
|
||||
logger.warning(f"MDD {drawdown_pct:.1f}% - 50% 강제 축소 + 신규진입 중단")
|
||||
return {
|
||||
"action": "FORCE_REDUCE",
|
||||
"drawdown": drawdown_pct,
|
||||
"reduce_pct": 0.50,
|
||||
"prioritize_leverage": True,
|
||||
}
|
||||
|
||||
# -10% 돌파: 30% 축소 + 신규진입 중단
|
||||
if drawdown_pct <= -10 and self.reduction_applied is None:
|
||||
self.is_paused = True
|
||||
self.reduction_applied = "10pct"
|
||||
logger.warning(f"MDD {drawdown_pct:.1f}% - 30% 축소 + 신규진입 중단")
|
||||
return {
|
||||
"action": "REDUCE_AND_PAUSE",
|
||||
"drawdown": drawdown_pct,
|
||||
"reduce_pct": 0.30,
|
||||
}
|
||||
|
||||
return {"action": "NONE", "drawdown": drawdown_pct}
|
||||
|
||||
async def can_open_new_position(self) -> bool:
|
||||
"""신규 포지션 진입 가능 여부 (thread-safe)"""
|
||||
async with self._lock:
|
||||
return not self.is_paused
|
||||
|
||||
def reset(self):
|
||||
"""MDD 상태 리셋 (수동 복구 시) — 동기 메서드, 단독 호출 시에만 사용"""
|
||||
self.is_paused = False
|
||||
self.reduction_applied = None
|
||||
logger.info("MDD 관리자 리셋")
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
mdd_manager = MDDManager()
|
||||
340
backend/app/services/mean_reversion_strategy.py
Normal file
340
backend/app/services/mean_reversion_strategy.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
RSI + 볼린저 밴드 평균회귀 전략 (CLAUDE.md 섹션 7.1-7.5)
|
||||
|
||||
일봉 기준, 전체 자본의 최대 30% 이내에서만 운용.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL = "bull"
|
||||
BEAR = "bear"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitSignal:
|
||||
action: str # "SELL_PARTIAL", "SELL_ALL"
|
||||
reason: str
|
||||
sell_pct: float # 0.5 = 50%, 1.0 = 전량
|
||||
|
||||
|
||||
def calculate_atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""ATR(Average True Range) 계산
|
||||
|
||||
TR = max(H-L, |H-Cprev|, |L-Cprev|)
|
||||
ATR = SMA(TR, period)
|
||||
"""
|
||||
if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
true_ranges = []
|
||||
for i in range(1, len(highs)):
|
||||
h, l, c_prev = highs[i], lows[i], closes[i - 1]
|
||||
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
|
||||
true_ranges.append(tr)
|
||||
|
||||
if len(true_ranges) < period:
|
||||
return None
|
||||
return sum(true_ranges[-period:]) / period
|
||||
|
||||
|
||||
def calculate_adx(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""Wilder's ADX(Average Directional Index) 계산 — 추세 강도 지표
|
||||
|
||||
알고리즘:
|
||||
1. TR = max(H-L, |H-Cprev|, |L-Cprev|)
|
||||
2. +DM = H-Hprev if (H-Hprev) > (Lprev-L) and > 0, else 0
|
||||
3. -DM = Lprev-L if (Lprev-L) > (H-Hprev) and > 0, else 0
|
||||
4. Wilder 평활: ATR14 = ATR14_prev - ATR14_prev/14 + TR (초기값은 14일 합산)
|
||||
5. +DI14 = 100 × (+DM14 / ATR14), -DI14 동일
|
||||
6. DX = 100 × |+DI14 - -DI14| / (+DI14 + -DI14)
|
||||
7. ADX = DX의 Wilder 평활
|
||||
|
||||
해석 (평균회귀 전략):
|
||||
- ADX < 20: 횡보장 → 평균회귀 유리
|
||||
- ADX 20~30: 중간 추세 → 조건부 허용
|
||||
- ADX > 30: 강한 추세 → 평균회귀 위험 (진입 차단)
|
||||
"""
|
||||
n = len(highs)
|
||||
# 최소 2*period + 1개 필요 (초기 평활 + ADX 평활)
|
||||
if n < period * 2 + 1:
|
||||
return None
|
||||
|
||||
trs: List[float] = []
|
||||
plus_dms: List[float] = []
|
||||
minus_dms: List[float] = []
|
||||
|
||||
for i in range(1, n):
|
||||
h, l, c_prev = highs[i], lows[i], closes[i - 1]
|
||||
h_prev, l_prev = highs[i - 1], lows[i - 1]
|
||||
|
||||
tr = max(h - l, abs(h - c_prev), abs(l - c_prev))
|
||||
up_move = h - h_prev
|
||||
down_move = l_prev - l
|
||||
|
||||
plus_dm = up_move if up_move > down_move and up_move > 0 else 0.0
|
||||
minus_dm = down_move if down_move > up_move and down_move > 0 else 0.0
|
||||
|
||||
trs.append(tr)
|
||||
plus_dms.append(plus_dm)
|
||||
minus_dms.append(minus_dm)
|
||||
|
||||
def _wilder_smooth(values: List[float]) -> List[float]:
|
||||
"""Wilder 평활: 초기값 = 첫 period개 합산, 이후 재귀 평활."""
|
||||
smoothed = [sum(values[:period])]
|
||||
for v in values[period:]:
|
||||
smoothed.append(smoothed[-1] - smoothed[-1] / period + v)
|
||||
return smoothed
|
||||
|
||||
atr_s = _wilder_smooth(trs)
|
||||
plus_dm_s = _wilder_smooth(plus_dms)
|
||||
minus_dm_s = _wilder_smooth(minus_dms)
|
||||
|
||||
dxs: List[float] = []
|
||||
for atr_v, p_dm, m_dm in zip(atr_s, plus_dm_s, minus_dm_s):
|
||||
if atr_v == 0:
|
||||
dxs.append(0.0)
|
||||
continue
|
||||
plus_di = 100.0 * p_dm / atr_v
|
||||
minus_di = 100.0 * m_dm / atr_v
|
||||
di_sum = plus_di + minus_di
|
||||
dxs.append(100.0 * abs(plus_di - minus_di) / di_sum if di_sum else 0.0)
|
||||
|
||||
if len(dxs) < period:
|
||||
return None
|
||||
|
||||
adx_s = _wilder_smooth(dxs)
|
||||
return round(adx_s[-1], 2)
|
||||
|
||||
|
||||
def detect_market_regime(index_closes: List[float]) -> MarketRegime:
|
||||
"""섹션 7.1: 200일 MA + 기울기로 상승장/하락장 판별
|
||||
|
||||
상승장(Bull): 지수 > MA200 AND MA200 기울기 양(+)
|
||||
하락장(Bear): 그 외
|
||||
"""
|
||||
if len(index_closes) < 205:
|
||||
return MarketRegime.BEAR # 데이터 부족 시 보수적
|
||||
|
||||
ma200_current = sum(index_closes[-200:]) / 200
|
||||
ma200_5d_ago = sum(index_closes[-205:-5]) / 200
|
||||
slope_positive = ma200_current > ma200_5d_ago
|
||||
|
||||
is_bull = index_closes[-1] > ma200_current and slope_positive
|
||||
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
|
||||
|
||||
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""RSI(Relative Strength Index) 계산"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gains.append(max(diff, 0))
|
||||
losses.append(max(-diff, 0))
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def calculate_bollinger(closes: List[float], period: int = 20) -> Optional[Tuple[float, float, float]]:
|
||||
"""볼린저 밴드 (20일, 2σ) 계산
|
||||
|
||||
Returns: (upper, middle, lower)
|
||||
"""
|
||||
if len(closes) < period:
|
||||
return None
|
||||
window = closes[-period:]
|
||||
middle = sum(window) / period
|
||||
variance = sum((p - middle) ** 2 for p in window) / period
|
||||
std = variance ** 0.5
|
||||
return (middle + 2 * std, middle, middle - 2 * std)
|
||||
|
||||
|
||||
def is_reversal_candle(opens: List[float], highs: List[float],
|
||||
lows: List[float], closes: List[float]) -> bool:
|
||||
"""하락장 매수 조건용 되돌림 캔들 감지 (섹션 7.2)
|
||||
|
||||
양봉 또는 긴 하단 꼬리(하단 그림자 > 2× 몸통)
|
||||
"""
|
||||
if not closes:
|
||||
return False
|
||||
o, h, l, c = opens[-1], highs[-1], lows[-1], closes[-1]
|
||||
body = abs(c - o)
|
||||
lower_shadow = min(o, c) - l
|
||||
is_bullish = c > o
|
||||
has_long_lower_shadow = lower_shadow > 2 * max(body, 0.01)
|
||||
return is_bullish or has_long_lower_shadow
|
||||
|
||||
|
||||
def check_buy_signal(
|
||||
regime: MarketRegime,
|
||||
rsi: float,
|
||||
close: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
avg_volume_20d: float,
|
||||
current_volume: int,
|
||||
prev_volume: int,
|
||||
is_reversal: bool = False,
|
||||
) -> Optional[Dict]:
|
||||
"""섹션 7.2: 매수 조건 판별
|
||||
|
||||
Bull: RSI(14) ≤ 25 (과매도) OR 거래량 ≥ 20일평균×200%, 종가가 BB 하단선 -3% ~ +1.5% 구간
|
||||
Bear: RSI(14) ≤ 35 (과매도), 종가 ≤ BB 하단선, 되돌림 캔들 출현, 전일 대비 거래량 증가
|
||||
|
||||
공통: 거래대금 ≥ 20일 평균
|
||||
"""
|
||||
if current_volume < avg_volume_20d:
|
||||
return None
|
||||
|
||||
if regime == MarketRegime.BULL:
|
||||
rsi_ok = rsi <= 25
|
||||
vol_ok = current_volume >= avg_volume_20d * 2 # 거래량 20일평균 200% 이상
|
||||
if rsi_ok or vol_ok:
|
||||
bb_low_minus = bb_lower * 0.97 # -3%
|
||||
bb_low_plus = bb_lower * 1.015 # +1.5%
|
||||
if bb_low_minus <= close <= bb_low_plus:
|
||||
trigger = f"RSI({rsi:.1f})<=25" if rsi_ok else f"거래량({current_volume / avg_volume_20d * 100:.0f}%>=200%)"
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strategy": "MEAN_REVERSION_BULL",
|
||||
"split_plan": [0.50, 0.50], # 2회 분할 매수
|
||||
"description": f"{trigger} + BB 하단 -3%~+1.5% (상승장 과매도)",
|
||||
}
|
||||
|
||||
elif regime == MarketRegime.BEAR:
|
||||
if rsi <= 35: # 40 → 35: 더 낮은 가격에서 진입
|
||||
# 하락장 진입 필터: 거래량 전일 대비 증가 확인 (매수세 유입)
|
||||
if close <= bb_lower and is_reversal and current_volume > prev_volume:
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strategy": "MEAN_REVERSION_BEAR",
|
||||
"split_plan": [0.30, 0.30, 0.40], # 3회 분할 매수
|
||||
"description": f"RSI({rsi:.1f})<=35 + BB 하단 + 되돌림 + 거래량↑ (하락장 과매도)",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_exit_signal(
|
||||
regime: MarketRegime,
|
||||
rsi: float,
|
||||
close: float,
|
||||
bb_upper: float,
|
||||
bb_middle: float,
|
||||
bb_lower: float,
|
||||
entry_price: float,
|
||||
atr: float,
|
||||
lowest_since_entry: float,
|
||||
remaining_pct: float,
|
||||
) -> Optional[ExitSignal]:
|
||||
"""섹션 7.3 + 7.4: 청산 조건
|
||||
|
||||
손절: min(최저가 - 2.5×ATR, 진입가 - 8%) 중 높은 값(타이트한 것)
|
||||
1차 익절: Bull 30% (트렌드 팔로잉), Bear 50% (리스크 관리)
|
||||
2차 익절(전량): RSI ≥ 60 진입 + 종가 ≥ BB 상단선 근처
|
||||
"""
|
||||
# 손절 체크 (섹션 7.4)
|
||||
atr_stop = lowest_since_entry - 2.5 * atr
|
||||
pct_stop = entry_price * 0.92 # -8%
|
||||
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
|
||||
|
||||
if close <= stop_price:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR={atr:.0f})",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
# 1차 익절: 시장 상태별 비중 조정 (RSI 45~50 회복 + BB 중앙선 상회)
|
||||
if regime == MarketRegime.BULL:
|
||||
# 상승장: 30% 익절 (트렌드 팔로잉)
|
||||
if remaining_pct > 0.7 and 45 <= rsi <= 50 and close > bb_middle:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"1차 익절(Bull 30%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
|
||||
sell_pct=0.30,
|
||||
)
|
||||
else:
|
||||
# 하락장: 50% 익절 (리스크 관리)
|
||||
if remaining_pct > 0.5 and 45 <= rsi <= 50 and close > bb_middle:
|
||||
return ExitSignal(
|
||||
action="SELL_PARTIAL",
|
||||
reason=f"1차 익절(Bear 50%): RSI({rsi:.1f}) 45-50 회복 + BB중앙({bb_middle:.0f}) 상회",
|
||||
sell_pct=0.50,
|
||||
)
|
||||
|
||||
# 2차 익절: 전량 매도 (RSI ≥ 60 진입 + BB 상단선 근처)
|
||||
threshold = 0.7 if regime == MarketRegime.BULL else 0.5
|
||||
if remaining_pct <= threshold and rsi >= 60:
|
||||
if close >= bb_upper * 0.99:
|
||||
return ExitSignal(
|
||||
action="SELL_ALL",
|
||||
reason=f"2차 익절: RSI({rsi:.1f})>=60 진입 + BB상단({bb_upper:.0f}) 도달",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def calculate_position_size(
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_strategy_allocation: float,
|
||||
regime: MarketRegime,
|
||||
max_per_position: float = 0.04,
|
||||
max_strategy_capital_bull: float = 0.70,
|
||||
max_strategy_capital_bear: float = 0.30,
|
||||
) -> float:
|
||||
"""섹션 6.1 + 7.5: 포지션 사이징 (시장 상태별 동적 배분)
|
||||
|
||||
- 1종목당 최대 3-4% (max_per_position)
|
||||
- 상승장(Bull): 전략 전체 최대 70% 운용 (공격적)
|
||||
- 하락장(Bear): 전략 전체 최대 30% 운용 (보수적)
|
||||
"""
|
||||
# 시장 상태에 따라 최대 자본 비중 결정
|
||||
max_strategy_capital = max_strategy_capital_bull if regime == MarketRegime.BULL else max_strategy_capital_bear
|
||||
|
||||
max_by_position = total_assets * max_per_position
|
||||
remaining_budget = (total_assets * max_strategy_capital) - current_strategy_allocation
|
||||
return max(0, min(max_by_position, remaining_budget, available_cash))
|
||||
|
||||
|
||||
def check_h1_breakout_with_ma5(
|
||||
h1_closes: List[float],
|
||||
h1_current_close: float,
|
||||
) -> bool:
|
||||
"""섹션 7.5: 60분봉 5일선 돌파 확인 (가짜 반등 필터)
|
||||
|
||||
60분봉 기준 5일(120시간 = 5일×24시간) 이동평균선 상향 돌파 여부 확인
|
||||
실제로는 5일 분량의 60분봉 데이터가 필요: 5일 × 6.5시간(장 운영) = 약 30~40개 봉
|
||||
|
||||
Args:
|
||||
h1_closes: 60분봉 종가 리스트 (최소 5개 이상)
|
||||
h1_current_close: 현재 60분봉 종가
|
||||
|
||||
Returns:
|
||||
True: 5일선 상향 돌파, False: 미돌파
|
||||
|
||||
Note:
|
||||
- 60분봉 데이터가 충분하지 않으면 False 반환 (보수적)
|
||||
- 복합 타임프레임 전략에서 선택적으로 사용
|
||||
"""
|
||||
if len(h1_closes) < 5:
|
||||
return False # 데이터 부족 시 보수적 대응
|
||||
|
||||
ma5 = sum(h1_closes[-5:]) / 5
|
||||
return h1_current_close > ma5
|
||||
560
backend/app/services/ml_ensemble.py
Normal file
560
backend/app/services/ml_ensemble.py
Normal file
@@ -0,0 +1,560 @@
|
||||
"""
|
||||
ML 앙상블 모델 서비스
|
||||
|
||||
3개 모델을 결합하여 주식 매수/매도 신호를 예측합니다:
|
||||
- LightGBM (40%): 탭뷸러 피처 기반 분류
|
||||
- PyTorch LSTM (35%): 시계열 패턴 학습
|
||||
- Random Forest (25%): 피처 조합 기반 분류
|
||||
|
||||
앙상블 스코어: 0~100 (높을수록 매수 신호 강함)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import joblib
|
||||
|
||||
from app.services.feature_engineering import (
|
||||
FEATURE_COLUMNS,
|
||||
LSTM_SEQUENCE_FEATURES,
|
||||
extract_single_features,
|
||||
chart_data_to_dataframe,
|
||||
extract_features,
|
||||
prepare_training_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 모델 저장 디렉토리
|
||||
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
|
||||
|
||||
|
||||
# ─── PyTorch LSTM 모델 정의 ───
|
||||
|
||||
def _get_torch():
|
||||
"""PyTorch lazy import"""
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
return torch, nn
|
||||
except ImportError:
|
||||
return None, None
|
||||
|
||||
|
||||
class StockLSTM:
|
||||
"""PyTorch LSTM 모델 래퍼"""
|
||||
|
||||
def __init__(self, input_size: int = 5, hidden_size: int = 64, num_layers: int = 2,
|
||||
num_classes: int = 3, dropout: float = 0.3):
|
||||
self.input_size = input_size
|
||||
self.hidden_size = hidden_size
|
||||
self.num_layers = num_layers
|
||||
self.num_classes = num_classes
|
||||
self.dropout = dropout
|
||||
self.model = None
|
||||
self.device = None
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
torch, nn = _get_torch()
|
||||
if torch is None:
|
||||
logger.warning("PyTorch not available, LSTM model disabled")
|
||||
return
|
||||
|
||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
class LSTMNet(nn.Module):
|
||||
def __init__(self_, input_size, hidden_size, num_layers, num_classes, dropout):
|
||||
super().__init__()
|
||||
self_.lstm = nn.LSTM(
|
||||
input_size=input_size,
|
||||
hidden_size=hidden_size,
|
||||
num_layers=num_layers,
|
||||
batch_first=True,
|
||||
dropout=dropout if num_layers > 1 else 0,
|
||||
)
|
||||
self_.fc1 = nn.Linear(hidden_size, 32)
|
||||
self_.relu = nn.ReLU()
|
||||
self_.drop = nn.Dropout(dropout)
|
||||
self_.fc2 = nn.Linear(32, num_classes)
|
||||
|
||||
def forward(self_, x):
|
||||
out, _ = self_.lstm(x)
|
||||
out = out[:, -1, :] # 마지막 타임스텝
|
||||
out = self_.drop(self_.relu(self_.fc1(out)))
|
||||
out = self_.fc2(out)
|
||||
return out
|
||||
|
||||
self.model = LSTMNet(
|
||||
self.input_size, self.hidden_size, self.num_layers,
|
||||
self.num_classes, self.dropout,
|
||||
).to(self.device)
|
||||
|
||||
def train_model(self, X_seq: np.ndarray, y: np.ndarray,
|
||||
epochs: int = 50, lr: float = 0.001, batch_size: int = 32) -> Dict[str, float]:
|
||||
"""LSTM 모델 학습"""
|
||||
torch, nn = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return {"error": "PyTorch not available"}
|
||||
|
||||
from torch.utils.data import TensorDataset, DataLoader
|
||||
|
||||
# 시퀀스 피처 정규화 (채널별)
|
||||
self.seq_mean = X_seq.mean(axis=(0, 1))
|
||||
self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
y_tensor = torch.LongTensor(y).to(self.device)
|
||||
|
||||
dataset = TensorDataset(X_tensor, y_tensor)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr)
|
||||
|
||||
self.model.train()
|
||||
best_loss = float("inf")
|
||||
|
||||
for epoch in range(epochs):
|
||||
total_loss = 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
for X_batch, y_batch in loader:
|
||||
optimizer.zero_grad()
|
||||
outputs = self.model(X_batch)
|
||||
loss = criterion(outputs, y_batch)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
total_loss += loss.item() * len(y_batch)
|
||||
_, predicted = torch.max(outputs, 1)
|
||||
correct += (predicted == y_batch).sum().item()
|
||||
total += len(y_batch)
|
||||
|
||||
avg_loss = total_loss / total
|
||||
accuracy = correct / total
|
||||
if avg_loss < best_loss:
|
||||
best_loss = avg_loss
|
||||
|
||||
if (epoch + 1) % 10 == 0:
|
||||
logger.info(f"LSTM Epoch [{epoch+1}/{epochs}] Loss: {avg_loss:.4f} Acc: {accuracy:.4f}")
|
||||
|
||||
return {"final_loss": best_loss, "final_accuracy": accuracy}
|
||||
|
||||
def predict_proba(self, X_seq: np.ndarray) -> np.ndarray:
|
||||
"""예측 확률 반환 (N, 3)"""
|
||||
torch, nn = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return np.full((len(X_seq), 3), 1.0 / 3)
|
||||
|
||||
self.model.eval()
|
||||
X_norm = (X_seq - self.seq_mean) / self.seq_std
|
||||
X_tensor = torch.FloatTensor(X_norm).to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.model(X_tensor)
|
||||
probs = torch.softmax(outputs, dim=1).cpu().numpy()
|
||||
return probs
|
||||
|
||||
def save(self, path: Path):
|
||||
torch, _ = _get_torch()
|
||||
if torch is None or self.model is None:
|
||||
return
|
||||
torch.save({
|
||||
"model_state": self.model.state_dict(),
|
||||
"seq_mean": self.seq_mean,
|
||||
"seq_std": self.seq_std,
|
||||
"config": {
|
||||
"input_size": self.input_size,
|
||||
"hidden_size": self.hidden_size,
|
||||
"num_layers": self.num_layers,
|
||||
"num_classes": self.num_classes,
|
||||
"dropout": self.dropout,
|
||||
},
|
||||
}, path)
|
||||
|
||||
def load(self, path: Path) -> bool:
|
||||
torch, _ = _get_torch()
|
||||
if torch is None:
|
||||
return False
|
||||
try:
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||
self.model.load_state_dict(checkpoint["model_state"])
|
||||
self.seq_mean = checkpoint["seq_mean"]
|
||||
self.seq_std = checkpoint["seq_std"]
|
||||
self.model.eval()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load LSTM model: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ─── 앙상블 모델 클래스 ───
|
||||
|
||||
class MLEnsemble:
|
||||
"""3개 모델(LightGBM + LSTM + RF)을 앙상블하여 주식 매수/매도 예측"""
|
||||
|
||||
def __init__(self, model_dir: Path = MODEL_DIR,
|
||||
weights: Tuple[float, float, float] = (0.40, 0.35, 0.25)):
|
||||
self.model_dir = model_dir
|
||||
self.weights = weights # (lgbm, lstm, rf)
|
||||
|
||||
self.lgbm_model = None
|
||||
self.lstm_model = None
|
||||
self.rf_model = None
|
||||
self.scaler = None # tabular feature scaler
|
||||
|
||||
self.is_trained = False
|
||||
self.training_metrics: Dict[str, Any] = {}
|
||||
|
||||
def _ensure_dir(self):
|
||||
self.model_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def train(self, X_tabular: np.ndarray, X_seq: np.ndarray, y: np.ndarray) -> Dict[str, Any]:
|
||||
"""전체 앙상블 학습
|
||||
|
||||
Args:
|
||||
X_tabular: (N, 20) 탭뷸러 피처
|
||||
X_seq: (N, 60, 5) LSTM 시퀀스
|
||||
y: (N,) 레이블 (0=DOWN, 1=HOLD, 2=UP)
|
||||
|
||||
Returns:
|
||||
학습 메트릭
|
||||
"""
|
||||
self._ensure_dir()
|
||||
metrics = {}
|
||||
|
||||
if len(X_tabular) < 50:
|
||||
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
|
||||
|
||||
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
|
||||
|
||||
# 탭뷸러 피처 스케일링
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
self.scaler = StandardScaler()
|
||||
X_scaled = self.scaler.fit_transform(X_tabular)
|
||||
|
||||
# 학습/검증 분리 (시계열이므로 뒤쪽 20%를 검증으로)
|
||||
split_idx = int(len(X_scaled) * 0.8)
|
||||
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
|
||||
y_train, y_val = y[:split_idx], y[split_idx:]
|
||||
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
|
||||
|
||||
# 1. LightGBM
|
||||
logger.info(">>> LightGBM 학습 중...")
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
self.lgbm_model = lgb.LGBMClassifier(
|
||||
n_estimators=200,
|
||||
max_depth=6,
|
||||
learning_rate=0.05,
|
||||
num_leaves=31,
|
||||
min_child_samples=10,
|
||||
subsample=0.8,
|
||||
colsample_bytree=0.8,
|
||||
reg_alpha=0.1,
|
||||
reg_lambda=0.1,
|
||||
random_state=42,
|
||||
verbose=-1,
|
||||
num_class=3,
|
||||
objective="multiclass",
|
||||
)
|
||||
self.lgbm_model.fit(
|
||||
X_train, y_train,
|
||||
eval_set=[(X_val, y_val)],
|
||||
)
|
||||
lgbm_val_pred = self.lgbm_model.predict(X_val)
|
||||
lgbm_acc = np.mean(lgbm_val_pred == y_val)
|
||||
metrics["lgbm_accuracy"] = float(lgbm_acc)
|
||||
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
|
||||
|
||||
# 피처 중요도
|
||||
importance = self.lgbm_model.feature_importances_
|
||||
top_features = sorted(
|
||||
zip(FEATURE_COLUMNS, importance),
|
||||
key=lambda x: x[1], reverse=True
|
||||
)[:5]
|
||||
metrics["lgbm_top_features"] = [
|
||||
{"name": name, "importance": int(imp)} for name, imp in top_features
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"LightGBM 학습 실패: {e}")
|
||||
metrics["lgbm_error"] = str(e)
|
||||
|
||||
# 2. PyTorch LSTM
|
||||
logger.info(">>> PyTorch LSTM 학습 중...")
|
||||
try:
|
||||
self.lstm_model = StockLSTM(
|
||||
input_size=len(LSTM_SEQUENCE_FEATURES),
|
||||
hidden_size=64,
|
||||
num_layers=2,
|
||||
num_classes=3,
|
||||
dropout=0.3,
|
||||
)
|
||||
lstm_metrics = self.lstm_model.train_model(
|
||||
X_seq_train, y_train,
|
||||
epochs=50, lr=0.001, batch_size=32,
|
||||
)
|
||||
# 검증
|
||||
lstm_val_probs = self.lstm_model.predict_proba(X_seq_val)
|
||||
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
|
||||
lstm_acc = np.mean(lstm_val_pred == y_val)
|
||||
metrics["lstm_accuracy"] = float(lstm_acc)
|
||||
metrics["lstm_training"] = lstm_metrics
|
||||
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"LSTM 학습 실패: {e}")
|
||||
metrics["lstm_error"] = str(e)
|
||||
|
||||
# 3. Random Forest
|
||||
logger.info(">>> Random Forest 학습 중...")
|
||||
try:
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
self.rf_model = RandomForestClassifier(
|
||||
n_estimators=100,
|
||||
max_depth=8,
|
||||
min_samples_split=10,
|
||||
min_samples_leaf=5,
|
||||
random_state=42,
|
||||
n_jobs=-1,
|
||||
)
|
||||
self.rf_model.fit(X_train, y_train)
|
||||
rf_val_pred = self.rf_model.predict(X_val)
|
||||
rf_acc = np.mean(rf_val_pred == y_val)
|
||||
metrics["rf_accuracy"] = float(rf_acc)
|
||||
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Random Forest 학습 실패: {e}")
|
||||
metrics["rf_error"] = str(e)
|
||||
|
||||
# 앙상블 검증
|
||||
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val)
|
||||
if ensemble_probs is not None:
|
||||
ensemble_pred = np.argmax(ensemble_probs, axis=1)
|
||||
ensemble_acc = np.mean(ensemble_pred == y_val)
|
||||
metrics["ensemble_accuracy"] = float(ensemble_acc)
|
||||
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
|
||||
|
||||
# 모델 저장
|
||||
self._save_models()
|
||||
self.is_trained = True
|
||||
self.training_metrics = metrics
|
||||
|
||||
logger.info(f"앙상블 학습 완료: {metrics}")
|
||||
return metrics
|
||||
|
||||
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""3개 모델의 가중 평균 확률"""
|
||||
probs_list = []
|
||||
weights_list = []
|
||||
|
||||
# LightGBM
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
lgbm_probs = self.lgbm_model.predict_proba(X_tabular)
|
||||
probs_list.append(lgbm_probs)
|
||||
weights_list.append(self.weights[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# LSTM
|
||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||
try:
|
||||
lstm_probs = self.lstm_model.predict_proba(X_seq)
|
||||
probs_list.append(lstm_probs)
|
||||
weights_list.append(self.weights[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# RF
|
||||
if self.rf_model is not None:
|
||||
try:
|
||||
rf_probs = self.rf_model.predict_proba(X_tabular)
|
||||
probs_list.append(rf_probs)
|
||||
weights_list.append(self.weights[2])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not probs_list:
|
||||
return None
|
||||
|
||||
# 가중 평균
|
||||
total_weight = sum(weights_list)
|
||||
weighted_probs = sum(
|
||||
p * (w / total_weight) for p, w in zip(probs_list, weights_list)
|
||||
)
|
||||
return weighted_probs
|
||||
|
||||
def predict(self, chart_data: list) -> Dict[str, Any]:
|
||||
"""단일 종목 예측.
|
||||
|
||||
Args:
|
||||
chart_data: KIS API 차트 데이터 (최신순)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ensemble_score": int (0~100),
|
||||
"confidence": float (0~1),
|
||||
"signal": str ("BUY"/"HOLD"/"SELL"),
|
||||
"model_scores": {"lgbm": ..., "lstm": ..., "rf": ...},
|
||||
"probabilities": {"up": ..., "hold": ..., "down": ...}
|
||||
}
|
||||
"""
|
||||
if not self.is_trained:
|
||||
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
tabular, sequence = extract_single_features(chart_data)
|
||||
if tabular is None or sequence is None:
|
||||
return {"error": "피처 추출 실패 (데이터 부족)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
# 스케일링
|
||||
tabular_scaled = self.scaler.transform(tabular.reshape(1, -1))
|
||||
sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES))
|
||||
|
||||
# 앙상블 예측
|
||||
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch)
|
||||
if probs is None:
|
||||
return {"error": "모든 모델 예측 실패", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||
|
||||
prob_down, prob_hold, prob_up = probs[0]
|
||||
|
||||
# 앙상블 스코어: UP 확률 기반 (0~100)
|
||||
ensemble_score = int(prob_up * 100)
|
||||
|
||||
# 신뢰도: 최고 확률과 나머지의 차이
|
||||
max_prob = max(prob_down, prob_hold, prob_up)
|
||||
confidence = float(max_prob)
|
||||
|
||||
# 신호 결정
|
||||
if prob_up > prob_down and prob_up > prob_hold and prob_up > 0.4:
|
||||
signal = "BUY"
|
||||
elif prob_down > prob_up and prob_down > prob_hold and prob_down > 0.4:
|
||||
signal = "SELL"
|
||||
else:
|
||||
signal = "HOLD"
|
||||
|
||||
# 개별 모델 스코어
|
||||
model_scores = {}
|
||||
if self.lgbm_model is not None:
|
||||
try:
|
||||
lp = self.lgbm_model.predict_proba(tabular_scaled)[0]
|
||||
model_scores["lgbm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||
try:
|
||||
lp = self.lstm_model.predict_proba(sequence_batch)[0]
|
||||
model_scores["lstm"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
if self.rf_model is not None:
|
||||
try:
|
||||
lp = self.rf_model.predict_proba(tabular_scaled)[0]
|
||||
model_scores["rf"] = {"up": float(lp[2]), "hold": float(lp[1]), "down": float(lp[0])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ensemble_score": ensemble_score,
|
||||
"confidence": round(confidence, 4),
|
||||
"signal": signal,
|
||||
"model_scores": model_scores,
|
||||
"probabilities": {
|
||||
"up": round(float(prob_up), 4),
|
||||
"hold": round(float(prob_hold), 4),
|
||||
"down": round(float(prob_down), 4),
|
||||
},
|
||||
}
|
||||
|
||||
def _save_models(self):
|
||||
"""모델 파일 저장"""
|
||||
self._ensure_dir()
|
||||
|
||||
if self.lgbm_model is not None:
|
||||
joblib.dump(self.lgbm_model, self.model_dir / "lgbm_model.pkl")
|
||||
if self.rf_model is not None:
|
||||
joblib.dump(self.rf_model, self.model_dir / "rf_model.pkl")
|
||||
if self.lstm_model is not None:
|
||||
self.lstm_model.save(self.model_dir / "lstm_model.pt")
|
||||
if self.scaler is not None:
|
||||
joblib.dump(self.scaler, self.model_dir / "scaler.pkl")
|
||||
|
||||
# 메트릭 저장
|
||||
import json
|
||||
with open(self.model_dir / "metrics.json", "w") as f:
|
||||
json.dump(self.training_metrics, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
logger.info(f"모델 저장 완료: {self.model_dir}")
|
||||
|
||||
def load_models(self) -> bool:
|
||||
"""저장된 모델 로드"""
|
||||
try:
|
||||
scaler_path = self.model_dir / "scaler.pkl"
|
||||
if not scaler_path.exists():
|
||||
logger.warning("저장된 앙상블 모델이 없습니다")
|
||||
return False
|
||||
|
||||
self.scaler = joblib.load(scaler_path)
|
||||
|
||||
lgbm_path = self.model_dir / "lgbm_model.pkl"
|
||||
if lgbm_path.exists():
|
||||
self.lgbm_model = joblib.load(lgbm_path)
|
||||
|
||||
rf_path = self.model_dir / "rf_model.pkl"
|
||||
if rf_path.exists():
|
||||
self.rf_model = joblib.load(rf_path)
|
||||
|
||||
lstm_path = self.model_dir / "lstm_model.pt"
|
||||
if lstm_path.exists():
|
||||
self.lstm_model = StockLSTM(
|
||||
input_size=len(LSTM_SEQUENCE_FEATURES),
|
||||
hidden_size=64, num_layers=2, num_classes=3, dropout=0.3,
|
||||
)
|
||||
self.lstm_model.load(lstm_path)
|
||||
|
||||
# 메트릭 로드
|
||||
metrics_path = self.model_dir / "metrics.json"
|
||||
if metrics_path.exists():
|
||||
import json
|
||||
with open(metrics_path) as f:
|
||||
self.training_metrics = json.load(f)
|
||||
|
||||
self.is_trained = True
|
||||
logger.info("앙상블 모델 로드 완료")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"앙상블 모델 로드 실패: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""모델 상태 조회"""
|
||||
return {
|
||||
"is_trained": self.is_trained,
|
||||
"models": {
|
||||
"lgbm": self.lgbm_model is not None,
|
||||
"lstm": self.lstm_model is not None and self.lstm_model.model is not None,
|
||||
"rf": self.rf_model is not None,
|
||||
},
|
||||
"weights": {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]},
|
||||
"metrics": self.training_metrics,
|
||||
"model_dir": str(self.model_dir),
|
||||
}
|
||||
|
||||
|
||||
# ─── 싱글톤 인스턴스 ───
|
||||
|
||||
_ensemble_instance: Optional[MLEnsemble] = None
|
||||
|
||||
|
||||
def get_ensemble() -> MLEnsemble:
|
||||
"""앙상블 모델 싱글톤 인스턴스 반환"""
|
||||
global _ensemble_instance
|
||||
if _ensemble_instance is None:
|
||||
_ensemble_instance = MLEnsemble()
|
||||
_ensemble_instance.load_models()
|
||||
return _ensemble_instance
|
||||
108
backend/app/services/tensorflow_predictor.py
Normal file
108
backend/app/services/tensorflow_predictor.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TensorFlowNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TensorFlowPredictor:
|
||||
"""Lightweight wrapper around a Keras model + scaler.
|
||||
|
||||
- Lazily attempts to import TensorFlow/Keras and load model files.
|
||||
- If TensorFlow or model/scaler files are missing, raises TensorFlowNotAvailable
|
||||
so callers can handle gracefully (e.g., return 503 or a friendly message).
|
||||
- Fetches recent daily closes from KIS API.
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: str = "models/lstm_model.h5", scaler_path: str = "models/scaler.pkl", time_steps: int = 60):
|
||||
self.model_path = model_path
|
||||
self.scaler_path = scaler_path
|
||||
self.time_steps = time_steps
|
||||
|
||||
try:
|
||||
# Local import to avoid hard dependency when not installed
|
||||
import tensorflow as tf # noqa: F401
|
||||
from tensorflow import keras # noqa: F401
|
||||
import joblib # noqa: F401
|
||||
except Exception as e:
|
||||
logger.warning(f"TensorFlow or joblib not available: {e}")
|
||||
raise TensorFlowNotAvailable("TensorFlow/joblib not installed in backend environment")
|
||||
|
||||
# Load resources
|
||||
try:
|
||||
from tensorflow import keras
|
||||
import joblib
|
||||
|
||||
self.model = keras.models.load_model(self.model_path)
|
||||
self.scaler = joblib.load(self.scaler_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load TF model/scaler: {e}")
|
||||
raise TensorFlowNotAvailable(f"Model or scaler not found: {e}")
|
||||
|
||||
async def fetch_kis_closes(self, stock_code: str, days: int = 60) -> np.ndarray:
|
||||
"""Fetch recent daily closes from KIS. Returns array oldest→latest.
|
||||
Uses korea_investment_api.get_daily_chart(stock_code, "D").
|
||||
"""
|
||||
try:
|
||||
resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||||
output = resp.get("output") or []
|
||||
if not output:
|
||||
raise RuntimeError("empty_chart")
|
||||
# KIS usually returns newest first; reverse to chronological
|
||||
closes_desc = [float(item.get("stck_clpr", 0) or 0) for item in output]
|
||||
closes = list(reversed(closes_desc))
|
||||
arr = np.array(closes[-days:], dtype=float)
|
||||
if arr.size < days:
|
||||
# If fewer than requested, still return what's available
|
||||
logger.info(f"{stock_code}: only {arr.size} closes available (requested {days})")
|
||||
return arr
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch KIS closes for {stock_code}: {e}")
|
||||
raise
|
||||
|
||||
async def predict_stock(self, stock_code: str) -> Dict[str, Any]:
|
||||
"""Return prediction dict with expected_return and simple signal."""
|
||||
try:
|
||||
prices = await self.fetch_kis_closes(stock_code, days=self.time_steps)
|
||||
if prices.size == 0:
|
||||
raise RuntimeError("no_price_data")
|
||||
|
||||
# Normalize and shape for LSTM (1, time_steps, 1)
|
||||
scaled = self.scaler.transform(prices.reshape(-1, 1))
|
||||
if scaled.shape[0] < self.time_steps:
|
||||
# Pad at the front if needed
|
||||
pad = np.repeat(scaled[0:1], self.time_steps - scaled.shape[0], axis=0)
|
||||
scaled = np.vstack([pad, scaled])
|
||||
X_input = scaled[-self.time_steps:].reshape(1, self.time_steps, 1)
|
||||
|
||||
# Predict
|
||||
predicted_scaled = self.model.predict(X_input, verbose=0)
|
||||
predicted_price = float(self.scaler.inverse_transform(predicted_scaled)[0][0])
|
||||
current_price = float(prices[-1])
|
||||
|
||||
expected_return = ((predicted_price - current_price) / current_price * 100.0) if current_price > 0 else 0.0
|
||||
signal = "BUY" if predicted_price > current_price * 1.02 else "HOLD"
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"current_price": current_price,
|
||||
"predicted_price": predicted_price,
|
||||
"expected_return": expected_return,
|
||||
"signal": signal,
|
||||
}
|
||||
except TensorFlowNotAvailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Prediction failed for {stock_code}: {e}")
|
||||
# Degrade gracefully
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"error": str(e),
|
||||
}
|
||||
63
backend/app/services/watchlist.py
Normal file
63
backend/app/services/watchlist.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
분석종목(Watchlist) 관리 공용 서비스
|
||||
|
||||
아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는
|
||||
초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다.
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.models import WatchlistStock, StockAnalysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 분석종목 관련 상수
|
||||
WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수
|
||||
WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수
|
||||
WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수
|
||||
WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수
|
||||
|
||||
|
||||
async def initialize_watchlist(session: AsyncSession, analysis_date: date) -> int:
|
||||
"""아침 배치 완료 후 분석종목 초기화.
|
||||
|
||||
기존 활성 종목을 전부 비활성화하고,
|
||||
오늘 stock_analysis score 상위 WATCHLIST_INIT_SIZE 개로 새로 채운다.
|
||||
|
||||
Returns: 추가된 종목 수
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# 1. 기존 활성 종목 전부 비활성화
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
.values(is_active=False, removed_at=now, removed_reason="daily_reset")
|
||||
)
|
||||
|
||||
# 2. 오늘 score 상위 N개 조회
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(StockAnalysis.analysis_date == analysis_date)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(WATCHLIST_INIT_SIZE)
|
||||
)
|
||||
top_stocks = result.all()
|
||||
|
||||
# 3. 신규 활성 종목 등록
|
||||
for row in top_stocks:
|
||||
session.add(WatchlistStock(
|
||||
stock_code=row.stock_code,
|
||||
stock_name=row.stock_name,
|
||||
added_at=now,
|
||||
added_reason="morning_batch",
|
||||
score_at_add=row.score,
|
||||
is_active=True,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"📋 분석종목 초기화 완료: {len(top_stocks)}개 (기준일: {analysis_date})")
|
||||
return len(top_stocks)
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Dict, List
|
||||
from fastapi import WebSocket
|
||||
import json
|
||||
from app.core.json_helpers import _dumps
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,7 +47,7 @@ class WebSocketManager:
|
||||
|
||||
async def broadcast_json(self, data: dict):
|
||||
"""JSON 데이터를 모든 클라이언트에게 브로드캐스트"""
|
||||
message = json.dumps(data)
|
||||
message = _dumps(data)
|
||||
await self.broadcast(message)
|
||||
|
||||
async def send_market_update(self, stock_code: str, data: dict):
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
- 기술적 지표 계산
|
||||
- 매매 신호 생성
|
||||
- DB 저장
|
||||
|
||||
실행: python batch_daily_analysis.py
|
||||
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
|
||||
"""
|
||||
@@ -19,14 +18,14 @@ from pathlib import Path
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import StockAnalysis
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
from app.api.endpoints.recommendations import analyze_stock_with_indicators
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI, korea_investment_api
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -37,8 +36,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 데이터베이스 연결
|
||||
# SQLAlchemy asyncpg 드라이버 사용 (postgresql+asyncpg://)
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
@@ -52,159 +56,214 @@ AsyncSessionLocal = sessionmaker(
|
||||
)
|
||||
|
||||
|
||||
async def get_all_tradable_stocks() -> list[tuple[str, str]]:
|
||||
"""거래 가능한 전체 종목 조회 (안전한 재시도 적용)"""
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
logger.info("Fetching all tradable stocks from KIS API...")
|
||||
|
||||
stocks = []
|
||||
|
||||
# KOSPI 상위 종목 조회
|
||||
async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]:
|
||||
"""시가총액 상위 종목 조회 (pykrx 사용)
|
||||
|
||||
Args:
|
||||
kis_api: KIS API 인스턴스 (호환성 유지용, 미사용)
|
||||
limit: 조회할 종목 수 (기본 500개)
|
||||
|
||||
Returns:
|
||||
[(stock_code, stock_name), ...] 시가총액 상위 종목 리스트
|
||||
"""
|
||||
logger.info(f"Fetching top {limit} stocks by market cap from pykrx...")
|
||||
|
||||
try:
|
||||
# 재시도 로직이 내장되어 있으므로 sleep 제거 (쓰로틀링은 자동 적용)
|
||||
kospi_result = await kis_api.get_volume_rank("J")
|
||||
if kospi_result and kospi_result.get("output"):
|
||||
for item in kospi_result["output"]:
|
||||
stocks.append((
|
||||
item["mksc_shrn_iscd"],
|
||||
item["hts_kor_isnm"]
|
||||
))
|
||||
logger.info(f"✅ Found {len(stocks)} KOSPI stocks")
|
||||
from pykrx import stock as pykrx_stock
|
||||
import pandas as pd
|
||||
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
stocks_with_cap = []
|
||||
|
||||
# ETF/특수 종목 제외 키워드
|
||||
SKIP_KEYWORDS = [
|
||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
|
||||
"선물", "스팩", "리츠", "SPAC",
|
||||
]
|
||||
|
||||
# KOSPI + KOSDAQ 전체 종목 조회
|
||||
for market in ["KOSPI", "KOSDAQ"]:
|
||||
try:
|
||||
# 시장 전체의 시가총액 데이터를 한 번에 조회 (최적화)
|
||||
cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market)
|
||||
logger.info(f" {market}: {len(cap_df)}개 종목 시가총액 조회")
|
||||
|
||||
for ticker in cap_df.index:
|
||||
try:
|
||||
name = pykrx_stock.get_market_ticker_name(ticker)
|
||||
|
||||
# ETF/특수 종목 필터링
|
||||
if any(k in name for k in SKIP_KEYWORDS):
|
||||
continue
|
||||
|
||||
market_cap = cap_df.loc[ticker, "시가총액"]
|
||||
stocks_with_cap.append((ticker, name, market_cap))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f" Skip {ticker}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to fetch {market} stocks: {e}")
|
||||
|
||||
# 시가총액 기준 내림차순 정렬
|
||||
stocks_with_cap.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
# 상위 N개 선택 (시가총액 제외)
|
||||
top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]]
|
||||
|
||||
logger.info(f"✅ Selected top {len(top_stocks)} stocks by market cap")
|
||||
if top_stocks:
|
||||
kospi_count = sum(1 for c, _ in top_stocks if c.startswith(('0', '1', '2', '3', '4', '5')))
|
||||
kosdaq_count = len(top_stocks) - kospi_count
|
||||
logger.info(f" KOSPI: {kospi_count} stocks")
|
||||
logger.info(f" KOSDAQ: {kosdaq_count} stocks")
|
||||
|
||||
return top_stocks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to fetch KOSPI stocks: {e}")
|
||||
|
||||
# KOSDAQ 상위 종목 조회
|
||||
try:
|
||||
kosdaq_result = await kis_api.get_volume_rank("Q")
|
||||
if kosdaq_result and kosdaq_result.get("output"):
|
||||
kospi_count = len(stocks)
|
||||
for item in kosdaq_result["output"]:
|
||||
stocks.append((
|
||||
item["mksc_shrn_iscd"],
|
||||
item["hts_kor_isnm"]
|
||||
))
|
||||
logger.info(f"✅ Found {len(stocks) - kospi_count} KOSDAQ stocks")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to fetch KOSDAQ stocks: {e}")
|
||||
|
||||
logger.info(f"📊 Total tradable stocks: {len(stocks)}")
|
||||
return stocks
|
||||
logger.error(f"❌ Failed to fetch stocks by market cap: {e}", exc_info=True)
|
||||
logger.warning("⚠️ Falling back to empty list")
|
||||
return []
|
||||
|
||||
|
||||
async def analyze_and_save_stock(
|
||||
session: AsyncSession,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
analysis_date: datetime
|
||||
) -> bool:
|
||||
"""종목 분석 후 DB 저장"""
|
||||
) -> str:
|
||||
"""종목 분석 후 DB 저장 (독립 세션 사용 — 동시 처리 안전)
|
||||
|
||||
ATR은 analyze_stock() 내부에서 이미 계산되어 result.atr_14에 포함됨.
|
||||
이중 API 호출 없음.
|
||||
|
||||
Returns: "ok" | "no_chart" | "price_filter" | "error"
|
||||
"""
|
||||
try:
|
||||
# 종목 분석 (기존 함수 재사용)
|
||||
result = await analyze_stock_with_indicators(stock_code, stock_name)
|
||||
|
||||
# 배치 전용 함수 사용 (거래량/점수 필터 없이 전 종목 지표 저장)
|
||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"No analysis result for {stock_code}")
|
||||
return False
|
||||
|
||||
# DB에 저장
|
||||
analysis = StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=result.score
|
||||
)
|
||||
|
||||
session.add(analysis)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}")
|
||||
return True
|
||||
|
||||
# for_batch=True 에서 None = 차트 데이터 없음 or 가격 0 등 실제 오류
|
||||
logger.info(f"차트없음 {stock_code} ({stock_name}): KIS API 차트 데이터 없음")
|
||||
return "no_chart"
|
||||
|
||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
|
||||
if result.current_price and result.current_price < 5000:
|
||||
logger.info(f"가격필터 {stock_code} ({stock_name}): {result.current_price:.0f}원 < 5,000원")
|
||||
return "price_filter"
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 이미 오늘 분석된 종목이면 삭제 후 재삽입 (중복 방지)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == stock_code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
analysis = StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=result.score,
|
||||
ml_score=result.ml_score,
|
||||
ml_confidence=result.ml_confidence,
|
||||
ml_signal=result.ml_signal,
|
||||
atr_14=result.atr_14,
|
||||
adx_14=result.adx_14,
|
||||
)
|
||||
session.add(analysis)
|
||||
await session.commit()
|
||||
|
||||
ml_info = f", ml_score={result.ml_score}" if result.ml_score is not None else ""
|
||||
logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}{ml_info}")
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to analyze {stock_code}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
return "error"
|
||||
|
||||
|
||||
async def batch_analyze_all_stocks(limit: int = None):
|
||||
"""전체 종목 배치 분석 (안전한 재시도 및 쓰로틀링 적용)"""
|
||||
"""전체 종목 배치 분석 (동시 처리 + 이중 API 호출 제거로 고속화)
|
||||
|
||||
변경 사항:
|
||||
- 순차 처리(+3초 sleep) → asyncio.gather() 동시 처리 (BATCH_CONCURRENCY 종목 병렬)
|
||||
- 이중 API 호출 제거: ATR이 analyze_stock() 내부에서 계산됨
|
||||
- 각 종목별 독립 세션 사용 (동시 처리 안전)
|
||||
- KIS API 내부 세마포어(API_MAX_CONCURRENT_REQUESTS)가 자체적으로 속도 제어
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
analysis_date = date.today()
|
||||
|
||||
|
||||
# 동시 처리 종목 수 (KIS API 내부 세마포어로 실제 API 호출은 추가 제어됨)
|
||||
BATCH_CONCURRENCY = 5
|
||||
|
||||
logger.info(f"🚀 Starting daily batch analysis for {analysis_date}")
|
||||
logger.info(f"⚙️ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}초 간격, 최대 재시도 {settings.API_MAX_RETRIES}회")
|
||||
|
||||
# KIS API 인스턴스
|
||||
logger.info(f"⚙️ 동시 처리: {BATCH_CONCURRENCY}종목, API 세마포어: {settings.API_MAX_CONCURRENT_REQUESTS}req")
|
||||
|
||||
# KIS API 인스턴스 (종목 목록 조회용)
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. 오늘 날짜 기존 데이터 삭제
|
||||
logger.info("🗑️ Deleting old analysis data...")
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.analysis_date >= analysis_date
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info("✅ Old data deleted")
|
||||
|
||||
# 2. 전체 종목 조회
|
||||
all_stocks = await get_all_tradable_stocks()
|
||||
|
||||
if limit:
|
||||
all_stocks = all_stocks[:limit]
|
||||
logger.info(f"🧪 Limited to {limit} stocks for testing")
|
||||
|
||||
# 3. 배치 처리 (쓰로틀링은 KIS API 클래스에서 자동 처리)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for i, (stock_code, stock_name) in enumerate(all_stocks, 1):
|
||||
logger.info(f"[{i}/{len(all_stocks)}] Analyzing {stock_code} ({stock_name})...")
|
||||
|
||||
success = await analyze_and_save_stock(
|
||||
session,
|
||||
stock_code,
|
||||
stock_name,
|
||||
analysis_date
|
||||
)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
# 30개마다 통계 출력
|
||||
if i % 30 == 0:
|
||||
kis_api.log_monitor_stats()
|
||||
|
||||
|
||||
# 1. 시가총액 상위 500개 종목 조회 (ETF/특수 종목 자동 제외)
|
||||
market_cap_limit = 500
|
||||
all_stocks = await get_all_tradable_stocks(kis_api, limit=market_cap_limit)
|
||||
logger.info(f"📊 시가총액 상위 {len(all_stocks)}개 종목 선정 완료")
|
||||
|
||||
# 테스트용 추가 제한 (옵션)
|
||||
filtered_stocks = all_stocks
|
||||
if limit:
|
||||
filtered_stocks = all_stocks[:limit]
|
||||
logger.info(f"🧪 테스트 모드: {limit}개 종목으로 제한")
|
||||
|
||||
# 2. 동시 처리 (세마포어로 동시 처리 종목 수 제한)
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
total = len(filtered_stocks)
|
||||
|
||||
async def analyze_one(i: int, stock_code: str, stock_name: str) -> bool:
|
||||
async with semaphore:
|
||||
logger.info(f"[{i}/{total}] Analyzing {stock_code} ({stock_name})...")
|
||||
return await analyze_and_save_stock(stock_code, stock_name, analysis_date)
|
||||
|
||||
tasks = [
|
||||
analyze_one(i, code, name)
|
||||
for i, (code, name) in enumerate(filtered_stocks, 1)
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
success_count = sum(1 for r in results if r == "ok")
|
||||
no_chart_count = sum(1 for r in results if r == "no_chart")
|
||||
price_filter_count = sum(1 for r in results if r == "price_filter")
|
||||
error_count = sum(1 for r in results if r == "error" or isinstance(r, Exception))
|
||||
|
||||
# 4. 결과 요약 및 최종 통계
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"✅ Batch analysis completed!")
|
||||
logger.info(f"📊 Success: {success_count} / Total: {len(all_stocks)}")
|
||||
logger.info(f"❌ Failed: {fail_count}")
|
||||
logger.info(f"📊 성공: {success_count} / 전체: {len(all_stocks)}")
|
||||
logger.info(f" ├─ 차트없음: {no_chart_count}건 (거래정지·신규상장·특수종목)")
|
||||
logger.info(f" ├─ 가격필터: {price_filter_count}건 (5,000원 미만)")
|
||||
logger.info(f" └─ 오류: {error_count}건")
|
||||
logger.info(f"⏱️ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)")
|
||||
logger.info(f"📅 Analysis Date: {analysis_date}")
|
||||
|
||||
# API 통계 출력
|
||||
stats = kis_api.get_monitor_stats()
|
||||
|
||||
# API 통계 출력 (싱글톤 인스턴스 기준)
|
||||
stats = korea_investment_api.get_monitor_stats()
|
||||
logger.info(f"📡 API 통계:")
|
||||
logger.info(f" - 총 호출: {stats['total_calls']}건")
|
||||
logger.info(f" - 오류: {stats['total_errors']}건 ({stats['error_rate']:.1%})")
|
||||
@@ -212,6 +271,15 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
logger.info(f" - 평균 응답 시간: {stats['avg_duration']:.2f}초")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 분석종목(watchlist) 초기화 — score 상위 50개로 장중 미니배치 대상 설정
|
||||
try:
|
||||
from app.services.watchlist import initialize_watchlist
|
||||
async with AsyncSessionLocal() as session:
|
||||
count = await initialize_watchlist(session, analysis_date)
|
||||
logger.info(f"📋 분석종목 초기화 완료: {count}개 → 장중 미니배치 준비")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 분석종목 초기화 실패 (non-critical): {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""메인 실행 함수"""
|
||||
|
||||
316
backend/batch_intraday_analysis.py
Normal file
316
backend/batch_intraday_analysis.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
장중 미니배치: 매 10분마다 실행 (평일 09:00~15:30 KST)
|
||||
|
||||
[흐름]
|
||||
1. 아침 배치(batch_daily_analysis.py)가 분석종목(watchlist)을 score 상위 50개로 초기화
|
||||
2. 장중 미니배치는 분석종목만 재분석하여 최신 지표로 stock_analysis 갱신
|
||||
3. 분석 결과를 watchlist_history에 시계열로 누적 저장
|
||||
4. score 기준으로 분석종목 진입(≥60) / 제거(<20) 자동 관리
|
||||
|
||||
실행: python batch_intraday_analysis.py
|
||||
Cron: */10 9-15 * * 1-5 docker exec autostock-backend python /app/batch_intraday_analysis.py >> /logs/batch_intraday.log 2>&1
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete, update, func
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
||||
from app.services.watchlist import (
|
||||
WATCHLIST_MAX_SIZE,
|
||||
WATCHLIST_ENTRY_SCORE,
|
||||
WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 동시 처리 종목 수 (KIS API 세마포어가 추가로 속도 제어)
|
||||
BATCH_CONCURRENCY = 5
|
||||
|
||||
# DB 연결
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_watchlist() -> list[tuple[str, str]]:
|
||||
"""현재 활성 분석종목 목록 조회."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(WatchlistStock.stock_code, WatchlistStock.stock_name)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
logger.warning(
|
||||
"⚠️ 분석종목이 없습니다. "
|
||||
"아침 배치(batch_daily_analysis.py)가 먼저 실행되었는지 확인하세요."
|
||||
)
|
||||
else:
|
||||
logger.info(f"📋 분석종목 {len(rows)}개 대상")
|
||||
return [(row.stock_code, row.stock_name) for row in rows]
|
||||
|
||||
|
||||
async def analyze_update_and_manage(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
analysis_date: date,
|
||||
) -> tuple[bool, int | None]:
|
||||
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
||||
|
||||
Returns: (success, score)
|
||||
"""
|
||||
try:
|
||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||
if not result:
|
||||
logger.info(f"No result for {stock_code} (chart/price issue)")
|
||||
return False, None
|
||||
|
||||
# 5,000원 이하 저가주 → watchlist에서도 제거
|
||||
if result.current_price and result.current_price < 5000:
|
||||
logger.debug(f"Skip {stock_code}: price={result.current_price:.0f}원 < 5,000원")
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=datetime.now(),
|
||||
removed_reason="price_filter",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return False, None
|
||||
|
||||
now = datetime.now()
|
||||
score = result.score
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. stock_analysis 갱신 (DELETE + INSERT)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == stock_code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
session.add(StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=score,
|
||||
ml_score=result.ml_score,
|
||||
ml_confidence=result.ml_confidence,
|
||||
ml_signal=result.ml_signal,
|
||||
atr_14=result.atr_14,
|
||||
adx_14=result.adx_14,
|
||||
))
|
||||
|
||||
# 2. watchlist_history 시계열 기록
|
||||
session.add(WatchlistHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
recorded_at=now,
|
||||
score=score,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
rsi=result.rsi,
|
||||
signals=result.signals,
|
||||
))
|
||||
|
||||
# 3. score가 기준 이하면 분석종목에서 제거
|
||||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=now,
|
||||
removed_reason="score_drop",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f"🚫 {stock_code} ({stock_name}) 분석종목 제거: "
|
||||
f"score={score} < {WATCHLIST_EXIT_SCORE}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ {stock_code} ({stock_name}): score={score}")
|
||||
return True, score
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed {stock_code}: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int:
|
||||
"""분석종목에 없는 고점수 종목을 신규 추가.
|
||||
|
||||
Args:
|
||||
analysis_date: 오늘 날짜 (stock_analysis 기준)
|
||||
current_codes: 이번 배치 시작 시점의 watchlist 코드 집합
|
||||
|
||||
Returns: 추가된 종목 수
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 현재 활성 종목 수 확인
|
||||
count_result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(WatchlistStock)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
)
|
||||
active_count = count_result.scalar() or 0
|
||||
|
||||
slots = WATCHLIST_MAX_SIZE - active_count
|
||||
if slots <= 0:
|
||||
logger.debug(f"Watchlist 최대치 도달 ({active_count}/{WATCHLIST_MAX_SIZE})")
|
||||
return 0
|
||||
|
||||
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
||||
exclude_codes = list(current_codes) if current_codes else ["__none__"]
|
||||
result = await session.execute(
|
||||
select(
|
||||
StockAnalysis.stock_code,
|
||||
StockAnalysis.stock_name,
|
||||
StockAnalysis.score,
|
||||
)
|
||||
.where(
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
StockAnalysis.score >= WATCHLIST_ENTRY_SCORE,
|
||||
StockAnalysis.stock_code.notin_(exclude_codes),
|
||||
)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(slots)
|
||||
)
|
||||
candidates = result.all()
|
||||
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
for row in candidates:
|
||||
session.add(WatchlistStock(
|
||||
stock_code=row.stock_code,
|
||||
stock_name=row.stock_name,
|
||||
added_at=now,
|
||||
added_reason="score_rise",
|
||||
score_at_add=row.score,
|
||||
is_active=True,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"➕ 분석종목 {len(candidates)}개 신규 추가 "
|
||||
f"(score ≥ {WATCHLIST_ENTRY_SCORE}, 현재 {active_count + len(candidates)}개)"
|
||||
)
|
||||
return len(candidates)
|
||||
|
||||
|
||||
async def main():
|
||||
"""장중 미니배치 메인"""
|
||||
# 장중(09:00~15:30 KST 평일)이 아니면 즉시 종료
|
||||
if not is_market_open():
|
||||
logger.info("📴 장중이 아닙니다 (09:00~15:30 평일). 종료합니다.")
|
||||
return
|
||||
|
||||
start_time = datetime.now()
|
||||
analysis_date = date.today()
|
||||
logger.info(
|
||||
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
||||
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
||||
)
|
||||
|
||||
# 활성 분석종목 조회
|
||||
active_stocks = await get_active_watchlist()
|
||||
if not active_stocks:
|
||||
return
|
||||
|
||||
# 이번 배치 시작 시점의 코드 집합 (신규 추가 시 중복 방지용)
|
||||
current_codes = {code for code, _ in active_stocks}
|
||||
total = len(active_stocks)
|
||||
|
||||
# 병렬 재분석
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None]:
|
||||
async with semaphore:
|
||||
logger.info(f"[{i}/{total}] {code} ({name})")
|
||||
return await analyze_update_and_manage(code, name, analysis_date)
|
||||
|
||||
tasks = [
|
||||
analyze_one(i, code, name)
|
||||
for i, (code, name) in enumerate(active_stocks, 1)
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
success = sum(1 for r in results if isinstance(r, tuple) and r[0] is True)
|
||||
removed = sum(
|
||||
1 for r in results
|
||||
if isinstance(r, tuple) and r[0] is True
|
||||
and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE
|
||||
)
|
||||
fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False)
|
||||
error = sum(1 for r in results if isinstance(r, Exception))
|
||||
|
||||
# 고점수 신규 종목 추가
|
||||
added = await refresh_watchlist(analysis_date, current_codes)
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
f"✅ [Intraday] 완료: {success}/{total} 성공, "
|
||||
f"{removed} 제거, +{added} 신규, "
|
||||
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
52
backend/cleanup_portfolio.py
Normal file
52
backend/cleanup_portfolio.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""포트폴리오 수량 0인 종목 정리 스크립트"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.models import Portfolio
|
||||
|
||||
async def cleanup():
|
||||
# asyncpg 드라이버 사용
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(database_url)
|
||||
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 수량 0인 종목 조회
|
||||
result = await session.execute(
|
||||
select(Portfolio).where(Portfolio.quantity == 0)
|
||||
)
|
||||
zero_qty = result.scalars().all()
|
||||
|
||||
print(f"수량 0인 종목: {len(zero_qty)}개")
|
||||
for p in zero_qty:
|
||||
print(f" - {p.stock_name}({p.stock_code}): {p.quantity}주")
|
||||
|
||||
if zero_qty:
|
||||
# 삭제
|
||||
await session.execute(
|
||||
delete(Portfolio).where(Portfolio.quantity == 0)
|
||||
)
|
||||
await session.commit()
|
||||
print(f"\n✅ {len(zero_qty)}개 종목 삭제 완료")
|
||||
else:
|
||||
print("\n정리할 종목이 없습니다.")
|
||||
|
||||
# 최종 포트폴리오 확인
|
||||
result = await session.execute(select(Portfolio))
|
||||
all_portfolio = result.scalars().all()
|
||||
print(f"\n현재 포트폴리오: {len(all_portfolio)}개")
|
||||
for p in all_portfolio:
|
||||
print(f" - {p.stock_name}({p.stock_code}): {p.quantity}주")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(cleanup())
|
||||
58
backend/clear_test_data.py
Normal file
58
backend/clear_test_data.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
테스트 데이터 삭제 스크립트
|
||||
재시작 시 오래된 테스트 데이터를 자동으로 삭제합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import delete, select
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import StockAnalysis, TradeHistory, Portfolio
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def clear_test_data():
|
||||
"""테스트 데이터 및 오래된 분석 데이터 삭제"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
# 1. 오래된 stock_analysis 데이터 삭제 (7일 이전)
|
||||
cutoff_date = date.today() - timedelta(days=7)
|
||||
result = await session.execute(
|
||||
delete(StockAnalysis).where(StockAnalysis.analysis_date < cutoff_date)
|
||||
)
|
||||
deleted_analysis = result.rowcount
|
||||
logger.info(f"🗑️ Deleted {deleted_analysis} old stock_analysis records (before {cutoff_date})")
|
||||
|
||||
# 2. PENDING/FAILED 상태의 오래된 거래 내역 삭제 (1일 이전)
|
||||
cutoff_datetime = date.today() - timedelta(days=1)
|
||||
result = await session.execute(
|
||||
delete(TradeHistory).where(
|
||||
TradeHistory.status.in_(['PENDING', 'FAILED']),
|
||||
TradeHistory.created_at < cutoff_datetime
|
||||
)
|
||||
)
|
||||
deleted_trades = result.rowcount
|
||||
logger.info(f"🗑️ Deleted {deleted_trades} old PENDING/FAILED trade records")
|
||||
|
||||
# 3. 수량이 0인 포트폴리오 항목 삭제
|
||||
result = await session.execute(
|
||||
delete(Portfolio).where(Portfolio.quantity <= 0)
|
||||
)
|
||||
deleted_portfolio = result.rowcount
|
||||
logger.info(f"🗑️ Deleted {deleted_portfolio} empty portfolio records")
|
||||
|
||||
await session.commit()
|
||||
logger.info("✅ Test data cleanup completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error during test data cleanup: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(clear_test_data())
|
||||
323
backend/collect_krx_data.py
Normal file
323
backend/collect_krx_data.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
KRX 히스토리컬 데이터 수집 스크립트
|
||||
|
||||
pykrx를 사용하여 KOSPI/KOSDAQ 종목의 일봉 OHLCV 데이터를 DB에 저장합니다.
|
||||
|
||||
사용법:
|
||||
python collect_krx_data.py # 전체 수집 (10년, 주요 200종목)
|
||||
python collect_krx_data.py --limit 5 --start-date 20240101 # 테스트 (5종목, 2024년~)
|
||||
python collect_krx_data.py --update # 증분 업데이트 (마지막 저장일 이후)
|
||||
python collect_krx_data.py --market kospi # KOSPI만
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pykrx import stock as pykrx_stock
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from app.core.database import AsyncSessionLocal, engine, Base
|
||||
from app.models.models import StockDailyPrice
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ETF/특수 종목 제외 키워드
|
||||
SKIP_KEYWORDS = [
|
||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
|
||||
"선물", "스팩", "리츠", "SPAC",
|
||||
]
|
||||
|
||||
|
||||
def get_stock_list(market: str = "all", limit: int = 0) -> list:
|
||||
"""pykrx로 종목 목록 조회
|
||||
|
||||
Args:
|
||||
market: "kospi", "kosdaq", "all"
|
||||
limit: 0이면 전체, N이면 상위 N개
|
||||
|
||||
Returns:
|
||||
[(stock_code, stock_name), ...]
|
||||
"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
stocks = []
|
||||
|
||||
markets = []
|
||||
if market in ("kospi", "all"):
|
||||
markets.append("KOSPI")
|
||||
if market in ("kosdaq", "all"):
|
||||
markets.append("KOSDAQ")
|
||||
|
||||
for mkt in markets:
|
||||
try:
|
||||
tickers = pykrx_stock.get_market_ticker_list(today, market=mkt)
|
||||
for ticker in tickers:
|
||||
name = pykrx_stock.get_market_ticker_name(ticker)
|
||||
if any(k in name for k in SKIP_KEYWORDS):
|
||||
continue
|
||||
stocks.append((ticker, name))
|
||||
logger.info(f"{mkt}: {len(tickers)}개 조회, 필터링 후 {len(stocks)}개")
|
||||
except Exception as e:
|
||||
logger.error(f"{mkt} 종목 목록 조회 실패: {e}")
|
||||
|
||||
# 중복 제거
|
||||
seen = set()
|
||||
unique = []
|
||||
for code, name in stocks:
|
||||
if code not in seen:
|
||||
seen.add(code)
|
||||
unique.append((code, name))
|
||||
|
||||
if limit > 0:
|
||||
unique = unique[:limit]
|
||||
|
||||
logger.info(f"총 {len(unique)}개 종목 선정")
|
||||
return unique
|
||||
|
||||
|
||||
def fetch_ohlcv(stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""pykrx로 일봉 OHLCV 데이터 조회
|
||||
|
||||
Returns:
|
||||
DataFrame with columns: date, open, high, low, close, volume, change_rate, market_cap
|
||||
"""
|
||||
try:
|
||||
df = pykrx_stock.get_market_ohlcv_by_date(
|
||||
start_date, end_date, stock_code, adjusted=True
|
||||
)
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# 시가총액 조회
|
||||
try:
|
||||
cap_df = pykrx_stock.get_market_cap_by_date(start_date, end_date, stock_code)
|
||||
if not cap_df.empty:
|
||||
df["시가총액"] = cap_df["시가총액"]
|
||||
except Exception:
|
||||
df["시가총액"] = None
|
||||
|
||||
df = df.reset_index()
|
||||
df.columns = [c.replace("날짜", "date") for c in df.columns]
|
||||
|
||||
result = pd.DataFrame({
|
||||
"date": df.iloc[:, 0], # 날짜
|
||||
"open": df["시가"].astype(int),
|
||||
"high": df["고가"].astype(int),
|
||||
"low": df["저가"].astype(int),
|
||||
"close": df["종가"].astype(int),
|
||||
"volume": df["거래량"].astype(int),
|
||||
"change_rate": df["등락률"] / 100.0 if "등락률" in df.columns else 0,
|
||||
"market_cap": df.get("시가총액"),
|
||||
})
|
||||
|
||||
# 거래량 0인 날 제거 (거래정지일)
|
||||
result = result[result["volume"] > 0].reset_index(drop=True)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{stock_code} OHLCV 조회 실패: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
async def create_tables():
|
||||
"""DB 테이블 생성"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("DB 테이블 확인/생성 완료")
|
||||
|
||||
|
||||
async def get_last_trade_date(session, stock_code: str) -> date | None:
|
||||
"""특정 종목의 DB 내 마지막 거래일 조회"""
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT MAX(trade_date) FROM stock_daily_prices WHERE stock_code = :code"
|
||||
),
|
||||
{"code": stock_code},
|
||||
)
|
||||
row = result.scalar()
|
||||
return row
|
||||
|
||||
|
||||
async def upsert_daily_prices(session, stock_code: str, stock_name: str, df: pd.DataFrame):
|
||||
"""DB에 일봉 데이터 upsert (중복 시 업데이트)"""
|
||||
if df.empty:
|
||||
return 0
|
||||
|
||||
records = []
|
||||
for _, row in df.iterrows():
|
||||
trade_date = row["date"]
|
||||
if isinstance(trade_date, pd.Timestamp):
|
||||
trade_date = trade_date.date()
|
||||
|
||||
record = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"trade_date": trade_date,
|
||||
"open_price": int(row["open"]),
|
||||
"high_price": int(row["high"]),
|
||||
"low_price": int(row["low"]),
|
||||
"close_price": int(row["close"]),
|
||||
"volume": int(row["volume"]),
|
||||
"change_rate": float(row["change_rate"]) if pd.notna(row["change_rate"]) else None,
|
||||
"market_cap": int(row["market_cap"]) if pd.notna(row.get("market_cap")) else None,
|
||||
}
|
||||
records.append(record)
|
||||
|
||||
# PostgreSQL ON CONFLICT upsert (batch)
|
||||
batch_size = 500
|
||||
inserted = 0
|
||||
for i in range(0, len(records), batch_size):
|
||||
batch = records[i:i + batch_size]
|
||||
stmt = pg_insert(StockDailyPrice).values(batch)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_stock_daily",
|
||||
set_={
|
||||
"stock_name": stmt.excluded.stock_name,
|
||||
"open_price": stmt.excluded.open_price,
|
||||
"high_price": stmt.excluded.high_price,
|
||||
"low_price": stmt.excluded.low_price,
|
||||
"close_price": stmt.excluded.close_price,
|
||||
"volume": stmt.excluded.volume,
|
||||
"change_rate": stmt.excluded.change_rate,
|
||||
"market_cap": stmt.excluded.market_cap,
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
inserted += len(batch)
|
||||
|
||||
await session.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
async def collect_all(
|
||||
market: str = "all",
|
||||
limit: int = 0,
|
||||
start_date: str = "20160101",
|
||||
update_mode: bool = False,
|
||||
):
|
||||
"""메인 수집 로직"""
|
||||
await create_tables()
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# 종목 목록
|
||||
stock_list = get_stock_list(market=market, limit=limit)
|
||||
if not stock_list:
|
||||
logger.error("종목 목록이 비어있습니다")
|
||||
return
|
||||
|
||||
total = len(stock_list)
|
||||
total_inserted = 0
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i, (stock_code, stock_name) in enumerate(stock_list):
|
||||
try:
|
||||
actual_start = start_date
|
||||
|
||||
if update_mode:
|
||||
last_date = await get_last_trade_date(session, stock_code)
|
||||
if last_date:
|
||||
# 마지막 저장일 다음 날부터
|
||||
next_day = last_date + timedelta(days=1)
|
||||
actual_start = next_day.strftime("%Y%m%d")
|
||||
if next_day >= date.today():
|
||||
logger.info(f"[{i+1}/{total}] {stock_code} ({stock_name}): 이미 최신")
|
||||
success_count += 1
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"[{i+1}/{total}] {stock_code} ({stock_name}): "
|
||||
f"{actual_start} ~ {end_date} 수집 중..."
|
||||
)
|
||||
|
||||
df = fetch_ohlcv(stock_code, actual_start, end_date)
|
||||
if df.empty:
|
||||
logger.warning(f" {stock_code}: 데이터 없음")
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
count = await upsert_daily_prices(session, stock_code, stock_name, df)
|
||||
total_inserted += count
|
||||
success_count += 1
|
||||
logger.info(f" {stock_code}: {count}개 행 저장 (총 {len(df)}일)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" {stock_code} ({stock_name}) 실패: {e}")
|
||||
fail_count += 1
|
||||
# 에러 시에도 계속 진행
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# pykrx rate limit 방지
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"수집 완료!")
|
||||
logger.info(f" 성공: {success_count}/{total}")
|
||||
logger.info(f" 실패: {fail_count}/{total}")
|
||||
logger.info(f" 총 저장 행: {total_inserted:,}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="KRX 히스토리컬 데이터 수집")
|
||||
parser.add_argument(
|
||||
"--market",
|
||||
choices=["kospi", "kosdaq", "all"],
|
||||
default="all",
|
||||
help="수집 시장 (기본: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=0, help="종목 수 제한 (0=전체, 테스트용)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-date",
|
||||
default="20160101",
|
||||
help="수집 시작일 YYYYMMDD (기본: 20160101)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="증분 업데이트 모드 (마지막 저장일 이후만 수집)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info(f"KRX 데이터 수집 시작")
|
||||
logger.info(f" 시장: {args.market}")
|
||||
logger.info(f" 종목 제한: {'전체' if args.limit == 0 else args.limit}")
|
||||
logger.info(f" 시작일: {args.start_date}")
|
||||
logger.info(f" 증분 모드: {args.update}")
|
||||
|
||||
start_time = time.time()
|
||||
asyncio.run(collect_all(
|
||||
market=args.market,
|
||||
limit=args.limit,
|
||||
start_date=args.start_date,
|
||||
update_mode=args.update,
|
||||
))
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"총 소요시간: {elapsed:.1f}초 ({elapsed/60:.1f}분)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
69
backend/diagnose_autotrade.py
Normal file
69
backend/diagnose_autotrade.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""자동매매 진단 스크립트"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
# 1. 현재 시간 확인
|
||||
KST = pytz.timezone('Asia/Seoul')
|
||||
now = datetime.now(KST)
|
||||
print(f"🕐 현재 시간 (KST): {now.strftime('%Y-%m-%d %H:%M:%S %A')}")
|
||||
print(f" 요일: {now.weekday()} (0=월, 6=일)")
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
print(f" 시간(분): {current_minutes} (장시간: 540-930)")
|
||||
|
||||
# 2. 장 운영 시간 체크
|
||||
from app.core.market_schedule import is_market_open
|
||||
market_status = is_market_open()
|
||||
print(f"\n📊 장 운영 상태: {'✅ OPEN (자동매매 가능)' if market_status else '❌ CLOSED (자동매매 중단)'}")
|
||||
|
||||
# 3. 설정 확인
|
||||
from app.core.config import settings
|
||||
print(f"\n⚙️ 자동매매 설정:")
|
||||
print(f" AUTO_TRADE_ENABLED: {settings.AUTO_TRADE_ENABLED}")
|
||||
print(f" STOP_LOSS_PERCENT: {settings.STOP_LOSS_PERCENT}%")
|
||||
print(f" TAKE_PROFIT_PERCENT: {settings.TAKE_PROFIT_PERCENT}%")
|
||||
print(f" MAX_DAILY_TRADES: {settings.MAX_DAILY_TRADES}")
|
||||
print(f" TREND_FILTER_ENABLED: {settings.TREND_FILTER_ENABLED}")
|
||||
|
||||
# 4. API 키 확인
|
||||
print(f"\n🔑 API 인증 정보:")
|
||||
print(f" KIS_APP_KEY: {'✅ 설정됨' if settings.KOREA_INVESTMENT_APP_KEY else '❌ 미설정'}")
|
||||
print(f" KIS_APP_SECRET: {'✅ 설정됨' if settings.KOREA_INVESTMENT_APP_SECRET else '❌ 미설정'}")
|
||||
print(f" KIS_ACCOUNT_NO: {'✅ 설정됨' if settings.KOREA_INVESTMENT_ACCOUNT_NUMBER else '❌ 미설정'}")
|
||||
|
||||
# 5. 자동매매 가능 여부 최종 판단
|
||||
can_trade = market_status and settings.AUTO_TRADE_ENABLED
|
||||
print(f"\n🎯 최종 판단: {'✅ 자동매매 실행 가능' if can_trade else '❌ 자동매매 실행 불가'}")
|
||||
|
||||
if not can_trade:
|
||||
reasons = []
|
||||
if not market_status:
|
||||
reasons.append("- 장 마감 시간입니다")
|
||||
if not settings.AUTO_TRADE_ENABLED:
|
||||
reasons.append("- AUTO_TRADE_ENABLED가 False입니다")
|
||||
if not settings.KOREA_INVESTMENT_APP_KEY:
|
||||
reasons.append("- KIS API 인증 정보가 없습니다")
|
||||
|
||||
print("\n⚠️ 자동매매 불가 원인:")
|
||||
for r in reasons:
|
||||
print(f" {r}")
|
||||
|
||||
# 6. 데이터베이스 연결 확인
|
||||
print(f"\n💾 데이터베이스:")
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
engine = create_engine(settings.DATABASE_URL.replace('+asyncpg', ''))
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT COUNT(*) FROM portfolio"))
|
||||
portfolio_count = result.scalar()
|
||||
print(f" ✅ 연결 성공")
|
||||
print(f" 포트폴리오 종목 수: {portfolio_count}")
|
||||
except Exception as e:
|
||||
print(f" ❌ 연결 실패: {e}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
62
backend/fix_trade_history.py
Normal file
62
backend/fix_trade_history.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import TradeHistory, Portfolio
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
async def fix_trade_history():
|
||||
"""잘못된 거래 내역 수정"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 현대ADM 포트폴리오 확인
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(Portfolio.stock_code == "187660")
|
||||
)
|
||||
portfolio = result.scalar_one_or_none()
|
||||
|
||||
if portfolio:
|
||||
actual_qty = portfolio.quantity
|
||||
print(f"✅ 실제 보유 수량: {actual_qty}주")
|
||||
else:
|
||||
print("❌ 포트폴리오에 현대ADM 없음")
|
||||
return
|
||||
|
||||
# 현대ADM 거래 내역 조회
|
||||
result = await db.execute(
|
||||
select(TradeHistory)
|
||||
.where(
|
||||
and_(
|
||||
TradeHistory.stock_code == "187660",
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "EXECUTED"])
|
||||
)
|
||||
)
|
||||
.order_by(TradeHistory.created_at)
|
||||
)
|
||||
trades = result.scalars().all()
|
||||
|
||||
print(f"\n📋 DB 거래 내역: {len(trades)}건")
|
||||
total_db_qty = sum(t.executed_quantity or t.order_quantity for t in trades)
|
||||
print(f"📊 DB 총 매수량: {total_db_qty}주")
|
||||
print(f"📊 실제 보유량: {actual_qty}주")
|
||||
print(f"⚠️ 차이: {total_db_qty - actual_qty}주")
|
||||
|
||||
# 가장 최근 거래만 남기고 나머지 삭제
|
||||
if len(trades) > 1:
|
||||
print(f"\n🗑️ 중복 거래 내역 삭제:")
|
||||
for trade in trades[:-1]: # 마지막 거래 제외하고 삭제
|
||||
print(f" - ID {trade.id}: {trade.order_quantity}주 (생성: {trade.created_at})")
|
||||
await db.delete(trade)
|
||||
|
||||
# 마지막 거래의 수량을 실제 보유량으로 수정
|
||||
last_trade = trades[-1]
|
||||
print(f"\n✏️ 마지막 거래 수량 수정:")
|
||||
print(f" - ID {last_trade.id}: {last_trade.order_quantity}주 → {actual_qty}주")
|
||||
last_trade.executed_quantity = actual_qty
|
||||
last_trade.order_quantity = actual_qty
|
||||
|
||||
await db.commit()
|
||||
print(f"\n✅ 수정 완료!")
|
||||
else:
|
||||
print("\n✅ 중복 없음")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_trade_history())
|
||||
45
backend/force_sync_portfolio.py
Normal file
45
backend/force_sync_portfolio.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""포트폴리오 강제 동기화 스크립트"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from app.api.endpoints.portfolio import sync_portfolio, portfolio_cache
|
||||
from app.core.database import get_db
|
||||
|
||||
async def force_sync():
|
||||
print("🔄 포트폴리오 강제 동기화 시작...")
|
||||
|
||||
# 캐시 초기화
|
||||
portfolio_cache.holdings = []
|
||||
portfolio_cache.summary = None
|
||||
portfolio_cache.last_synced = None
|
||||
portfolio_cache.change_flag = True
|
||||
|
||||
print("✅ 캐시 초기화 완료")
|
||||
|
||||
# DB 세션 생성
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(database_url)
|
||||
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
result = await sync_portfolio(session, force=True)
|
||||
print(f"✅ 동기화 완료: {result}")
|
||||
print(f"📊 보유 종목: {len(portfolio_cache.holdings)}개")
|
||||
for h in portfolio_cache.holdings:
|
||||
print(f" - {h.stock_name}({h.stock_code}): {h.quantity}주")
|
||||
except Exception as e:
|
||||
print(f"❌ 동기화 실패: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(force_sync())
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
테스트용 샘플 분석 데이터 생성 스크립트
|
||||
|
||||
실제 배치 대신 테스트용 데이터를 DB에 넣습니다.
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
from datetime import date
|
||||
|
||||
# DB 연결
|
||||
conn = psycopg2.connect(
|
||||
host="shium.net",
|
||||
port=55432,
|
||||
database="stock",
|
||||
user="stock_admin",
|
||||
password="stock_pass"
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
# 테스트 데이터
|
||||
test_data = [
|
||||
('005930', '삼성전자', 58100, -1.11, 20000000, 22.5, 61200, 65400, 68200, 14.5, 18.2, 72000, 58000, 64500, ['RSI 과매도', '스토캐스틱 과매도'], 3),
|
||||
('122630', 'KODEX 레버리지', 41140, -3.52, 40000000, 25.9, 43200, 45100, 47800, 21.9, 24.1, 49000, 39000, 44000, ['RSI 과매도', '스토캐스틱 과매도'], 3),
|
||||
('462330', 'KODEX 2차전지산업레버리지', 1690, -6.63, 50000000, 20.5, 1850, 1920, 2100, 29.4, 32.1, 2200, 1600, 1900, ['RSI 과매도', '볼린저밴드 하단'], 3),
|
||||
('000660', 'SK하이닉스', 179000, -2.15, 15000000, 28.3, 185000, 192000, 198000, 18.5, 22.3, 205000, 172000, 188500, ['RSI 과매도'], 2),
|
||||
('035720', '카카오', 42500, 1.43, 8000000, 52.1, 41800, 40200, 38500, 65.2, 68.1, 46000, 38000, 42000, ['RSI 중립-상승', '골든크로스'], 2),
|
||||
]
|
||||
|
||||
today = date.today()
|
||||
|
||||
print(f"🚀 Inserting test data for {today}...")
|
||||
|
||||
for stock in test_data:
|
||||
cur.execute("""
|
||||
INSERT INTO stock_analysis (
|
||||
stock_code, stock_name, analysis_date, current_price, change_rate, volume,
|
||||
rsi, ma5, ma20, ma60, stochastic_k, stochastic_d,
|
||||
bollinger_upper, bollinger_lower, bollinger_middle,
|
||||
signals, score
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (
|
||||
stock[0], stock[1], today, stock[2], stock[3], stock[4],
|
||||
stock[5], stock[6], stock[7], stock[8], stock[9], stock[10],
|
||||
stock[11], stock[12], stock[13], stock[14], stock[15]
|
||||
))
|
||||
print(f" ✅ {stock[0]} ({stock[1]})")
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print(f"\n✅ Test data inserted successfully!")
|
||||
print(f"📊 Total: {len(test_data)} stocks")
|
||||
print(f"\nNow test the API:")
|
||||
print(f" GET http://localhost:8000/api/v1/recommendations/rule-based")
|
||||
6
backend/migrations/add_ml_ensemble_columns.sql
Normal file
6
backend/migrations/add_ml_ensemble_columns.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- ML 앙상블 스코어 컬럼 추가 (stock_analysis 테이블)
|
||||
-- 실행: psql -d autostock_db -f migrations/add_ml_ensemble_columns.sql
|
||||
|
||||
ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_score INTEGER;
|
||||
ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_confidence NUMERIC(5, 4);
|
||||
ALTER TABLE stock_analysis ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10);
|
||||
39
backend/migrations/add_risk_management.sql
Normal file
39
backend/migrations/add_risk_management.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- 🔥 리스크 관리 기능 추가를 위한 DB 마이그레이션
|
||||
|
||||
-- 1. trading_sessions 테이블 생성 (일일 리스크 관리)
|
||||
CREATE TABLE IF NOT EXISTS trading_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
session_date DATE NOT NULL UNIQUE,
|
||||
consecutive_losses INTEGER DEFAULT 0,
|
||||
consecutive_wins INTEGER DEFAULT 0,
|
||||
daily_profit_loss NUMERIC(15, 2) DEFAULT 0,
|
||||
daily_trades_count INTEGER DEFAULT 0,
|
||||
is_paused BOOLEAN DEFAULT FALSE,
|
||||
pause_reason VARCHAR(200),
|
||||
starting_balance NUMERIC(15, 2),
|
||||
ending_balance NUMERIC(15, 2),
|
||||
peak_balance NUMERIC(15, 2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trading_sessions_date ON trading_sessions(session_date);
|
||||
|
||||
-- 2. trade_history 테이블에 리스크 관리 필드 추가
|
||||
ALTER TABLE trade_history
|
||||
ADD COLUMN IF NOT EXISTS entry_trade_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS slippage NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS commission NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS net_profit_loss NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS profit_loss_rate NUMERIC(10, 4);
|
||||
|
||||
-- 3. 기존 데이터에 대한 기본값 설정 (선택적)
|
||||
-- 기존 매도 거래에 대해 profit_loss_rate 계산 (매수 거래와 페어링 필요)
|
||||
-- 이 부분은 실제 데이터에 맞게 커스터마이징 필요
|
||||
|
||||
COMMENT ON TABLE trading_sessions IS '일일 트레이딩 세션 관리 - 연속 손실 추적 및 리스크 제한';
|
||||
COMMENT ON COLUMN trade_history.entry_trade_id IS '매도 시 연결된 매수 거래 ID';
|
||||
COMMENT ON COLUMN trade_history.slippage IS '슬리피지 (체결가 - 주문가)';
|
||||
COMMENT ON COLUMN trade_history.commission IS '수수료';
|
||||
COMMENT ON COLUMN trade_history.net_profit_loss IS '수수료 차감 후 순손익';
|
||||
COMMENT ON COLUMN trade_history.profit_loss_rate IS '수익률 (%)';
|
||||
11
backend/migrations/add_trade_history_columns.sql
Normal file
11
backend/migrations/add_trade_history_columns.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 거래 이력 테이블에 새로운 컬럼 추가
|
||||
ALTER TABLE trade_history
|
||||
ADD COLUMN IF NOT EXISTS total_amount NUMERIC(15, 2),
|
||||
ADD COLUMN IF NOT EXISTS entry_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS exit_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS signals JSONB;
|
||||
|
||||
-- 기존 데이터에 대한 total_amount 계산
|
||||
UPDATE trade_history
|
||||
SET total_amount = executed_price * executed_quantity
|
||||
WHERE total_amount IS NULL AND executed_price IS NOT NULL AND executed_quantity IS NOT NULL;
|
||||
48
backend/prefetch_token.py
Normal file
48
backend/prefetch_token.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
KIS API 토큰 선제 발급 스크립트
|
||||
|
||||
매일 오전 6시에 실행하여 토큰을 미리 발급·캐시함으로써
|
||||
장 시작 전(8:50 배치, 자동매매)의 불필요한 토큰 발급 지연을 방지한다.
|
||||
|
||||
토큰 유효기간: 23시간 (6시 발급 → 다음날 5시까지 유효)
|
||||
Cron: 0 6 * * 1-5 docker exec autostock-backend python /app/prefetch_token.py >> /logs/token_prefetch.log 2>&1
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
|
||||
logger.info(f"🔑 [{datetime.now().strftime('%H:%M:%S')}] 토큰 선제 발급 시작")
|
||||
|
||||
api = KoreaInvestmentAPI()
|
||||
|
||||
# 캐시된 토큰이 있어도 강제 재발급 (토큰 만료 임박 방지)
|
||||
api.access_token = None
|
||||
api.token_expires_at = None
|
||||
|
||||
try:
|
||||
token = await api._get_access_token()
|
||||
expires = api.token_expires_at.strftime('%Y-%m-%d %H:%M') if api.token_expires_at else "unknown"
|
||||
logger.info(f"✅ 토큰 발급 완료 — 만료: {expires}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 토큰 발급 실패: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 매매 규칙을 API로 등록하는 스크립트
|
||||
|
||||
API_URL="http://localhost:8000/api/v1/rules"
|
||||
|
||||
# 1. RSI 과매도 반등 전략
|
||||
curl -X POST "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "RSI 과매도 반등 전략",
|
||||
"description": "RSI가 40 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수",
|
||||
"rule_type": "BUY",
|
||||
"is_active": true,
|
||||
"priority": 1,
|
||||
"conditions": {
|
||||
"rsi_threshold": 40,
|
||||
"volume_multiplier": 1.5,
|
||||
"ma_proximity": 0.03
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_ratio": 0.15,
|
||||
"min_amount": 500000,
|
||||
"take_profit": 5.0,
|
||||
"stop_loss": 3.0
|
||||
}
|
||||
}'
|
||||
|
||||
echo -e "\n✅ RSI 전략 등록 완료\n"
|
||||
|
||||
# 2. 골든크로스 추세 추종
|
||||
curl -X POST "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "골든크로스 추세 추종",
|
||||
"description": "5일선이 20일선을 상향 돌파하고 상승 추세가 확인될 때 매수",
|
||||
"rule_type": "BUY",
|
||||
"is_active": true,
|
||||
"priority": 2,
|
||||
"conditions": {
|
||||
"sma_short": 5,
|
||||
"sma_long": 20,
|
||||
"trend": "up"
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_ratio": 0.2,
|
||||
"min_amount": 1000000,
|
||||
"take_profit": 10.0,
|
||||
"stop_loss": 5.0
|
||||
}
|
||||
}'
|
||||
|
||||
echo -e "\n✅ 골든크로스 전략 등록 완료\n"
|
||||
|
||||
# 3. 이동평균 정배열
|
||||
curl -X POST "$API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "이동평균 정배열 전략",
|
||||
"description": "5일 > 20일 > 60일 이동평균 정배열 상태에서 매수",
|
||||
"rule_type": "BUY",
|
||||
"is_active": true,
|
||||
"priority": 3,
|
||||
"conditions": {
|
||||
"ma5_above_ma20": true,
|
||||
"ma20_above_ma60": true,
|
||||
"price_above_ma5": true
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_ratio": 0.15,
|
||||
"min_amount": 500000,
|
||||
"take_profit": 7.0,
|
||||
"stop_loss": 4.0
|
||||
}
|
||||
}'
|
||||
|
||||
echo -e "\n✅ 이동평균 전략 등록 완료\n"
|
||||
|
||||
echo "🎉 모든 매매 규칙이 등록되었습니다!"
|
||||
@@ -4,21 +4,30 @@ pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
sqlalchemy>=2.0.36
|
||||
asyncpg>=0.30.0
|
||||
psycopg2-binary>=2.9.9
|
||||
alembic>=1.14.0
|
||||
greenlet>=3.0.0
|
||||
redis==5.0.1
|
||||
aio-pika==9.3.1
|
||||
websockets==12.0
|
||||
httpx==0.25.2
|
||||
httpx>=0.27.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
anthropic==0.7.8
|
||||
python-multipart>=0.0.9
|
||||
anthropic>=0.18.0
|
||||
pandas>=2.2.0
|
||||
numpy>=1.26.0
|
||||
ta==0.11.0
|
||||
python-dotenv==1.0.0
|
||||
pytz>=2024.1
|
||||
celery==5.3.4
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
feedparser==6.0.11
|
||||
joblib>=1.3.0
|
||||
pytz
|
||||
# ML Ensemble
|
||||
torch>=2.1.0
|
||||
lightgbm>=4.1.0
|
||||
scikit-learn>=1.3.0
|
||||
pykrx>=1.0.44
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API 모니터링 테스트 스크립트
|
||||
|
||||
KIS API의 재시도 및 쓰로틀링 기능을 테스트합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 프로젝트 루트 추가
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def test_throttling():
|
||||
"""쓰로틀링 테스트: 빠른 연속 호출"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("쓰로틀링 테스트 시작: 5개 종목 연속 조회")
|
||||
logger.info("=" * 60)
|
||||
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
test_stocks = [
|
||||
"005930", # 삼성전자
|
||||
"000660", # SK하이닉스
|
||||
"035420", # 네이버
|
||||
"051910", # LG화학
|
||||
"005380", # 현대차
|
||||
]
|
||||
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
for stock_code in test_stocks:
|
||||
logger.info(f"📊 Fetching {stock_code}...")
|
||||
try:
|
||||
result = await kis_api.get_current_price(stock_code)
|
||||
if result and result.get("output"):
|
||||
price = result["output"].get("stck_prpr", "N/A")
|
||||
logger.info(f"✅ {stock_code}: {price}원")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ {stock_code}: {e}")
|
||||
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"⏱️ Total time: {elapsed:.2f}초")
|
||||
logger.info(f"📈 Expected: ~{len(test_stocks) * kis_api.min_request_interval:.1f}초 (쓰로틀링 적용)")
|
||||
|
||||
# 통계 출력
|
||||
stats = kis_api.get_monitor_stats()
|
||||
logger.info("=" * 60)
|
||||
logger.info("📡 API 통계:")
|
||||
logger.info(f" - 총 호출: {stats['total_calls']}건")
|
||||
logger.info(f" - 오류: {stats['total_errors']}건")
|
||||
logger.info(f" - 재시도: {stats['total_retries']}건")
|
||||
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
|
||||
logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
async def test_retry():
|
||||
"""재시도 테스트: 잘못된 종목 코드로 테스트"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("재시도 테스트 시작: 오류 케이스")
|
||||
logger.info("=" * 60)
|
||||
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
# 잘못된 종목 코드
|
||||
invalid_code = "000000"
|
||||
|
||||
try:
|
||||
logger.info(f"📊 Fetching invalid stock: {invalid_code}")
|
||||
result = await kis_api.get_current_price(invalid_code)
|
||||
logger.info(f"✅ Result: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Expected error: {e}")
|
||||
|
||||
# 통계 출력
|
||||
stats = kis_api.get_monitor_stats()
|
||||
logger.info("=" * 60)
|
||||
logger.info("📡 API 통계:")
|
||||
logger.info(f" - 총 호출: {stats['total_calls']}건")
|
||||
logger.info(f" - 오류: {stats['total_errors']}건")
|
||||
logger.info(f" - 재시도: {stats['total_retries']}건")
|
||||
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
async def test_concurrent():
|
||||
"""동시 요청 테스트: 세마포어 확인"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"동시 요청 테스트 시작 (최대 동시 요청: 2건)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
test_stocks = [
|
||||
"005930", # 삼성전자
|
||||
"000660", # SK하이닉스
|
||||
"035420", # 네이버
|
||||
"051910", # LG화학
|
||||
]
|
||||
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
# 병렬 실행
|
||||
tasks = [kis_api.get_current_price(code) for code in test_stocks]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
elapsed = time.time() - start
|
||||
|
||||
success = sum(1 for r in results if not isinstance(r, Exception))
|
||||
logger.info(f"✅ 성공: {success}/{len(test_stocks)}")
|
||||
logger.info(f"⏱️ Total time: {elapsed:.2f}초")
|
||||
|
||||
# 통계 출력
|
||||
stats = kis_api.get_monitor_stats()
|
||||
logger.info("=" * 60)
|
||||
logger.info("📡 API 통계:")
|
||||
logger.info(f" - 총 호출: {stats['total_calls']}건")
|
||||
logger.info(f" - 오류: {stats['total_errors']}건")
|
||||
logger.info(f" - 재시도: {stats['total_retries']}건")
|
||||
logger.info(f" - 오류율: {stats['error_rate']:.1%}")
|
||||
logger.info(f" - 평균 응답: {stats['avg_duration']:.2f}초")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
async def main():
|
||||
"""메인 테스트 실행"""
|
||||
logger.info("🚀 KIS API 안전 운영 테스트 시작")
|
||||
logger.info("")
|
||||
|
||||
# 1. 쓰로틀링 테스트
|
||||
await test_throttling()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 2. 재시도 테스트
|
||||
await test_retry()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. 동시 요청 테스트
|
||||
await test_concurrent()
|
||||
|
||||
logger.info("")
|
||||
logger.info("✅ 모든 테스트 완료")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DB 연결 테스트 스크립트
|
||||
실행: python test_db_connection.py
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
|
||||
async def test_connection():
|
||||
print("🔵 Testing PostgreSQL connection...")
|
||||
print("Host: shium.net")
|
||||
print("Port: 55432")
|
||||
print("Database: stock")
|
||||
print("User: stock")
|
||||
print()
|
||||
|
||||
try:
|
||||
# 직접 asyncpg로 연결 시도
|
||||
conn = await asyncpg.connect(
|
||||
host='shium.net',
|
||||
port=55432,
|
||||
database='stock',
|
||||
user='stock',
|
||||
password='Tjduddn!!12',
|
||||
timeout=10,
|
||||
ssl=False,
|
||||
)
|
||||
|
||||
print("✅ Connection successful!")
|
||||
|
||||
# 간단한 쿼리 테스트
|
||||
version = await conn.fetchval('SELECT version()')
|
||||
print(f"PostgreSQL version: {version}")
|
||||
|
||||
# 테이블 목록 조회
|
||||
tables = await conn.fetch("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
""")
|
||||
|
||||
print(f"\nTables in database:")
|
||||
for table in tables:
|
||||
print(f" - {table['table_name']}")
|
||||
|
||||
await conn.close()
|
||||
print("\n✅ Connection test completed successfully!")
|
||||
|
||||
except asyncpg.PostgresConnectionError as e:
|
||||
print(f"❌ PostgreSQL connection error: {e}")
|
||||
except ConnectionRefusedError as e:
|
||||
print(f"❌ Connection refused: {e}")
|
||||
print("Possible causes:")
|
||||
print(" 1. PostgreSQL server is not running")
|
||||
print(" 2. Firewall blocking port 55432")
|
||||
print(" 3. PostgreSQL not configured to accept external connections")
|
||||
print(" 4. Need SSH tunnel or VPN to access the server")
|
||||
except TimeoutError as e:
|
||||
print(f"❌ Connection timeout: {e}")
|
||||
print("The server is not responding. Check network connectivity.")
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_connection())
|
||||
@@ -3,134 +3,83 @@
|
||||
"min_cash_reserve_ratio": 0.2,
|
||||
"max_cash_reserve_ratio": 0.3,
|
||||
"min_cash_reserve_amount": 1000000,
|
||||
"max_holdings_count": 5,
|
||||
"max_position_size_ratio": 0.25,
|
||||
"min_position_size_ratio": 0.05,
|
||||
"max_holdings_count": 20,
|
||||
"max_position_size_ratio": 0.04,
|
||||
"min_position_size_ratio": 0.02,
|
||||
"max_sector_concentration": 0.4,
|
||||
"leverage_etf_max_ratio": 0.20,
|
||||
"mean_reversion_max_capital": 0.30,
|
||||
"rebalance_threshold": 0.1
|
||||
},
|
||||
"risk_management": {
|
||||
"max_daily_loss": 0.03,
|
||||
"max_daily_trades": 10,
|
||||
"max_loss_per_trade": 0.05,
|
||||
"max_loss_per_trade": 0.08,
|
||||
"stop_loss_low_vol": -6.0,
|
||||
"stop_loss_high_vol": -10.0,
|
||||
"atr_stop_multiplier": 2.5,
|
||||
"atr_trailing_multiplier": 1.5,
|
||||
"trailing_stop_loss": 0.02,
|
||||
"daily_profit_target": 0.05
|
||||
"daily_profit_target": 0.05,
|
||||
"mdd_pause_threshold": -10,
|
||||
"mdd_force_threshold": -20,
|
||||
"consecutive_loss_ban_count": 3,
|
||||
"consecutive_loss_ban_days": 30,
|
||||
"overheat_filter": {
|
||||
"enabled": true,
|
||||
"max_gain_3day": 15.0,
|
||||
"max_gain_5day": 20.0,
|
||||
"max_gain_10day": 30.0,
|
||||
"max_disparity_20ma": 108.0
|
||||
},
|
||||
"bear_market_filter": {
|
||||
"enabled": true,
|
||||
"description": "코스피 200일 MA 기반 하락장 방어 전략 (섹션 7.1)",
|
||||
"conditions": {
|
||||
"bull": "코스피 > MA200 AND MA200 기울기 양(+)",
|
||||
"bear": "위 조건 미충족"
|
||||
},
|
||||
"bearish_actions": {
|
||||
"min_score": 5,
|
||||
"max_trades_per_day": 3,
|
||||
"position_multiplier": 0.5
|
||||
},
|
||||
"strong_bearish_actions": {
|
||||
"min_score": 8,
|
||||
"max_trades_per_day": 1,
|
||||
"position_multiplier": 0.1
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "RSI 과매도 반등 전략",
|
||||
"description": "RSI가 30 이하로 과매도 상태이고, 거래량이 평균의 150% 이상일 때 매수",
|
||||
"rule_type": "buy",
|
||||
"name": "RSI+볼린저 평균회귀",
|
||||
"description": "RSI 과매도 + 볼린저 하단 평균회귀 전략 (CLAUDE.md 섹션 7)",
|
||||
"rule_type": "MEAN_REVERSION",
|
||||
"is_active": true,
|
||||
"priority": 1,
|
||||
"conditions": {
|
||||
"rsi_threshold": 30,
|
||||
"volume_multiplier": 1.5,
|
||||
"ma_proximity": 0.03,
|
||||
"trend": "down"
|
||||
"rsi_bull_max": 25,
|
||||
"rsi_bear_max": 35,
|
||||
"bb_proximity_pct": 1.0,
|
||||
"min_price_krw": 5000,
|
||||
"min_price_usd": 4.0,
|
||||
"min_volume_vs_avg": 1.0
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_sizing": {
|
||||
"method": "fixed_ratio",
|
||||
"target_ratio": 0.15,
|
||||
"min_amount": 500000
|
||||
"target_ratio": 0.04
|
||||
},
|
||||
"stop_loss_atr_mult": 2.5,
|
||||
"stop_loss_max_pct": -8.0,
|
||||
"take_profit": 5.0,
|
||||
"stop_loss": 3.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "골든크로스 추세 추종",
|
||||
"description": "20일선이 60일선을 상향 돌파하고 상승 추세가 확인될 때 매수",
|
||||
"rule_type": "buy",
|
||||
"is_active": true,
|
||||
"priority": 2,
|
||||
"conditions": {
|
||||
"sma_short": 20,
|
||||
"sma_long": 60,
|
||||
"macd_signal": "positive",
|
||||
"volume_trend": "increasing"
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_sizing": {
|
||||
"method": "fixed_ratio",
|
||||
"target_ratio": 0.2,
|
||||
"min_amount": 1000000
|
||||
},
|
||||
"take_profit": 10.0,
|
||||
"stop_loss": 5.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "지지선 반등 전략",
|
||||
"description": "주요 지지선에서 반등하고 긍정적인 뉴스나 이벤트가 있을 때 매수",
|
||||
"rule_type": "buy",
|
||||
"is_active": true,
|
||||
"priority": 3,
|
||||
"conditions": {
|
||||
"support_level": "recent_low",
|
||||
"bb_position": "lower_20",
|
||||
"rsi_trend": "rising",
|
||||
"volume_increase": true
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_sizing": {
|
||||
"method": "fixed_ratio",
|
||||
"target_ratio": 0.15,
|
||||
"min_amount": 500000
|
||||
},
|
||||
"take_profit": 7.0,
|
||||
"stop_loss": 4.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "박스권 돌파 전략",
|
||||
"description": "박스권 상단을 강한 거래량과 함께 돌파할 때 매수",
|
||||
"rule_type": "buy",
|
||||
"is_active": true,
|
||||
"priority": 4,
|
||||
"conditions": {
|
||||
"breakout_period": 20,
|
||||
"volume_multiplier": 2.0,
|
||||
"rsi_max": 70,
|
||||
"trend": "up"
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_sizing": {
|
||||
"method": "fixed_ratio",
|
||||
"target_ratio": 0.2,
|
||||
"min_amount": 1000000
|
||||
},
|
||||
"take_profit": 15.0,
|
||||
"stop_loss": 5.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "모멘텀 추세 전략",
|
||||
"description": "강한 상승 모멘텀이 지속되고 있을 때 추가 매수",
|
||||
"rule_type": "buy",
|
||||
"is_active": false,
|
||||
"priority": 5,
|
||||
"conditions": {
|
||||
"consecutive_up_days": 3,
|
||||
"volume_trend": "increasing",
|
||||
"macd_histogram": "increasing",
|
||||
"rsi_min": 50,
|
||||
"rsi_max": 70
|
||||
},
|
||||
"actions": {
|
||||
"action_type": "buy",
|
||||
"position_sizing": {
|
||||
"method": "fixed_ratio",
|
||||
"target_ratio": 0.15,
|
||||
"min_amount": 500000
|
||||
},
|
||||
"take_profit": 12.0,
|
||||
"stop_loss": 4.0
|
||||
"stop_loss": 6.0,
|
||||
"exit_1_rsi": 50,
|
||||
"exit_1_pct": 0.50,
|
||||
"exit_2_rsi_min": 60,
|
||||
"exit_2_rsi_max": 70
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
306
backend/train_ensemble.py
Normal file
306
backend/train_ensemble.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
앙상블 모델 학습 스크립트
|
||||
|
||||
데이터 소스를 선택하여 LightGBM + PyTorch LSTM + Random Forest 앙상블 모델을 학습합니다.
|
||||
- DB 모드(기본): stock_daily_prices 테이블의 KRX 히스토리컬 데이터 사용
|
||||
- API 모드: KIS API에서 실시간 차트 데이터 수집
|
||||
|
||||
사용법:
|
||||
python train_ensemble.py # DB 기반 전체 학습
|
||||
python train_ensemble.py --source db --limit 20 # DB에서 20종목
|
||||
python train_ensemble.py --source api --test # KIS API로 10종목 (기존 방식)
|
||||
python train_ensemble.py --min-days 500 # 최소 500일 데이터 있는 종목만
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from app.services.feature_engineering import (
|
||||
chart_data_to_dataframe,
|
||||
db_data_to_dataframe,
|
||||
extract_features,
|
||||
prepare_training_data,
|
||||
)
|
||||
from app.services.ml_ensemble import MLEnsemble
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── DB 기반 데이터 수집 ───
|
||||
|
||||
async def collect_training_data_from_db(limit: int = 200, min_days: int = 200) -> tuple:
|
||||
"""DB의 stock_daily_prices에서 학습 데이터 수집
|
||||
|
||||
Args:
|
||||
limit: 최대 종목 수
|
||||
min_days: 최소 데이터 일수 (이보다 적으면 제외)
|
||||
|
||||
Returns:
|
||||
(X_tabular, X_seq, y)
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
from app.core.database import AsyncSessionLocal
|
||||
|
||||
all_X_tabular = []
|
||||
all_X_seq = []
|
||||
all_y = []
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 충분한 데이터가 있는 종목 조회
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT stock_code, stock_name, COUNT(*) as cnt
|
||||
FROM stock_daily_prices
|
||||
GROUP BY stock_code, stock_name
|
||||
HAVING COUNT(*) >= :min_days
|
||||
ORDER BY cnt DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"min_days": min_days, "limit": limit},
|
||||
)
|
||||
stock_list = [(row[0], row[1], row[2]) for row in result.fetchall()]
|
||||
|
||||
if not stock_list:
|
||||
logger.error(f"DB에 {min_days}일 이상 데이터가 있는 종목이 없습니다")
|
||||
logger.error("먼저 python collect_krx_data.py 를 실행하세요")
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
|
||||
logger.info(f"DB에서 {len(stock_list)}개 종목 조회 (최소 {min_days}일)")
|
||||
|
||||
for i, (stock_code, stock_name, day_count) in enumerate(stock_list):
|
||||
logger.info(f"[{i+1}/{len(stock_list)}] {stock_code} ({stock_name}): {day_count}일 데이터")
|
||||
|
||||
try:
|
||||
# DB에서 OHLCV 조회
|
||||
rows = await session.execute(
|
||||
text("""
|
||||
SELECT trade_date, open_price, high_price, low_price,
|
||||
close_price, volume
|
||||
FROM stock_daily_prices
|
||||
WHERE stock_code = :code
|
||||
ORDER BY trade_date ASC
|
||||
"""),
|
||||
{"code": stock_code},
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"trade_date": r[0],
|
||||
"open_price": r[1],
|
||||
"high_price": r[2],
|
||||
"low_price": r[3],
|
||||
"close_price": r[4],
|
||||
"volume": r[5],
|
||||
}
|
||||
for r in rows.fetchall()
|
||||
]
|
||||
|
||||
if len(data) < min_days:
|
||||
continue
|
||||
|
||||
# DataFrame 변환 및 피처 추출
|
||||
df = db_data_to_dataframe(data)
|
||||
features_df = extract_features(df)
|
||||
|
||||
# 학습 데이터 준비
|
||||
X_tab, X_seq, y, indices = prepare_training_data(
|
||||
df, features_df,
|
||||
forecast_days=3,
|
||||
up_threshold=0.015,
|
||||
down_threshold=-0.015,
|
||||
)
|
||||
|
||||
if len(X_tab) == 0:
|
||||
logger.warning(f" {stock_code}: 유효 샘플 없음")
|
||||
continue
|
||||
|
||||
all_X_tabular.append(X_tab)
|
||||
all_X_seq.append(X_seq)
|
||||
all_y.append(y)
|
||||
|
||||
logger.info(
|
||||
f" {stock_code}: {len(X_tab)}개 샘플 "
|
||||
f"(UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" {stock_code} 처리 실패: {e}")
|
||||
|
||||
if not all_X_tabular:
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
|
||||
X_tabular = np.vstack(all_X_tabular)
|
||||
X_seq = np.vstack(all_X_seq)
|
||||
y = np.concatenate(all_y)
|
||||
|
||||
return X_tabular, X_seq, y
|
||||
|
||||
|
||||
# ─── KIS API 기반 데이터 수집 (기존 방식) ───
|
||||
|
||||
async def collect_training_data_from_api(stock_list: list, kis_api) -> tuple:
|
||||
"""여러 종목의 차트 데이터를 KIS API에서 수집하여 학습 데이터로 변환"""
|
||||
all_X_tabular = []
|
||||
all_X_seq = []
|
||||
all_y = []
|
||||
|
||||
for i, (stock_code, stock_name) in enumerate(stock_list):
|
||||
logger.info(f"[{i+1}/{len(stock_list)}] 데이터 수집: {stock_code} ({stock_name})")
|
||||
|
||||
try:
|
||||
chart_resp = await kis_api.get_daily_chart(stock_code, "D")
|
||||
chart_data = chart_resp.get("output", [])
|
||||
|
||||
if not chart_data or len(chart_data) < 80:
|
||||
logger.warning(f" {stock_code}: 데이터 부족 ({len(chart_data) if chart_data else 0}일)")
|
||||
continue
|
||||
|
||||
df = chart_data_to_dataframe(chart_data)
|
||||
features_df = extract_features(df)
|
||||
|
||||
X_tab, X_seq, y, indices = prepare_training_data(
|
||||
df, features_df,
|
||||
forecast_days=3,
|
||||
up_threshold=0.015,
|
||||
down_threshold=-0.015,
|
||||
)
|
||||
|
||||
if len(X_tab) == 0:
|
||||
logger.warning(f" {stock_code}: 유효 샘플 없음")
|
||||
continue
|
||||
|
||||
all_X_tabular.append(X_tab)
|
||||
all_X_seq.append(X_seq)
|
||||
all_y.append(y)
|
||||
|
||||
logger.info(f" {stock_code}: {len(X_tab)}개 샘플 (UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" {stock_code} 수집 실패: {e}")
|
||||
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if not all_X_tabular:
|
||||
return np.array([]), np.array([]), np.array([])
|
||||
|
||||
X_tabular = np.vstack(all_X_tabular)
|
||||
X_seq = np.vstack(all_X_seq)
|
||||
y = np.concatenate(all_y)
|
||||
|
||||
return X_tabular, X_seq, y
|
||||
|
||||
|
||||
async def get_stock_list_from_api(kis_api, limit: int = 50) -> list:
|
||||
"""거래량 상위 종목 조회 (KIS API)"""
|
||||
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_keywords = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
|
||||
if any(k in name for k in skip_keywords):
|
||||
continue
|
||||
stocks.append((item["mksc_shrn_iscd"], name))
|
||||
except Exception as e:
|
||||
logger.error(f"KOSPI 종목 조회 실패: {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_keywords = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
|
||||
if any(k in name for k in skip_keywords):
|
||||
continue
|
||||
stocks.append((item["mksc_shrn_iscd"], name))
|
||||
except Exception as e:
|
||||
logger.error(f"KOSDAQ 종목 조회 실패: {e}")
|
||||
|
||||
logger.info(f"총 {len(stocks)}개 종목 조회 완료")
|
||||
return stocks[:limit]
|
||||
|
||||
|
||||
# ─── 메인 ───
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="앙상블 모델 학습")
|
||||
parser.add_argument("--source", choices=["db", "api"], default="db",
|
||||
help="데이터 소스 (기본: db)")
|
||||
parser.add_argument("--limit", type=int, default=200,
|
||||
help="학습 종목 수 (기본: 200)")
|
||||
parser.add_argument("--test", action="store_true",
|
||||
help="테스트 모드 (10종목)")
|
||||
parser.add_argument("--min-days", type=int, default=200,
|
||||
help="DB 모드: 최소 데이터 일수 (기본: 200)")
|
||||
args = parser.parse_args()
|
||||
|
||||
limit = 10 if args.test else args.limit
|
||||
logger.info(f"데이터 소스: {args.source.upper()}")
|
||||
logger.info(f"{'테스트 모드' if args.test else '전체 학습'}: {limit}개 종목")
|
||||
|
||||
start_time = datetime.now()
|
||||
|
||||
# 데이터 수집
|
||||
if args.source == "db":
|
||||
logger.info("=" * 60)
|
||||
logger.info("DB(stock_daily_prices)에서 학습 데이터 수집 중...")
|
||||
X_tabular, X_seq, y = await collect_training_data_from_db(
|
||||
limit=limit, min_days=args.min_days
|
||||
)
|
||||
else:
|
||||
# KIS API 모드
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
stock_list = await get_stock_list_from_api(kis_api, limit=limit)
|
||||
if not stock_list:
|
||||
logger.error("종목 리스트를 가져올 수 없습니다")
|
||||
return
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("KIS API에서 학습 데이터 수집 중...")
|
||||
X_tabular, X_seq, y = await collect_training_data_from_api(stock_list, kis_api)
|
||||
|
||||
if len(X_tabular) == 0:
|
||||
logger.error("학습 데이터가 없습니다")
|
||||
return
|
||||
|
||||
logger.info(f"총 학습 데이터: {len(X_tabular):,}개 샘플")
|
||||
logger.info(f" 클래스 분포: UP={np.sum(y==2):,}, HOLD={np.sum(y==1):,}, DOWN={np.sum(y==0):,}")
|
||||
|
||||
# 앙상블 학습
|
||||
logger.info("=" * 60)
|
||||
logger.info("앙상블 모델 학습 중...")
|
||||
ensemble = MLEnsemble()
|
||||
metrics = ensemble.train(X_tabular, X_seq, y)
|
||||
|
||||
# 결과 출력
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info("=" * 60)
|
||||
logger.info("학습 완료!")
|
||||
logger.info(f" 소요 시간: {duration:.1f}초 ({duration/60:.1f}분)")
|
||||
logger.info(f" 학습 결과:")
|
||||
for key, value in metrics.items():
|
||||
logger.info(f" {key}: {value}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user