수정본

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

View File

@@ -2,6 +2,9 @@ FROM python:3.11-slim
WORKDIR /app
# Python 출력 버퍼링 비활성화 (로그 즉시 출력)
ENV PYTHONUNBUFFERED=1
# 시스템 의존성 설치
RUN apt-get update && apt-get install -y \
gcc \

View File

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

View File

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

View File

@@ -0,0 +1,731 @@
"""
데이트레이딩 API 엔드포인트
라우터 prefix: /api/daytrading
엔드포인트:
GET /watchlist — 감시 종목 목록 조회
POST /watchlist — 감시 종목 추가/교체
GET /signals — 현재 진입 신호 조회
POST /execute — 신호 기반 데이트레이딩 실행
GET /positions — 활성 데이트레이딩 포지션 조회
POST /close-all — 전체 포지션 강제 청산 (EOD 수동)
POST /cycle — 1분 주기 사이클 수동 실행 (디버그용)
상태 (인메모리, 일일 초기화):
_watchlist — 감시 종목 코드+이름 딕셔너리
_positions — 활성 포지션 {stock_code: {...}}
_orb_cache — 종목별 ORB 데이터
_daily_pnl — 오늘 실현 손익
_last_date — 마지막 초기화 날짜 (날짜 바뀌면 자동 리셋)
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from datetime import datetime, date, time as dtime
import asyncio
import logging
import pytz
from app.services.korea_investment_api import korea_investment_api
from app.services.day_trading_strategy import (
MinuteCandle, OpeningRange, DayTradeSignal,
parse_minute_candles, aggregate_to_5min,
calculate_vwap, calculate_opening_range,
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
check_day_trade_exit,
calculate_day_trade_position_size, is_daily_loss_exceeded,
ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS,
)
from app.core.market_schedule import (
is_market_open, is_opening_range_period,
is_day_trade_entry_window, is_eod_flat_time,
)
from app.core.config import settings
from app.core.json_helpers import _dumps
from app.services.websocket_manager import websocket_manager
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import TradeHistory
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
logger = logging.getLogger(__name__)
KST = pytz.timezone("Asia/Seoul")
# ── 인메모리 상태 ──────────────────────────────────────────────────────────────
def _kst_now() -> datetime:
return datetime.now(KST).replace(tzinfo=None)
def _kst_time() -> dtime:
return datetime.now(KST).time()
def _today_kst() -> date:
return datetime.now(KST).date()
# 감시 종목 (code → name)
_watchlist: Dict[str, str] = {}
# 활성 포지션
# {stock_code: {
# "stock_name", "strategy", "entry_price", "stop_loss", "target_price",
# "quantity", "highest_price", "entry_time", "reason"
# }}
_positions: Dict[str, Dict[str, Any]] = {}
# ORB 캐시 (당일, stock_code → OpeningRange)
_orb_cache: Dict[str, OpeningRange] = {}
# 일일 실현 손익 (원)
_daily_pnl: float = 0.0
# 마지막 초기화 날짜
_last_reset_date: Optional[date] = None
# 매매 실행 lock
_trade_lock = asyncio.Lock()
def _reset_if_new_day() -> None:
"""날짜가 바뀌면 일일 상태 초기화"""
global _daily_pnl, _orb_cache, _last_reset_date
today = _today_kst()
if _last_reset_date != today:
_daily_pnl = 0.0
_orb_cache = {}
_last_reset_date = today
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
# ── Pydantic 모델 ─────────────────────────────────────────────────────────────
class WatchlistRequest(BaseModel):
stocks: List[Dict[str, str]] # [{"code": "005930", "name": "삼성전자"}, ...]
class ExecuteRequest(BaseModel):
stock_code: str
dry_run: bool = False # True면 주문 없이 신호만 반환
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
async def _fetch_candles(stock_code: str) -> List[MinuteCandle]:
"""당일 전체 분봉 데이터 조회 및 파싱"""
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
return parse_minute_candles(raw)
async def _get_current_price(stock_code: str) -> float:
"""현재가 조회"""
try:
result = await korea_investment_api.get_current_price(stock_code)
return float(result.get("output", {}).get("stck_prpr", 0) or 0)
except Exception:
return 0.0
async def _get_portfolio_summary() -> Dict[str, float]:
"""포트폴리오 요약 (총자산, 가용 현금)"""
try:
balance = await korea_investment_api.get_balance()
output2 = balance.get("output2", [{}])
summary = output2[0] if output2 else {}
total_assets = float(summary.get("tot_evlu_amt", 0) or 0)
available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0)
return {"total_assets": total_assets, "available_cash": available_cash}
except Exception as e:
logger.error(f"포트폴리오 조회 실패: {e}")
return {"total_assets": 0.0, "available_cash": 0.0}
async def _place_buy_order(
stock_code: str,
quantity: int,
price: int,
) -> Optional[str]:
"""매수 주문 실행, 주문번호 반환 (실패 시 None)"""
try:
result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="BUY",
quantity=quantity,
price=price,
)
if result.get("rt_cd") == "0":
return result.get("output", {}).get("ODNO", "")
logger.warning(f"매수 주문 실패 [{stock_code}]: {result.get('msg1')}")
return None
except Exception as e:
logger.error(f"매수 주문 오류 [{stock_code}]: {e}")
return None
async def _place_sell_order(
stock_code: str,
quantity: int,
price: int = 0, # 0 = 시장가
) -> bool:
"""매도 주문 실행"""
try:
result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=price,
)
return result.get("rt_cd") == "0"
except Exception as e:
logger.error(f"매도 주문 오류 [{stock_code}]: {e}")
return False
async def _record_trade(
db: AsyncSession,
stock_code: str,
stock_name: str,
order_type: str,
price: float,
quantity: int,
reason: str,
profit_loss: float = 0.0,
) -> None:
"""TradeHistory DB 기록"""
try:
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
executed_price=price,
executed_quantity=quantity,
total_amount=price * quantity,
profit_loss=profit_loss,
profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0,
status="COMPLETED",
entry_reason=reason if order_type == "BUY" else None,
exit_reason=reason if order_type == "SELL" else None,
executed_at=_kst_now(),
)
db.add(trade)
await db.commit()
except Exception as e:
logger.error(f"TradeHistory 기록 실패: {e}")
# ── 핵심 사이클 로직 ──────────────────────────────────────────────────────────
async def run_day_trading_cycle() -> Dict[str, Any]:
"""1분 주기 데이트레이딩 사이클
흐름:
1. 장 미개장 → 스킵
2. 일일 상태 초기화 체크
3. Opening Range 수집 (09:00~09:30)
4. 활성 포지션 청산 체크
5. 신규 진입 신호 체크 (09:30~15:00)
6. 결과 반환
"""
_reset_if_new_day()
now_time = _kst_time()
result: Dict[str, Any] = {
"time": _kst_now().strftime("%H:%M:%S"),
"phase": "",
"exits": [],
"entries": [],
"signals": [],
}
if not is_market_open():
result["phase"] = "market_closed"
return result
# ── 1. EOD 강제 청산 ──
if is_eod_flat_time():
result["phase"] = "eod_flat"
await _execute_eod_flat()
return result
# ── 2. ORB 수집 단계 (09:00~09:30) ──
if is_opening_range_period():
result["phase"] = "opening_range"
await _collect_opening_range()
result["orb_count"] = len(_orb_cache)
return result
# ── 3. 매매 가능 구간 (09:30~15:00) ──
if not is_day_trade_entry_window():
result["phase"] = "outside_window"
return result
result["phase"] = "active"
# ── 4. 활성 포지션 청산 체크 ──
exits = await _check_exits()
result["exits"] = exits
# ── 5. 신규 진입 신호 체크 ──
if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED:
portfolio = await _get_portfolio_summary()
total_assets = portfolio["total_assets"]
available_cash = portfolio["available_cash"]
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
current_day_trade_amount = sum(
p["entry_price"] * p["quantity"] for p in _positions.values()
)
signals = await _scan_signals(
total_assets=total_assets,
available_cash=available_cash,
current_day_trade_amount=current_day_trade_amount,
)
result["signals"] = [
{
"stock_code": s["signal"].stock_code,
"strategy": s["signal"].strategy,
"entry_price": s["signal"].entry_price,
"stop_loss": s["signal"].stop_loss,
"target_price": s["signal"].target_price,
"reason": s["signal"].reason,
"quantity": s["quantity"],
}
for s in signals
]
for s in signals:
if len(_positions) >= MAX_POSITIONS:
break
entry_result = await _execute_entry(s["signal"], s["quantity"])
if entry_result:
result["entries"].append(entry_result)
else:
logger.warning(
f"⚠️ 일일 손실 한도 도달 ({_daily_pnl:,.0f}원) → 신규 진입 중단"
)
return result
async def _collect_opening_range() -> None:
"""Opening Range 집계 (09:00~09:30)"""
for code in list(_watchlist.keys()):
if code in _orb_cache:
continue
try:
candles = await _fetch_candles(code)
orb = calculate_opening_range(candles)
if orb:
_orb_cache[code] = orb
logger.info(
f"📊 ORB 수집 [{code}]: "
f"H={orb.high:.0f}, L={orb.low:.0f}, "
f"분당거래량={orb.avg_vol_per_min:.0f}"
)
except Exception as e:
logger.warning(f"ORB 수집 실패 [{code}]: {e}")
await asyncio.sleep(0.6)
async def _check_exits() -> List[Dict[str, Any]]:
"""활성 포지션 청산 조건 체크 및 실행"""
global _daily_pnl
exits = []
now_time = _kst_time()
for code, pos in list(_positions.items()):
try:
current_price = await _get_current_price(code)
if current_price <= 0:
continue
# 최고가 갱신
if current_price > pos["highest_price"]:
_positions[code]["highest_price"] = current_price
candles = await _fetch_candles(code)
candles_5m = aggregate_to_5min(candles)
exit_signal = check_day_trade_exit(
entry_price=pos["entry_price"],
current_price=current_price,
stop_loss=pos["stop_loss"],
target_price=pos["target_price"],
highest_since_entry=pos["highest_price"],
candles_5min=candles_5m,
current_time=now_time,
)
if exit_signal:
quantity = pos["quantity"]
ok = await _place_sell_order(code, quantity, price=0) # 시장가
if ok:
pnl = (current_price - pos["entry_price"]) * quantity
_daily_pnl += pnl
del _positions[code]
async with AsyncSessionLocal() as db:
await _record_trade(
db, code, pos["stock_name"], "SELL",
current_price, quantity, exit_signal.reason, pnl,
)
exits.append({
"stock_code": code,
"reason": exit_signal.reason,
"pnl": round(pnl, 0),
"price": current_price,
})
logger.info(
f"✅ 데이트레이딩 청산 [{code}]: "
f"{exit_signal.reason}, PNL={pnl:+,.0f}원"
)
await websocket_manager.broadcast(_dumps({
"type": "day_trade_exit",
"stock_code": code,
"reason": exit_signal.reason,
"pnl": pnl,
}))
except Exception as e:
logger.error(f"청산 체크 오류 [{code}]: {e}")
await asyncio.sleep(0.6)
return exits
async def _scan_signals(
total_assets: float,
available_cash: float,
current_day_trade_amount: float,
) -> List[Dict[str, Any]]:
"""감시 종목에서 진입 신호 스캔"""
results = []
for code, name in list(_watchlist.items()):
if code in _positions:
continue
try:
candles_1m = await _fetch_candles(code)
if not candles_1m:
continue
candles_5m = aggregate_to_5min(candles_1m)
current_price = candles_1m[-1].close if candles_1m else 0
if current_price < settings.MIN_PRICE_KRW:
continue
vwap = calculate_vwap(candles_1m)
orb = _orb_cache.get(code)
signal: Optional[DayTradeSignal] = None
# ORB 우선
if orb:
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
# VWAP 반등
if signal is None and vwap:
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
# RSI+BB
if signal is None:
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
if signal:
position_size = calculate_day_trade_position_size(
total_assets, available_cash, current_day_trade_amount
)
if position_size < signal.entry_price:
continue # 최소 1주도 살 수 없으면 스킵
quantity = max(1, int(position_size // signal.entry_price))
results.append({"signal": signal, "quantity": quantity})
logger.info(
f"🎯 데이트레이딩 신호 [{code}] {signal.strategy}: "
f"{signal.reason}, 수량={quantity}"
)
except Exception as e:
logger.warning(f"신호 스캔 오류 [{code}]: {e}")
await asyncio.sleep(0.6)
# confidence 내림차순 정렬
results.sort(key=lambda x: x["signal"].confidence, reverse=True)
return results[:MAX_POSITIONS - len(_positions)]
async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict]:
"""진입 주문 실행 및 포지션 등록"""
global _trade_lock
async with _trade_lock:
if len(_positions) >= MAX_POSITIONS:
return None
if signal.stock_code in _positions:
return None
order_no = await _place_buy_order(
signal.stock_code, quantity, int(signal.entry_price)
)
if not order_no:
return None
_positions[signal.stock_code] = {
"stock_name": signal.stock_name,
"strategy": signal.strategy,
"entry_price": signal.entry_price,
"stop_loss": signal.stop_loss,
"target_price": signal.target_price,
"quantity": quantity,
"highest_price": signal.entry_price,
"entry_time": _kst_now().strftime("%H:%M:%S"),
"reason": signal.reason,
}
async with AsyncSessionLocal() as db:
await _record_trade(
db, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
)
logger.info(
f"🛒 데이트레이딩 진입 [{signal.stock_code}] "
f"{signal.strategy}: {signal.entry_price:.0f}원 × {quantity}주"
)
await websocket_manager.broadcast(_dumps({
"type": "day_trade_entry",
"stock_code": signal.stock_code,
"strategy": signal.strategy,
"entry_price": signal.entry_price,
"quantity": quantity,
}))
return {
"stock_code": signal.stock_code,
"strategy": signal.strategy,
"entry_price": signal.entry_price,
"quantity": quantity,
"reason": signal.reason,
}
async def _execute_eod_flat() -> None:
"""EOD 전체 포지션 강제 청산"""
global _daily_pnl
for code, pos in list(_positions.items()):
try:
current_price = await _get_current_price(code)
ok = await _place_sell_order(code, pos["quantity"], price=0)
if ok:
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
_daily_pnl += pnl
del _positions[code]
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
except Exception as e:
logger.error(f"EOD 청산 오류 [{code}]: {e}")
await asyncio.sleep(0.6)
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
@router.get("/watchlist")
async def get_watchlist():
"""감시 종목 목록 조회"""
_reset_if_new_day()
return {
"watchlist": [
{"code": k, "name": v, "has_orb": k in _orb_cache}
for k, v in _watchlist.items()
],
"count": len(_watchlist),
}
@router.post("/watchlist")
async def set_watchlist(req: WatchlistRequest):
"""감시 종목 설정 (최대 20종목)
body: {"stocks": [{"code": "005930", "name": "삼성전자"}, ...]}
"""
global _watchlist
if len(req.stocks) > 20:
raise HTTPException(400, "감시 종목은 최대 20종목까지만 설정 가능합니다.")
_watchlist = {s["code"]: s.get("name", s["code"]) for s in req.stocks}
logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}")
return {"status": "ok", "watchlist": list(_watchlist.keys())}
@router.get("/signals")
async def get_current_signals():
"""현재 진입 가능 신호 조회 (주문 미실행)"""
_reset_if_new_day()
if not is_day_trade_entry_window():
return {
"phase": "outside_window",
"message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).",
"signals": [],
}
portfolio = await _get_portfolio_summary()
total_assets = portfolio["total_assets"]
available_cash = portfolio["available_cash"]
current_day_trade_amount = sum(
p["entry_price"] * p["quantity"] for p in _positions.values()
)
signals = await _scan_signals(total_assets, available_cash, current_day_trade_amount)
return {
"phase": "active",
"signals": [
{
"stock_code": s["signal"].stock_code,
"stock_name": s["signal"].stock_name,
"strategy": s["signal"].strategy,
"entry_price": s["signal"].entry_price,
"stop_loss": s["signal"].stop_loss,
"target_price": s["signal"].target_price,
"reason": s["signal"].reason,
"confidence": s["signal"].confidence,
"quantity": s["quantity"],
}
for s in signals
],
"count": len(signals),
}
@router.post("/execute")
async def execute_day_trade(req: ExecuteRequest):
"""특정 종목 데이트레이딩 신호 스캔 후 진입"""
_reset_if_new_day()
code = req.stock_code
name = _watchlist.get(code, code)
if code in _positions:
raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.")
if len(_positions) >= MAX_POSITIONS:
raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.")
if not is_day_trade_entry_window():
raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).")
portfolio = await _get_portfolio_summary()
total_assets = portfolio["total_assets"]
available_cash = portfolio["available_cash"]
if is_daily_loss_exceeded(total_assets, _daily_pnl):
raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.")
candles_1m = await _fetch_candles(code)
candles_5m = aggregate_to_5min(candles_1m)
current_price = candles_1m[-1].close if candles_1m else 0
vwap = calculate_vwap(candles_1m)
orb = _orb_cache.get(code)
signal = None
if orb:
signal = check_orb_signal(code, name, candles_1m, orb, current_price)
if signal is None and vwap:
signal = check_vwap_bounce_signal(code, name, candles_5m, vwap, current_price)
if signal is None:
signal = check_rsi_bb_signal(code, name, candles_5m, current_price)
if not signal:
return {"status": "no_signal", "message": "현재 진입 조건을 충족하는 신호가 없습니다."}
current_day_trade_amount = sum(
p["entry_price"] * p["quantity"] for p in _positions.values()
)
position_size = calculate_day_trade_position_size(
total_assets, available_cash, current_day_trade_amount
)
quantity = max(1, int(position_size // signal.entry_price))
if req.dry_run:
return {
"status": "dry_run",
"signal": {
"strategy": signal.strategy,
"entry_price": signal.entry_price,
"stop_loss": signal.stop_loss,
"target_price": signal.target_price,
"reason": signal.reason,
"quantity": quantity,
},
}
entry = await _execute_entry(signal, quantity)
if not entry:
raise HTTPException(500, "주문 실행에 실패했습니다.")
return {"status": "ok", "entry": entry}
@router.get("/positions")
async def get_positions():
"""활성 데이트레이딩 포지션 조회"""
_reset_if_new_day()
result = []
for code, pos in _positions.items():
try:
current_price = await _get_current_price(code)
except Exception:
current_price = pos["entry_price"]
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
pnl_pct = (current_price - pos["entry_price"]) / pos["entry_price"] * 100
result.append({
"stock_code": code,
"stock_name": pos["stock_name"],
"strategy": pos["strategy"],
"entry_price": pos["entry_price"],
"current_price": current_price,
"stop_loss": pos["stop_loss"],
"target_price": pos["target_price"],
"quantity": pos["quantity"],
"pnl": round(pnl, 0),
"pnl_pct": round(pnl_pct, 2),
"entry_time": pos["entry_time"],
})
return {
"positions": result,
"count": len(result),
"daily_pnl": round(_daily_pnl, 0),
"is_daily_loss_exceeded": is_daily_loss_exceeded(
sum(p["entry_price"] * p["quantity"] for p in _positions.values()),
_daily_pnl,
),
}
@router.post("/close-all")
async def close_all_positions():
"""전체 포지션 수동 강제 청산 (EOD 대비 수동 실행)"""
_reset_if_new_day()
await _execute_eod_flat()
return {
"status": "ok",
"message": "전체 데이트레이딩 포지션 청산 완료",
"daily_pnl": round(_daily_pnl, 0),
}
@router.post("/cycle")
async def manual_cycle():
"""1분 사이클 수동 실행 (테스트/디버그용)"""
_reset_if_new_day()
result = await run_day_trading_cycle()
return result
@router.get("/status")
async def get_status():
"""데이트레이딩 시스템 상태 요약"""
_reset_if_new_day()
return {
"watchlist_count": len(_watchlist),
"active_positions": len(_positions),
"orb_collected": len(_orb_cache),
"daily_pnl": round(_daily_pnl, 0),
"is_market_open": is_market_open(),
"is_opening_range": is_opening_range_period(),
"is_entry_window": is_day_trade_entry_window(),
"is_eod_flat": is_eod_flat_time(),
"today": _today_kst().isoformat(),
}

View File

@@ -0,0 +1,37 @@
from fastapi import APIRouter
from app.core.config import settings
from app.core.market_schedule import is_market_open
from datetime import datetime
import pytz
router = APIRouter()
@router.get("/debug/autotrade-status")
async def get_autotrade_status():
"""자동매매 상태 진단"""
KST = pytz.timezone('Asia/Seoul')
now = datetime.now(KST)
market_open = is_market_open()
return {
"timestamp": now.isoformat(),
"current_time_kst": now.strftime('%Y-%m-%d %H:%M:%S %A'),
"weekday": now.weekday(),
"current_minutes": now.hour * 60 + now.minute,
"market_hours": "540-930 (09:00-15:30)",
"market_open": market_open,
"auto_trade_enabled": settings.AUTO_TRADE_ENABLED,
"can_trade": market_open and settings.AUTO_TRADE_ENABLED,
"settings": {
"stop_loss_percent": settings.STOP_LOSS_PERCENT,
"take_profit_percent": settings.TAKE_PROFIT_PERCENT,
"max_daily_trades": settings.MAX_DAILY_TRADES,
"trend_filter_enabled": settings.TREND_FILTER_ENABLED,
},
"api_configured": bool(settings.KOREA_INVESTMENT_APP_KEY),
"diagnosis": {
"market_status": "OPEN - 자동매매 가능" if market_open else "CLOSED - 장 마감",
"config_status": "ENABLED" if settings.AUTO_TRADE_ENABLED else "DISABLED",
"api_status": "CONFIGURED" if settings.KOREA_INVESTMENT_APP_KEY else "NOT CONFIGURED",
}
}

View File

@@ -0,0 +1,78 @@
"""
매매일지 API 엔드포인트
- GET /: 최근 AI 생성 매매일지 목록
- GET /{journal_id}: 특정 일지 상세
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import Optional
from datetime import datetime
import logging
from app.core.database import get_db
from app.models.models import TradeJournal
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
async def list_journals(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
stock_code: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""최근 AI 생성 매매일지 목록 조회"""
try:
query = select(TradeJournal).order_by(desc(TradeJournal.journal_date))
if stock_code:
query = query.where(TradeJournal.stock_code == stock_code)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
journals = result.scalars().all()
return [
{
"id": j.id,
"trade_id": j.trade_id,
"stock_code": j.stock_code,
"stock_name": j.stock_name,
"journal_date": j.journal_date.isoformat() if j.journal_date else None,
"content": j.content,
"strategy_name": j.strategy_name,
"mood": j.mood,
"tags": j.tags,
"created_at": j.created_at.isoformat() if j.created_at else None,
}
for j in journals
]
except Exception as e:
logger.error(f"journals list error: {e}", exc_info=True)
return []
@router.get("/{journal_id}")
async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)):
"""특정 매매일지 상세 조회"""
result = await db.execute(select(TradeJournal).where(TradeJournal.id == journal_id))
journal = result.scalar_one_or_none()
if not journal:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Journal not found")
return {
"id": journal.id,
"trade_id": journal.trade_id,
"stock_code": journal.stock_code,
"stock_name": journal.stock_name,
"journal_date": journal.journal_date.isoformat() if journal.journal_date else None,
"content": journal.content,
"strategy_name": journal.strategy_name,
"mood": journal.mood,
"tags": journal.tags,
"created_at": journal.created_at.isoformat() if journal.created_at else None,
}

View File

@@ -1,17 +1,34 @@
"""
시세 데이터 API 엔드포인트
- GET /price/{stock_code}: 실시간 현재가 조회
- GET /chart/{stock_code}: 차트 데이터 (일봉/주봉/월봉)
- GET /orderbook/{stock_code}: 호가 정보 조회
- GET /ai-analysis/{stock_code}: AI 기반 종목 분석
"""
# 필요한 라이브러리 및 모듈 임포트
from datetime import datetime, timedelta
import random
import logging
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
# 내부 모듈 임포트
from app.core.config import settings
from app.core.database import get_db
from app.services.korea_investment_api import korea_investment_api
# APIRouter 인스턴스 생성
router = APIRouter()
# 로거 설정
logger = logging.getLogger(__name__)
# 주식 가격 응답을 위한 Pydantic 모델 정의
class StockPriceResponse(BaseModel):
stock_code: str
current_price: float
@@ -24,15 +41,18 @@ class StockPriceResponse(BaseModel):
@router.get("/price/{stock_code}")
async def get_stock_price(stock_code: str):
"""주식 현재가 조회"""
"""주식 현재가 조회 엔드포인트"""
try:
# 한국투자증권 API를 통해 현재가 데이터 조회
data = await korea_investment_api.get_current_price(stock_code)
# API 응답에 'output' 필드가 없는 경우 예외 처리
if "output" not in data:
raise HTTPException(status_code=404, detail="Stock data not found")
output = data["output"]
# 필요한 데이터를 추출하여 딕셔너리 형태로 반환
return {
"stock_code": stock_code,
"current_price": float(output.get("stck_prpr", 0)),
@@ -44,59 +64,115 @@ async def get_stock_price(stock_code: str):
}
except Exception as e:
# 예외 발생 시 500 에러와 함께 상세 내용 반환
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
def _generate_sample_chart(stock_code: str, period: str) -> dict:
"""로컬 개발 환경을 위한 샘플 차트 데이터를 생성"""
@router.get("/prices")
async def get_stock_prices(codes: str):
"""여러 종목의 현재가를 일괄 조회하는 엔드포인트
Query:
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
"""
try:
# codes 파라미터가 없는 경우 빈 데이터 반환
if not codes:
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
prices: Dict[str, float] = {}
# 각 종목 코드에 대해 순차적으로 현재가 조회
# 클래스 내부에 API 호출 간격을 조절하는 쓰로틀링 기능이 내장되어 있음
for code in code_list:
try:
data = await korea_investment_api.get_current_price(code)
if data and data.get("output"):
o = data["output"]
# 콤마가 포함된 가격 문자열을 float으로 변환
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
prices[code] = current_price
except Exception as e:
# 특정 종목 조회 실패 시 경고 로그 기록
logger.warning(f"Failed to fetch price for {code}: {e}")
# 조회 결과를 딕셔너리 형태로 반환
return {
"prices": prices,
"count": len(prices),
"requested": len(code_list),
"timestamp": datetime.now().isoformat(),
}
except Exception as e:
# 예외 발생 시 500 에러와 함께 상세 내용 반환
raise HTTPException(status_code=500, detail=f"Failed to fetch prices: {str(e)}")
def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> dict:
"""로컬 개발 환경 및 API 키가 없을 때 사용할 샘플 차트 데이터를 생성하는 함수"""
# 유효한 주기(period)인지 확인, 아니면 'D'(일봉)으로 설정
if period not in {"D", "W", "M"}:
period = "D"
# 주기에 따라 데이터 포인트 수와 날짜 간격 설정
if period == "D":
points = 60
points = min(limit, 240) # 일봉은 최대 240개
step = 1
elif period == "W":
points = 52
points = min(limit, 240) # 주봉도 최대 240개
step = 7
else: # "M"
points = 36
else: # "M" (월봉)
points = min(limit, 240) # 월봉도 최대 240개
step = 30
# 차트 데이터의 시작 날짜 계산
today = datetime.now()
start_date = today - timedelta(days=(points - 1) * step)
# 종목코드와 주기에 기반하여 일관된 랜덤 데이터 생성을 위한 시드 설정
rng = random.Random(f"{stock_code}:{period}")
# 랜덤 가격 및 변동성 파라미터 초기화
base_price = rng.randint(30000, 90000)
drift = rng.uniform(-0.04, 0.06)
volatility = rng.uniform(0.004, 0.018)
volume_base = rng.randint(80000, 180000)
# 가격을 10단위로 반올림하는 헬퍼 함수
def round_price(value: float) -> int:
return max(1000, int(round(value / 10)) * 10)
price = float(base_price)
chart_data = []
# 설정된 포인트 수만큼 반복하여 캔들 데이터 생성
for idx in range(points):
current_date = start_date + timedelta(days=idx * step)
# 일일 가격 변동률 계산
daily_change = drift / points + rng.uniform(-volatility, volatility)
# 시가, 종가, 고가, 저가 생성
open_price = price
close_price = max(1000.0, open_price * (1 + daily_change))
high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8))
low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8))
# 저가가 0 이하로 내려가지 않도록 보정
if low_price <= 0:
low_price = min(open_price, close_price) * 0.98
# 거래량 생성
volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35)))
# 가격 반올림 처리
open_value = round_price(open_price)
close_value = round_price(close_price)
high_value = max(round_price(high_price), open_value, close_value)
low_value = min(round_price(low_price), open_value, close_value)
# 생성된 캔들 데이터를 리스트에 추가
chart_data.append({
"date": current_date.strftime("%Y%m%d"),
"open": float(open_value),
@@ -106,8 +182,10 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
"volume": max(1000, volume),
})
# 다음 캔들의 시가를 위해 현재 종가 저장
price = close_price
# 최종 샘플 차트 데이터를 딕셔너리 형태로 반환
return {
"stock_code": stock_code,
"period": period,
@@ -116,63 +194,99 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
}
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리
chart_cache = {}
@router.get("/chart/{stock_code}")
async def get_stock_chart(
stock_code: str,
period: str = "D",
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환")
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
):
"""주식 차트 데이터 조회
"""주식 차트 데이터 조회 엔드포인트 (캐싱 적용)
Args:
stock_code: 종목 코드
period: D(일봉), W(주봉), M(월봉)
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
"""
try:
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
return _generate_sample_chart(stock_code, period)
data = await korea_investment_api.get_daily_chart(stock_code, period)
if "output" not in data:
raise HTTPException(status_code=404, detail="Chart data not found")
# 캐시 키 생성 (예: "005930__D")
cache_key = f"{stock_code}__{period}"
current_time = datetime.now()
# 캐시에서 데이터 조회
cached_data = chart_cache.get(cache_key)
use_cache = False
chart_data = []
for item in data["output"]:
# KIS API 가격 데이터 안전하게 파싱
try:
close_str = str(item.get("stck_clpr", "0"))
open_str = str(item.get("stck_oprc", "0"))
high_str = str(item.get("stck_hgpr", "0"))
low_str = str(item.get("stck_lwpr", "0"))
# 소수점이 포함되어 있으면 제거하고 정수로 변환
close_val = float(close_str.replace(",", ""))
open_val = float(open_str.replace(",", ""))
high_val = float(high_str.replace(",", ""))
low_val = float(low_str.replace(",", ""))
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": open_val,
"high": high_val,
"low": low_val,
"close": close_val,
"volume": int(item.get("acml_vol", 0))
})
except (ValueError, TypeError) as e:
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 캐시 데이터가 있고, 10분(600초) 이내에 생성된 경우 캐시 사용
if cached_data:
last_updated = cached_data.get("timestamp")
if last_updated and (current_time - last_updated).total_seconds() < 600:
chart_data = list(cached_data.get("data", [])) # 원본 수정을 방지하기 위해 데이터 복사
use_cache = True
# logger.debug(f"Chart cache hit for {stock_code}")
# KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리
# 캐시를 사용하지 않는 경우 (캐시가 없거나 만료됨)
if not use_cache:
# API 키가 설정되지 않은 경우 샘플 데이터 생성
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
return _generate_sample_chart(stock_code, period, limit)
# 한국투자증권 API를 통해 차트 데이터 조회
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
if "output2" not in data:
raise HTTPException(status_code=404, detail="Chart data not found")
chart_data = [] # 데이터 리스트 초기화
for item in data["output2"]:
# KIS API 응답에서 가격 데이터를 안전하게 파싱
try:
# 콤마가 포함될 수 있는 문자열 형태의 가격을 가져옴
close_str = str(item.get("stck_clpr", "0"))
open_str = str(item.get("stck_oprc", "0"))
high_str = str(item.get("stck_hgpr", "0"))
low_str = str(item.get("stck_lwpr", "0"))
# 콤마를 제거하고 float으로 변환
close_val = float(close_str.replace(",", ""))
open_val = float(open_str.replace(",", ""))
high_val = float(high_str.replace(",", ""))
low_val = float(low_str.replace(",", ""))
# 파싱된 데이터를 딕셔너리 형태로 리스트에 추가
chart_data.append({
"date": item.get("stck_bsop_date"),
"open": open_val,
"high": high_val,
"low": low_val,
"close": close_val,
"volume": int(item.get("acml_vol", 0))
})
except (ValueError, TypeError) as e:
# 가격 파싱 중 오류 발생 시 로그 기록하고 해당 데이터는 건너뜀
logger.error(f"가격 파싱 오류: {item} - {e}")
continue
# 조회한 데이터를 캐시에 저장
chart_cache[cache_key] = {
"timestamp": current_time,
"data": chart_data
}
# KIS API는 최신 데이터가 가장 먼저 오는 경향이 있으므로, 프론트엔드에서 날짜순으로 정렬(reverse)하여 사용
latest_candle = None
# 'realtime' 쿼리 파라미터가 True인 경우, 현재가를 반영한 실시간 캔들 생성
if realtime:
try:
price_data = await korea_investment_api.get_current_price(stock_code)
if price_data and price_data.get("output"):
o = price_data["output"]
# 현재가를 마지막(최신) 캔들로 보정 (지표 계산 시 영향 최소화 위해 기존 첫 캔들과 가격 다르면 prepend)
# 현재가, 고가, 저가, 시가 등의 정보를 파싱하여 실시간 캔들 데이터 구성
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
high_price = float(str(o.get("stck_hgpr", "0")).replace(",", ""))
low_price = float(str(o.get("stck_lwpr", "0")).replace(",", ""))
@@ -188,14 +302,16 @@ async def get_stock_chart(
"volume": volume,
"realtime": True
}
# 기존 첫 번째 캔들의 날짜와 같으면 교체, 다르면 맨 앞에 추가
# 기존 차트 데이터의 첫 번째(가장 최신) 캔들이 오늘 날짜와 같으면 교체, 아니면 맨 앞에 추가
if chart_data and chart_data[0]["date"] == latest_candle["date"]:
chart_data[0] = latest_candle
else:
chart_data.insert(0, latest_candle)
except Exception as e:
# 실시간 캔들 생성 실패 시 경고 로그 기록
logger.warning(f"실시간 현재가 캔들 생성 실패: {e}")
# 최종 차트 데이터를 딕셔너리 형태로 반환
return {
"stock_code": stock_code,
"period": period,
@@ -206,9 +322,9 @@ async def get_stock_chart(
}
except Exception as e:
# 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함)
# API 호출 등에서 예외 발생 시, 개발 편의를 위해 샘플 데이터를 반환
sample = _generate_sample_chart(stock_code, period)
sample["error"] = str(e)
sample["error"] = str(e) # 에러 메시지 포함
sample["is_sample"] = True
sample["realtime"] = False
sample["latest"] = None
@@ -217,19 +333,19 @@ async def get_stock_chart(
@router.get("/news")
async def get_market_news(limit: int = 10):
"""시장 뉴스 조회 (네이버 금융 RSS)"""
import feedparser
"""시장 뉴스 조회 엔드포인트 (Google News RSS 활용)"""
import feedparser # RSS 파싱을 위한 라이브러리
from datetime import datetime
try:
# 네이버 금융 증권 뉴스 RSS
feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title"
# Google News에서 '한국 증시 주식' 키워드로 검색한 결과의 RSS 주소
rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko"
# RSS 피드 파싱
feed = feedparser.parse(rss_url)
news_list = []
# 조회할 개수(limit)만큼 반복하여 뉴스 항목 추출
for idx, entry in enumerate(feed.entries[:limit]):
news_list.append({
"id": idx + 1,
@@ -243,7 +359,7 @@ async def get_market_news(limit: int = 10):
return news_list
except Exception as e:
# 에러 발생 시 샘플 데이터 반환
# 에러 발생 시 미리 정의된 샘플 뉴스 데이터 반환
return [
{
"id": 1,
@@ -270,3 +386,290 @@ async def get_market_news(limit: int = 10):
"url": "#"
}
][:limit]
@router.get("/orderbook/{stock_code}")
async def get_orderbook(stock_code: str):
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
try:
logger.info(f"🔍 Fetching orderbook for stock: {stock_code}")
# 한국투자증권 API를 통해 호가 데이터 조회
data = await korea_investment_api.get_orderbook(stock_code)
# API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리
if data.get("rt_cd") != "0":
logger.error(f"❌ Orderbook API error for {stock_code}: rt_cd={data.get('rt_cd')}, msg={data.get('msg1')}")
raise HTTPException(status_code=404, detail=data.get("msg1", "Orderbook data not found"))
# API 응답에서 output1(호가), output2(체결가) 데이터 추출
output1 = data.get("output1", {})
output2 = data.get("output2", {})
# 현재가와 누적 거래량 정보 추출
current_price = 0
current_volume = 0
if output2 and output2.get("stck_prpr"):
current_price = int(output2.get("stck_prpr", 0))
current_volume = int(output2.get("acml_vol", 0) or 0)
elif output1.get("stck_prpr"):
current_price = int(output1.get("stck_prpr", 0))
current_volume = int(output1.get("acml_vol", 0) or 0)
# 호가 데이터를 담을 딕셔너리 초기화
orderbook = {
"stock_code": stock_code,
"stock_name": output1.get("hts_kor_isnm", ""),
"current_price": current_price,
"current_volume": current_volume,
"asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse)
"bids": [], # 매수 호가 (높은 가격 -> 낮은 가격 순)
"total_ask_volume": 0,
"total_bid_volume": 0,
}
# 디버깅을 위한 원본 매도호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매도호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"askp{i}", 0)
volume = output1.get(f"askp_rsqn{i}", 0)
logger.info(f" askp{i}: {price} ({volume}주)")
# 디버깅을 위한 원본 매수호가 데이터 로그
logger.info(f"📊 [{stock_code}] 매수호가 원본 데이터:")
for i in range(1, 11):
price = output1.get(f"bidp{i}", 0)
volume = output1.get(f"bidp_rsqn{i}", 0)
logger.info(f" bidp{i}: {price} ({volume}주)")
# 매도호가 10단계 파싱 (askp1 ~ askp10)
temp_asks = []
for i in range(1, 11):
price = int(output1.get(f"askp{i}", 0) or 0)
volume = int(output1.get(f"askp_rsqn{i}", 0) or 0)
if price > 0:
temp_asks.append({"price": price, "volume": volume})
orderbook["total_ask_volume"] += volume
# UI 표기를 위해 매도호가는 높은 가격이 위로 가도록 리스트를 뒤집음
orderbook["asks"] = list(reversed(temp_asks))
# 매수호가 10단계 파싱 (bidp1 ~ bidp10)
for i in range(1, 11):
price = int(output1.get(f"bidp{i}", 0) or 0)
volume = int(output1.get(f"bidp_rsqn{i}", 0) or 0)
if price > 0:
orderbook["bids"].append({"price": price, "volume": volume})
orderbook["total_bid_volume"] += volume
# 최종 파싱 결과 로그
logger.info(f"✅ Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.info(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.info(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
if orderbook['asks']:
logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
if orderbook['bids']:
logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
return orderbook
except HTTPException:
# HTTPException은 그대로 다시 발생시킴
raise
except Exception as e:
# 그 외 예외는 500 에러로 처리
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}")
@router.get("/ai-analysis/{stock_code}")
async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
"""AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)"""
try:
import httpx
from app.core.config import settings
logger.info(f"🤖 AI 분석 요청: {stock_code}")
# 1. 최신 시장 데이터(현재가 등) 가져오기
price_data = await korea_investment_api.get_current_price(stock_code)
if price_data.get("rt_cd") != "0":
raise HTTPException(status_code=404, detail="시장 데이터를 가져올 수 없습니다")
output = price_data.get("output", {})
current_price = int(output.get("stck_prpr", 0))
high_price = int(output.get("stck_hgpr", 0))
low_price = int(output.get("stck_lwpr", 0))
volume = int(output.get("acml_vol", 0))
# 2. DB에서 가장 최근의 기술적 지표 분석 데이터 가져오기
from app.models.models import StockAnalysis
result = await db.execute(
select(StockAnalysis)
.filter(StockAnalysis.stock_code == stock_code)
.order_by(StockAnalysis.analysis_date.desc())
.limit(1)
)
analysis_record = result.scalar_one_or_none()
indicators = {}
if analysis_record:
indicators = {
"rsi": float(analysis_record.rsi) if analysis_record.rsi else None,
"ma5": float(analysis_record.ma5) if analysis_record.ma5 else None,
"ma20": float(analysis_record.ma20) if analysis_record.ma20 else None,
"ma60": float(analysis_record.ma60) if analysis_record.ma60 else None,
"stochastic_k": float(analysis_record.stochastic_k) if analysis_record.stochastic_k else None,
"stochastic_d": float(analysis_record.stochastic_d) if analysis_record.stochastic_d else None,
"bollinger_upper": float(analysis_record.bollinger_upper) if analysis_record.bollinger_upper else None,
"bollinger_middle": float(analysis_record.bollinger_middle) if analysis_record.bollinger_middle else None,
"bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None,
}
# 3. 규칙 기반 분석 로직
recommendation = "HOLD" # 기본 추천: 보유
confidence = 0.0 # 신뢰도
trend = "SIDEWAYS" # 추세
analysis_text = "" # 분석 요약 텍스트
# 매수/매도 신호 점수 계산
buy_signals = 0
sell_signals = 0
total_signals = 0
# 기술적 지표 데이터가 있는 경우 분석 시작
if indicators and any(indicators.values()):
signals = [] # 분석된 신호들을 담을 리스트
# RSI 분석
if indicators.get("rsi") is not None:
rsi = indicators["rsi"]
total_signals += 1
if rsi < 30: # 과매도
buy_signals += 1
signals.append(f"RSI 과매도({rsi:.1f})")
elif rsi > 70: # 과매수
sell_signals += 1
signals.append(f"RSI 과매수({rsi:.1f})")
elif rsi < 40: # 매수 고려
buy_signals += 0.5
signals.append(f"RSI 매수권장({rsi:.1f})")
elif rsi > 60: # 매도 고려
sell_signals += 0.5
signals.append(f"RSI 매도권장({rsi:.1f})")
# 이동평균선(MA) 분석
ma5 = indicators.get("ma5")
ma20 = indicators.get("ma20")
ma60 = indicators.get("ma60")
if ma5 and ma20:
# 현재가와 20일 이동평균선의 괴리율을 계산하여 데이터의 신선도 체크
ma_deviation = abs(current_price - ma20) / ma20 if ma20 > 0 else 999
if ma_deviation < 0.5: # 괴리율이 50% 미만일 때만 유효한 데이터로 간주
total_signals += 1
if ma60 and ma5 > ma20 > ma60: # 정배열 (강한 상승 추세)
buy_signals += 1
trend = "UP"
signals.append("이동평균 정배열(상승)")
elif ma60 and ma5 < ma20 < ma60: # 역배열 (강한 하락 추세)
sell_signals += 1
trend = "DOWN"
signals.append("이동평균 역배열(하락)")
elif ma5 < ma20 and current_price < ma20: # 데드크로스 (하락 신호)
sell_signals += 0.5
trend = "DOWN"
signals.append("데드크로스 신호")
else:
logger.debug(f"이동평균선 데이터 오래됨 (현재가: {current_price}, MA20: {ma20}, 괴리율: {ma_deviation*100:.1f}%)")
# 볼린저 밴드 분석
bb_upper = indicators.get("bollinger_upper")
bb_lower = indicators.get("bollinger_lower")
bb_middle = indicators.get("bollinger_middle")
if bb_upper and bb_lower and bb_middle and bb_upper > bb_lower:
# 현재가가 볼린저 밴드 내 어느 위치에 있는지 계산 (0~1)
bb_position = (current_price - bb_lower) / (bb_upper - bb_lower)
# 밴드 위치가 -0.2 ~ 1.2 범위 내에 있을 때만 유효한 데이터로 간주 (괴리율 체크)
if -0.2 <= bb_position <= 1.2:
total_signals += 1
if bb_position > 0.8: # 상단 근접 (과매수)
sell_signals += 1
signals.append(f"볼린저 밴드 상단근접({bb_position*100:.0f}%)")
elif bb_position < 0.2: # 하단 근접 (과매도)
buy_signals += 1
signals.append(f"볼린저 밴드 하단근접({bb_position*100:.0f}%)")
else:
logger.debug(f"볼린저 밴드 데이터 오래됨 (현재가: {current_price}, 밴드: {bb_lower:.0f}~{bb_upper:.0f}, 위치: {bb_position*100:.0f}%)")
# 스토캐스틱 분석
stoch_k = indicators.get("stochastic_k")
stoch_d = indicators.get("stochastic_d")
if stoch_k is not None and stoch_d is not None:
total_signals += 1
if stoch_k < 20 and stoch_d < 20: # 과매도
buy_signals += 1
signals.append(f"스토캐스틱 과매도({stoch_k:.1f})")
elif stoch_k > 80 and stoch_d > 80: # 과매수
sell_signals += 1
signals.append(f"스토캐스틱 과매수({stoch_k:.1f})")
elif stoch_k > stoch_d and stoch_k < 50: # 상승 전환 신호
buy_signals += 0.3
signals.append("스토캐스틱 상승전환")
elif stoch_k < stoch_d and stoch_k > 50: # 하락 전환 신호
sell_signals += 0.3
signals.append("스토캐스틱 하락전환")
# 최종 추천 결정
if total_signals > 0:
# (매수 신호 - 매도 신호) / 전체 신호 수로 점수 계산
signal_score = (buy_signals - sell_signals) / total_signals
confidence = min(abs(signal_score), 1.0) # 신뢰도는 점수의 절대값
if signal_score > 0.3: # 점수가 0.3보다 크면 '매수'
recommendation = "BUY"
elif signal_score < -0.3: # 점수가 -0.3보다 작으면 '매도'
recommendation = "SELL"
else: # 그 외에는 '보유'
recommendation = "HOLD"
analysis_text = " | ".join(signals) if signals else "충분한 신호 없음"
else:
analysis_text = "기술적 지표 데이터 부족"
else:
analysis_text = "기술적 지표 데이터 없음"
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
if trend == "SIDEWAYS" and high_price and low_price and current_price:
daily_change_pct = ((current_price - low_price) / low_price) * 100 if low_price > 0 else 0
if daily_change_pct > 10:
trend = "UP"
elif daily_change_pct < -10:
trend = "DOWN"
# 4. 종합 결과 반환
return {
"stock_code": stock_code,
"stock_name": output.get("stck_prdt_name", ""),
"current_price": current_price,
"high_price": high_price,
"low_price": low_price,
"volume": volume,
"indicators": indicators,
"ai_analysis": analysis_text,
"recommendation": recommendation,
"confidence": confidence,
"trend": trend
}
except HTTPException:
# HTTPException은 그대로 다시 발생시킴
raise
except Exception as e:
# 그 외 예외는 500 에러로 처리
logger.error(f"Error in AI analysis for {stock_code}: {e}")
raise HTTPException(status_code=500, detail=f"AI 분석 실패: {str(e)}")

View File

@@ -25,10 +25,13 @@ async def get_mcp_status():
response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0)
if response.status_code == 200:
health_data = response.json()
return {
"status": "connected",
"mcp_server_url": settings.MCP_SERVER_URL,
"details": response.json()
"claude_configured": health_data.get("claude_configured", False),
"ai_provider": health_data.get("ai_provider", "unknown"),
"details": health_data
}
else:
return {

View File

@@ -0,0 +1,170 @@
"""
ML 앙상블 API 엔드포인트
- POST /train: 모델 학습
- GET /predict/{stock_code}: 개별 종목 예측
- GET /status: 모델 상태 조회
- GET /backtest: 백테스트 결과
"""
from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
import logging
import asyncio
from app.services.ml_ensemble import get_ensemble
from app.services.korea_investment_api import korea_investment_api
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/status")
async def get_model_status():
"""앙상블 모델 상태 조회"""
ensemble = get_ensemble()
status = ensemble.get_status()
status["config"] = {
"enabled": settings.ML_ENSEMBLE_ENABLED,
"rule_weight": settings.ML_ENSEMBLE_RULE_WEIGHT,
"ml_weight": settings.ML_ENSEMBLE_ML_WEIGHT,
"buy_threshold": settings.ML_ENSEMBLE_BUY_THRESHOLD,
"confidence_min": settings.ML_ENSEMBLE_CONFIDENCE_MIN,
}
return status
@router.get("/predict/{stock_code}")
async def predict_stock(stock_code: str):
"""개별 종목 ML 앙상블 예측"""
ensemble = get_ensemble()
if not ensemble.is_trained:
raise HTTPException(
status_code=503,
detail="모델이 학습되지 않았습니다. POST /ml-ensemble/train 으로 학습을 먼저 실행하세요."
)
try:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
chart_data = chart_resp.get("output", [])
if not chart_data or len(chart_data) < 61:
raise HTTPException(status_code=400, detail=f"차트 데이터 부족: {len(chart_data) if chart_data else 0}일 (최소 61일 필요)")
result = ensemble.predict(chart_data)
result["stock_code"] = stock_code
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"예측 실패 ({stock_code}): {e}")
raise HTTPException(status_code=500, detail=str(e))
async def _train_task(limit: int):
"""백그라운드 학습 작업"""
import numpy as np
from app.services.feature_engineering import (
chart_data_to_dataframe, extract_features, prepare_training_data
)
from app.services.korea_investment_api import KoreaInvestmentAPI
kis_api = KoreaInvestmentAPI()
# 종목 조회
stocks = []
try:
kospi = await kis_api.get_volume_rank("J")
if kospi and kospi.get("output"):
for item in kospi["output"]:
name = item.get("hts_kor_isnm", "")
skip = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
if not any(k in name for k in skip):
stocks.append((item["mksc_shrn_iscd"], name))
except Exception as e:
logger.error(f"종목 조회 실패: {e}")
await asyncio.sleep(3)
try:
kosdaq = await kis_api.get_volume_rank("Q")
if kosdaq and kosdaq.get("output"):
for item in kosdaq["output"]:
name = item.get("hts_kor_isnm", "")
skip = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"]
if not any(k in name for k in skip):
stocks.append((item["mksc_shrn_iscd"], name))
except Exception as e:
logger.error(f"종목 조회 실패: {e}")
stocks = stocks[:limit]
logger.info(f"학습 대상: {len(stocks)}개 종목")
# 데이터 수집
all_X_tab, all_X_seq, all_y = [], [], []
for i, (code, name) in enumerate(stocks):
try:
chart_resp = await kis_api.get_daily_chart(code, "D")
chart_data = chart_resp.get("output", [])
if not chart_data or len(chart_data) < 80:
continue
df = chart_data_to_dataframe(chart_data)
feat = extract_features(df)
X_tab, X_seq, y, _ = prepare_training_data(df, feat)
if len(X_tab) > 0:
all_X_tab.append(X_tab)
all_X_seq.append(X_seq)
all_y.append(y)
logger.info(f"[{i+1}/{len(stocks)}] {code}: {len(X_tab)}개 샘플")
except Exception as e:
logger.warning(f"[{i+1}] {code} 실패: {e}")
await asyncio.sleep(3)
if not all_X_tab:
logger.error("학습 데이터 없음")
return
X_tabular = np.vstack(all_X_tab)
X_seq = np.vstack(all_X_seq)
y = np.concatenate(all_y)
logger.info(f"총 {len(X_tabular)}개 샘플로 학습 시작")
ensemble = get_ensemble()
metrics = ensemble.train(X_tabular, X_seq, y)
logger.info(f"학습 완료: {metrics}")
@router.post("/train")
async def train_model(
background_tasks: BackgroundTasks,
limit: int = Query(50, description="학습 종목 수"),
):
"""앙상블 모델 학습 (백그라운드 실행)"""
background_tasks.add_task(_train_task, limit)
return {
"message": f"학습이 백그라운드에서 시작되었습니다 ({limit}개 종목)",
"status": "training_started",
}
@router.get("/backtest")
async def get_backtest():
"""모델 학습 메트릭 및 백테스트 결과 조회"""
ensemble = get_ensemble()
if not ensemble.is_trained:
raise HTTPException(status_code=503, detail="모델이 학습되지 않았습니다")
return {
"is_trained": True,
"metrics": ensemble.training_metrics,
"weights": {
"lgbm": ensemble.weights[0],
"lstm": ensemble.weights[1],
"rf": ensemble.weights[2],
},
}

View File

@@ -1,18 +1,47 @@
"""
포트폴리오 API 엔드포인트
- GET /: 보유 종목 목록 조회 (캐시 활용)
- GET /summary: 포트폴리오 요약 (총자산, 평가손익)
- GET /sync: 실제 계좌와 동기화
- POST /sell: 수동 매도 (청산)
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from sqlalchemy import select, func
from typing import List, Optional
from decimal import Decimal
from pydantic import BaseModel
import pytz
from app.core.database import get_db
from app.models.models import Portfolio
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import Portfolio, TradeHistory, ActiveTrade, StockAnalysis, TradeJournal
from app.services.korea_investment_api import korea_investment_api
from app.services.mean_reversion_strategy import (
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
)
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
from app.services.websocket_manager import websocket_manager
import asyncio
import logging
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
# Request/Response Models
class SellRequest(BaseModel):
stock_code: str
quantity: int
price: Optional[int] = None
def _get_kst_now():
"""현재 한국 시간 반환 (timezone-naive datetime)"""
return datetime.now(KST).replace(tzinfo=None)
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
@@ -28,6 +57,7 @@ class PortfolioCache:
self.failure_count: int = 0
self.next_retry_at: datetime | None = None
self.last_sync_success: bool = False
self.last_trade_id: int | None = None
def mark_changed(self):
self.change_flag = True
@@ -45,7 +75,7 @@ class PortfolioCache:
return False
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
return True
if (now - self.last_synced) > timedelta(minutes=5): # 주기적 신선도 보장
if (now - self.last_synced) > timedelta(seconds=5): # 실시간: 5초마다 갱신
return True
return False
@@ -68,19 +98,102 @@ class PortfolioCache:
self.next_retry_at = None
self.last_sync_success = True
def cache_summary(self):
def cache_summary(self, balance_data: dict = None):
holdings = self.holdings
total_stocks = len(holdings)
total_value = sum(float(h.current_price or 0) * h.quantity for h in holdings)
total_investment = sum(float(h.average_price or 0) * h.quantity for h in holdings)
total_profit_loss = sum(float(h.profit_loss or 0) for h in holdings)
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
# KIS API output2에서 예수금 및 총자산 정보 추출
available_cash = 0.0
total_assets = 0.0
if balance_data:
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
if "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
logger.info(f"📊 output2 keys: {output2.keys()}")
logger.info(f"💰 output2 data: {output2}")
else:
logger.warning("⚠️ No 'output2' in balance_data!")
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
# 가급적 증권사 화면과 동일 기준 사용:
# - 현금: D+2 정산 기준 예수금(d2_depo_amt)
# - 총자산: 총평가금액(tot_evlu_amt) 우선, 없으면 유가평가+현금 계산
# 필드명이 환경에 따라 다를 수 있어 다중 키 체킹
def pick_float(d: dict, keys: list[str]) -> float:
for k in keys:
if k in d and d.get(k) not in (None, ""):
try:
return float(d.get(k))
except Exception:
continue
return 0.0
# 현금: D+2 정산액 우선 -> 예수금총액 -> 순자산금액 순
available_cash = pick_float(output2, [
"d2_depo_amt", # D+2 정산액
"d2_deposit", # 일부 응답 변형 키
"dnca_tot_amt", # 예수금 총액
"nass_amt" # 순자산금액 (fallback)
])
# 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확)
# tot_evlu_amt는 D+2 정산액 + 유가평가액을 포함한 실제 총자산
kis_total_assets = pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
if kis_total_assets > 0:
# KIS API 값 우선 사용
total_assets = kis_total_assets
calculated_assets = total_value + available_cash
# 차이가 크면 로그 (정상 - D+2 정산액 때문)
if abs(kis_total_assets - calculated_assets) > 1000:
logger.debug(
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
)
else:
# KIS API 값이 없으면 계산 (fallback)
total_assets = total_value + available_cash
# 디버깅 로그(요약): 실제 사용된 값과 원본 일부 키 노출
try:
logger.debug(
"Portfolio summary calc - cash=%s, total_assets=%s, keys=%s",
f"{available_cash:,.0f}",
f"{total_assets:,.0f}",
{k: output2.get(k) for k in [
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
] if k in output2}
)
except Exception:
pass
# D+2 정산액 및 기타 상세 정보 추출
d2_settlement = 0.0
stock_value_from_api = 0.0
if balance_data and "output2" in balance_data:
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
d2_settlement = pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
stock_value_from_api = pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
self.summary = {
"total_stocks": total_stocks,
"total_value": total_value,
"total_investment": total_investment,
"total_profit_loss": total_profit_loss,
"total_profit_loss_rate": total_profit_loss_rate
"total_profit_loss_rate": total_profit_loss_rate,
"available_cash": available_cash,
"total_assets": total_assets,
"d2_settlement": d2_settlement, # D+2 정산액
"stock_value_from_api": stock_value_from_api # API의 유가평가액
}
portfolio_cache = PortfolioCache()
@@ -103,19 +216,37 @@ class PortfolioResponse(BaseModel):
from_attributes = True
async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None:
try:
result = await db.execute(
select(func.max(TradeHistory.id)).where(TradeHistory.status.in_(['EXECUTED', 'COMPLETED']))
)
return result.scalar_one_or_none()
except Exception as e:
logger.warning(f"Failed to fetch latest trade id: {e}")
return None
@router.get("", response_model=List[PortfolioResponse])
async def get_portfolio(db: AsyncSession = Depends(get_db)):
"""포트폴리오 조회 (캐시 활용)"""
# 거래 이력 기준으로 캐시 무효화
latest_trade_id = await _get_latest_executed_trade_id(db)
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
portfolio_cache.mark_changed()
if portfolio_cache.holdings and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (holdings)")
return portfolio_cache.holdings
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
# 필요하면 동기화 시도
try:
await sync_portfolio(db, force=False)
except HTTPException:
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
logger.warning("get_portfolio: sync failed, returning cached holdings")
return portfolio_cache.holdings
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
@router.get("/sync")
@@ -126,11 +257,71 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if not force and not portfolio_cache.should_sync():
logger.debug("Skip sync: cache fresh & no change flag")
return {"message": "Skipped (cache fresh)", "cached": True}
async with portfolio_cache.sync_lock:
# 락 획득에 30초 타임아웃 적용 (이전 sync hang 시 데드락 방지)
try:
await asyncio.wait_for(portfolio_cache.sync_lock.acquire(), timeout=30)
except asyncio.TimeoutError:
portfolio_cache.record_failure("Sync lock timeout (30s)")
logger.error("Portfolio sync lock timeout — 이전 sync가 30초 이상 hang 중")
if force:
raise HTTPException(status_code=503, detail="Portfolio sync timeout")
return {"message": "Sync timeout (using cache)", "cached": True}
try:
portfolio_cache.last_attempt = datetime.now()
try:
balance_data = await korea_investment_api.get_balance()
# 디버깅: KIS API 전체 응답 로깅
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
# Balance API 오류 시 기존 DB 보유종목을 삭제하지 않고 캐시 유지
if not balance_data or balance_data.get('rt_cd') != '0':
error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response'
logger.warning(f"⚠️ Balance API 오류 - 기존 포트폴리오 유지: {error_msg}")
# 캐시가 비어있으면 DB에서 로드
if not portfolio_cache.holdings:
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
portfolio_cache.holdings = list(result.scalars().all())
logger.info(f"📦 DB에서 포트폴리오 로드: {len(portfolio_cache.holdings)}종목")
return {"message": f"Balance API error: {error_msg}", "cached": True, "count": len(portfolio_cache.holdings)}
portfolio_cache.record_success()
if "output1" in balance_data and balance_data["output1"]:
logger.info(f"🔍 output1 count: {len(balance_data['output1'])}")
# 1. 오늘 발생한 모든 매매 이력 조회 (Ghost Stock 방지 - 시간 범위 확대)
now = _get_kst_now()
# 오늘의 시작 시간 (00:00:00) 계산
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# 오늘 포함 최근 24시간 (혹시 자정 근처 이슈 방지)이지만, 주로 '최근 행동'이 중요함.
# 안전하게 '오늘 0시 이후'의 모든 거래를 가져옴.
recent_point = today_start
logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection")
# 복잡한 SQL 대신 단순 조회 후 애플리케이션 레벨에서 필터링
trades_stmt = select(TradeHistory).where(
TradeHistory.executed_at >= recent_point,
TradeHistory.status.in_(['EXECUTED', 'COMPLETED'])
).order_by(TradeHistory.executed_at.asc())
trade_result = await db.execute(trades_stmt)
recent_trades = trade_result.scalars().all()
# 종목별 최신 상태 추적
stock_status_map = {}
for t in recent_trades:
stock_status_map[t.stock_code] = t.order_type # 덮어쓰기 (마지막이 최신)
logger.debug(f"Trade trace: {t.stock_name}({t.stock_code}) -> {t.order_type} at {t.executed_at}")
# 최신 상태가 'SELL'인 종목 식별
ghost_candidates = {code for code, status in stock_status_map.items() if status == "SELL"}
if ghost_candidates:
logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}")
# 기존 레코드 로드 (삭제 대신 업데이트)
result = await db.execute(select(Portfolio))
existing_all = {p.stock_code: p for p in result.scalars().all()}
@@ -139,14 +330,53 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if "output1" in balance_data:
for item in balance_data["output1"]:
stock_code = item.get("pdno")
# 최근 최신 거래가 '매도'라면 KIS가 보내줘도 무시 (Ghost Stock 처리)
# 만약 재매수했다면 latest_trades의 type이 'BUY'였을 것이므로 ghost_candidates에 없음.
if stock_code in ghost_candidates:
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
# DB에서도 명시적 삭제
if stock_code in existing_all:
await db.delete(existing_all[stock_code])
logger.info(f"🗑️ Force deleted ghost stock from DB: {stock_code}")
continue
stock_name = item.get("prdt_name")
quantity = int(item.get("hldg_qty", 0))
if quantity <= 0:
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue
average_price = Decimal(item.get("pchs_avg_pric", 0))
current_price = Decimal(item.get("prpr", 0))
profit_loss = Decimal(item.get("evlu_pfls_amt", 0))
profit_loss_rate = Decimal(item.get("evlu_pfls_rt", 0))
# KIS API 필드명 정확히 매핑 (로깅 추가)
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# 🔥 실시간 현재가 조회 (잔고 조회의 prpr은 실시간이 아님)
try:
price_resp = await korea_investment_api.get_current_price(stock_code)
if price_resp and "output" in price_resp:
realtime_price = int(price_resp["output"].get("stck_prpr", 0))
if realtime_price > 0:
current_price = Decimal(str(realtime_price))
else:
current_price = Decimal(str(item.get("prpr", 0)))
else:
current_price = Decimal(str(item.get("prpr", 0)))
except Exception as e:
logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {e}, fallback to prpr")
current_price = Decimal(str(item.get("prpr", 0)))
# 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
# 디버깅: 실제 KIS API 응답 로깅
logger.info(
f"Portfolio sync - {stock_name}({stock_code}): "
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, "
f"평가손익={profit_loss}, 수익률={profit_loss_rate}% | "
f"원본 필드={item}"
)
processed_codes.add(stock_code)
if stock_code in existing_all:
h = existing_all[stock_code]
@@ -169,15 +399,25 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
)
db.add(new_holding)
new_holdings.append(new_holding)
# 제거된 종목 반영 (수량 0 처리)
# 제거된 종목 반영 (DB에서 완전 삭제)
for code, h in existing_all.items():
if code not in processed_codes:
h.quantity = 0
# 보호 로직: 최근 매수 종목이면 API 미반영 상태로 간주하고 유지
# (KIS API가 체결 직후 잔고 반영이 느릴 수 있음)
last_action = stock_status_map.get(code)
if last_action == "BUY" and h.quantity > 0:
logger.info(f"🛡️ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists")
new_holdings.append(h)
continue
logger.info(f"🗑️ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)")
await db.delete(h)
await db.commit()
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
portfolio_cache.cache_summary()
portfolio_cache.cache_summary(balance_data) # balance_data 전달
portfolio_cache.last_synced = datetime.now()
portfolio_cache.change_flag = False
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings")
return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False}
except Exception as e:
@@ -188,11 +428,18 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
# 비강제(백그라운드) 동기화 실패 시에는 캐시/빈 값 사용
return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)}
finally:
portfolio_cache.sync_lock.release()
@router.get("/summary")
async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
"""포트폴리오 요약 (캐시 활용)"""
# 거래 이력 기준으로 캐시 무효화
latest_trade_id = await _get_latest_executed_trade_id(db)
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
portfolio_cache.mark_changed()
if portfolio_cache.summary and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (summary)")
resp = dict(portfolio_cache.summary)
@@ -211,7 +458,9 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
"total_value": 0,
"total_investment": 0,
"total_profit_loss": 0,
"total_profit_loss_rate": 0
"total_profit_loss_rate": 0,
"available_cash": 0,
"total_assets": 0
}
resp = dict(base)
resp.update({
@@ -226,3 +475,402 @@ async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
"""강제 캐시 무효화 및 재동기화"""
portfolio_cache.change_flag = True
return await sync_portfolio(db, force=True)
async def check_and_execute_exit_trades(db: AsyncSession):
"""
보유 종목의 손절/익절 조건을 체크하고 자동 매도 실행
CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -8%)
- 1차 익절(50%): RSI ≥ 50 + BB 중앙선 상회
- 2차 익절(전량): RSI 60~70 + BB 상단선
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
"""
from app.core.config import settings
from datetime import datetime
try:
await sync_portfolio(db, force=True)
# MDD 업데이트 (async — thread-safe lock 내부)
summary = await _get_portfolio_summary(db)
total_assets = float(summary.get("total_assets", 0)) if summary else 0
mdd_action = await mdd_manager.update(total_assets)
if mdd_action["action"] != "NONE":
logger.warning(f"MDD 조치: {mdd_action}")
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
)
holdings = result.scalars().all()
if not holdings:
logger.debug("No holdings to check for exit conditions")
return
executed_exits = []
for holding in holdings:
try:
stock_code = holding.stock_code
stock_name = holding.stock_name
quantity = holding.quantity
avg_price = float(holding.average_price or 0)
# 실시간 가격 확인
price_resp = await korea_investment_api.get_current_price(stock_code)
latest_price = float(holding.current_price or 0)
if price_resp and price_resp.get("output"):
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
if latest_price <= 0 or avg_price <= 0:
continue
profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100
# ActiveTrade 조회 (분할 청산 상태 추적)
at_result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active"
)
)
active_trade = at_result.scalar_one_or_none()
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0
# ATR 기반 청산 시도 (차트 데이터 필요)
exit_signal = None
try:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20:
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes)
if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price
exit_signal = check_exit_signal(
rsi=rsi,
close=latest_price,
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=avg_price,
atr=atr,
lowest_since_entry=lowest_since,
remaining_pct=remaining_pct,
)
except Exception as chart_err:
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
should_exit = False
exit_reason = ""
sell_quantity = quantity
order_price = latest_price
if exit_signal:
should_exit = True
exit_reason = exit_signal.reason
if exit_signal.action == "SELL_PARTIAL":
sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
elif profit_loss_rate <= settings.STOP_LOSS_PERCENT:
# 폴백: 고정 비율 손절 (-6%)
should_exit = True
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= {settings.STOP_LOSS_PERCENT}%)"
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
should_exit = True
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
if should_exit:
logger.info(f"{exit_reason} - {stock_name}({stock_code})")
try:
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=order_price
)
except Exception as e:
logger.warning(f"지정가 실패, 시장가 대체: {e}")
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=sell_quantity,
price=None
)
executed_price = Decimal(str(order_result.get("output", {}).get("ord_prc", order_price)))
total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_price) * sell_quantity
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
order_price=Decimal(str(order_price)),
order_quantity=sell_quantity,
executed_price=executed_price,
executed_quantity=sell_quantity,
total_amount=total_amount,
status="COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED",
order_id=order_result.get("odno"),
message=f"자동 청산: {profit_loss_rate:.2f}%",
exit_reason=f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
created_at=_get_kst_now(),
executed_at=_get_kst_now() if order_result.get("rt_cd") == "0" else None
)
db.add(trade)
await db.commit()
if order_result.get("rt_cd") == "0":
# 연속 손실 추적 (섹션 7.4) — 인메모리 + DB 영속화
is_loss = profit_loss < 0
loss_tracker.record_trade_result(stock_code, is_loss)
await loss_tracker.save_to_db(db, stock_code)
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade:
active_trade.remaining_pct = Decimal(str(remaining_pct - exit_signal.sell_pct))
await db.commit()
else:
# 전량 매도
holding.quantity = max(0, quantity - sell_quantity)
holding.current_price = Decimal(str(latest_price))
if active_trade:
active_trade.status = "closed"
await db.commit()
portfolio_cache.mark_changed()
executed_exits.append({
"stock": stock_name,
"reason": exit_reason,
"profit_loss": profit_loss_rate,
"sell_quantity": sell_quantity,
})
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성
asyncio.create_task(_create_journal_background(
trade_id=trade.id,
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
executed_price=float(executed_price),
quantity=sell_quantity,
profit_loss=profit_loss,
profit_loss_rate=profit_loss_rate,
exit_reason=exit_reason,
))
except Exception as e:
logger.error(f"Exit trade error for {holding.stock_code}: {e}", exc_info=True)
continue
if executed_exits:
logger.info(f"Executed {len(executed_exits)} exit trades: {executed_exits}")
else:
logger.debug("No exit conditions met for current holdings")
except Exception as e:
logger.error(f"check_and_execute_exit_trades error: {e}", exc_info=True)
async def _create_journal_background(
trade_id: int,
stock_code: str,
stock_name: str,
order_type: str,
executed_price: float,
quantity: int,
profit_loss: float,
profit_loss_rate: float,
exit_reason: str,
):
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크)"""
try:
import httpx
from app.core.config import settings
trade_info = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": order_type,
"executed_price": executed_price,
"quantity": quantity,
"profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"exit_reason": exit_reason,
}
content = None
mood = None
tags = []
# MCP 서버로 AI 일지 생성 요청 (실패 시 규칙 기반 폴백)
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info},
timeout=30.0,
)
if resp.status_code == 200:
result = resp.json().get("result", {})
content = result.get("content") or result.get("journal") or result.get("analysis")
mood = result.get("mood")
tags = result.get("tags", [])
except Exception as mcp_err:
logger.debug(f"MCP 일지 생성 실패 ({stock_code}): {mcp_err}")
# 폴백: 규칙 기반 간단 일지
if not content:
direction = "익절" if profit_loss_rate > 0 else "손절"
sign = "+" if profit_loss > 0 else ""
content = (
f"{stock_name}({stock_code}) 매도 체결.\n"
f"매도가: {executed_price:,.0f}원 × {quantity}주\n"
f"손익: {sign}{profit_loss:,.0f}원 ({sign}{profit_loss_rate:.2f}%)\n"
f"사유: {exit_reason}"
)
mood = "좋음" if profit_loss > 0 else "나쁨"
tags = [direction, stock_code]
# DB 저장 (별도 세션 사용 — 호출 시점의 세션은 이미 닫혔을 수 있음)
async with AsyncSessionLocal() as session:
journal = TradeJournal(
trade_id=trade_id,
stock_code=stock_code,
stock_name=stock_name,
journal_date=datetime.utcnow(),
content=content,
strategy_name="AUTO_TRADE",
mood=mood,
tags=tags if tags else None,
)
session.add(journal)
await session.commit()
logger.info(f"매매일지 생성 완료: {stock_name} (trade_id={trade_id})")
# WebSocket으로 프론트엔드에 즉시 알림
await websocket_manager.broadcast_json({
"type": "journal_created",
"data": {"stock_code": stock_code, "stock_name": stock_name},
})
except Exception as e:
logger.error(f"매매일지 생성 오류 ({stock_code}): {e}", exc_info=True)
async def _get_portfolio_summary(db: AsyncSession) -> dict:
"""포트폴리오 요약 (MDD 계산용)"""
try:
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
holdings = result.scalars().all()
total_eval = sum(float(h.current_price or 0) * h.quantity for h in holdings)
total_invest = sum(float(h.average_price or 0) * h.quantity for h in holdings)
return {"total_assets": total_eval, "total_invest": total_invest}
except Exception:
return {"total_assets": 0, "total_invest": 0}
@router.post("/sell")
async def sell_stock(
request: SellRequest,
db: AsyncSession = Depends(get_db)
):
"""보유 종목 수동 매도 (청산 버튼)"""
try:
stock_code = request.stock_code
quantity = request.quantity
price = request.price
logger.info(f"🔵 수동 매도 요청: {stock_code} {quantity}주 @ {price or '시장가'}")
# 보유 여부 확인
result = await db.execute(
select(Portfolio).where(Portfolio.stock_code == stock_code)
)
holding = result.scalar_one_or_none()
if not holding:
raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}")
if holding.quantity < quantity:
raise HTTPException(
status_code=400,
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청"
)
# 매도 주문 실행
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type='SELL',
quantity=quantity,
price=price # None이면 시장가
)
if not order_result or order_result.get('rt_cd') != '0':
error_msg = order_result.get('msg1', '알 수 없는 오류') if order_result else 'API 호출 실패'
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
# TradeHistory 기록
executed_price = price if price else holding.current_price # 체결가는 나중에 업데이트
profit_loss = (executed_price - holding.average_price) * quantity
profit_loss_rate = ((executed_price / holding.average_price) - 1) * 100
trade = TradeHistory(
stock_code=stock_code,
stock_name=holding.stock_name,
order_type='SELL',
order_price=Decimal(str(price)) if price else None,
order_quantity=quantity,
executed_price=Decimal(str(executed_price)),
executed_quantity=quantity,
total_amount=Decimal(str(executed_price * quantity)),
status='EXECUTED',
order_id=order_result.get('output', {}).get('ODNO'),
message=f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
exit_reason=f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
created_at=_get_kst_now(),
executed_at=_get_kst_now()
)
db.add(trade)
await db.commit()
# 포트폴리오 캐시 무효화
portfolio_cache.mark_changed()
logger.info(f"✅ 수동 매도 완료: {holding.stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
# 매매일지 백그라운드 생성
asyncio.create_task(_create_journal_background(
trade_id=trade.id,
stock_code=stock_code,
stock_name=holding.stock_name,
order_type="SELL",
executed_price=float(executed_price),
quantity=quantity,
profit_loss=float(profit_loss),
profit_loss_rate=float(profit_loss_rate),
exit_reason=f"수동 청산 (손익: {profit_loss:+,.0f}원, {profit_loss_rate:+.2f}%)",
))
return {
"success": True,
"message": f"{holding.stock_name} {quantity}주 매도 주문 완료",
"order_id": order_result.get('output', {}).get('ODNO'),
"profit_loss": float(profit_loss),
"profit_loss_rate": float(profit_loss_rate)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Manual sell error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"매도 처리 중 오류: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,473 @@
"""
🔥 리스크 관리 및 성과 평가 API
- MDD (Maximum Drawdown) 추적
- Sharpe Ratio 계산
- 연속 손실 제한
- 일일 손실 한도
- 포지션 사이징 제한
"""
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from datetime import datetime, timedelta, date
from decimal import Decimal
from typing import List, Optional
import logging
import numpy as np
from app.core.database import get_db
from app.models.models import TradeHistory, TradingSession, Portfolio
router = APIRouter()
logger = logging.getLogger(__name__)
# 🔥 리스크 관리 설정
MAX_POSITION_SIZE_RATIO = 0.20 # 계좌의 20%까지만
MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단
COMMISSION_RATE = 0.00015 # 수수료 0.015%
TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만)
TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26%
@router.get("/risk-metrics")
async def get_risk_metrics(
days: int = Query(30, description="분석 기간 (일)"),
db: AsyncSession = Depends(get_db)
):
"""
📊 리스크 관리 메트릭 조회
- Maximum Drawdown (MDD): 최대 손실 깊이
- 누적 손익 곡선
- 현재 계좌 상태
"""
try:
since_date = datetime.utcnow() - timedelta(days=days)
# 매도 완료된 거래만 조회 (실현 손익)
result = await db.execute(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
.order_by(TradeHistory.executed_at)
)
trades = result.scalars().all()
if not trades:
return {
"max_drawdown": 0,
"max_drawdown_pct": 0,
"current_equity": 0,
"peak_equity": 0,
"trades_count": 0,
"message": "거래 내역이 없습니다"
}
# 누적 손익 계산
cumulative_pl = 0
peak = 0
max_drawdown = 0
equity_curve = []
drawdown_curve = []
for trade in trades:
# 순손익 사용 (없으면 total_amount 사용)
pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0
cumulative_pl += pl
equity_curve.append({
"date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(),
"equity": cumulative_pl
})
# 최고점 갱신
if cumulative_pl > peak:
peak = cumulative_pl
# Drawdown 계산
drawdown = peak - cumulative_pl
drawdown_pct = (drawdown / peak * 100) if peak > 0 else 0
drawdown_curve.append({
"date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(),
"drawdown": drawdown,
"drawdown_pct": round(drawdown_pct, 2)
})
if drawdown > max_drawdown:
max_drawdown = drawdown
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0
# MDD 위험 레벨
risk_level = "안전"
if max_drawdown_pct > 30:
risk_level = "위험 (시스템 중지 권장)"
elif max_drawdown_pct > 20:
risk_level = "경고 (포지션 축소 권장)"
elif max_drawdown_pct > 10:
risk_level = "주의"
return {
"max_drawdown": round(float(max_drawdown), 2),
"max_drawdown_pct": round(max_drawdown_pct, 2),
"current_equity": round(cumulative_pl, 2),
"peak_equity": round(peak, 2),
"trades_count": len(trades),
"risk_level": risk_level,
"equity_curve": equity_curve[-50:], # 최근 50개만
"drawdown_curve": drawdown_curve[-50:]
}
except Exception as e:
logger.error(f"Error calculating risk metrics: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sharpe-ratio")
async def get_sharpe_ratio(
days: int = Query(30, description="분석 기간 (일)"),
db: AsyncSession = Depends(get_db)
):
"""
📈 Sharpe Ratio 계산
수익률 대비 변동성(리스크)을 측정
- > 2.0: 매우 우수
- > 1.0: 우수
- < 1.0: 개선 필요
"""
try:
since_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
.where(TradeHistory.profit_loss_rate.isnot(None))
)
trades = result.scalars().all()
if len(trades) < 2:
return {
"sharpe_ratio": None,
"avg_return": 0,
"std_return": 0,
"trades_count": len(trades),
"message": "거래가 부족합니다 (최소 2건 필요)"
}
# 각 거래의 수익률 추출
returns = [float(trade.profit_loss_rate) / 100 for trade in trades if trade.profit_loss_rate]
if not returns:
return {
"sharpe_ratio": None,
"message": "수익률 데이터가 없습니다"
}
avg_return = np.mean(returns)
std_return = np.std(returns)
# Sharpe Ratio 계산 (연간화: 252 거래일 기준)
# 무위험 수익률은 0으로 가정
sharpe = (avg_return * np.sqrt(252)) / std_return if std_return > 0 else 0
# 평가
if sharpe > 2:
interpretation = "Excellent (매우 우수)"
elif sharpe > 1:
interpretation = "Good (우수)"
elif sharpe > 0:
interpretation = "Fair (보통)"
else:
interpretation = "Poor (개선 필요)"
return {
"sharpe_ratio": round(sharpe, 3),
"avg_return": round(avg_return * 100, 2), # %
"std_return": round(std_return * 100, 2), # %
"annualized_return": round(avg_return * 252 * 100, 2), # %
"interpretation": interpretation,
"trades_count": len(returns)
}
except Exception as e:
logger.error(f"Error calculating Sharpe ratio: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/session/today")
async def get_today_session(db: AsyncSession = Depends(get_db)):
"""
📅 오늘의 트레이딩 세션 조회
- 연속 손실/승리 추적
- 일일 손익
- 거래 중지 상태
"""
try:
today = date.today()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
)
session = result.scalar_one_or_none()
if not session:
# 오늘 세션이 없으면 생성
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
await db.commit()
await db.refresh(session)
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0,
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused,
"pause_reason": session.pause_reason,
"starting_balance": float(session.starting_balance) if session.starting_balance else None,
"ending_balance": float(session.ending_balance) if session.ending_balance else None,
"peak_balance": float(session.peak_balance) if session.peak_balance else None
}
except Exception as e:
logger.error(f"Error getting today's session: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check-risk-limits")
async def check_risk_limits(
order_value: float = Query(..., description="주문 금액"),
account_balance: float = Query(..., description="계좌 잔고"),
db: AsyncSession = Depends(get_db)
):
"""
⚠️ 주문 전 리스크 제한 확인
- 포지션 사이즈 제한
- 일일 손실 한도
- 연속 손실 제한
"""
try:
# 1. 오늘 세션 확인
today = date.today()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
)
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance)))
db.add(session)
await db.commit()
await db.refresh(session)
# 2. 거래 중지 상태 확인
if session.is_paused:
return {
"allowed": False,
"reason": session.pause_reason or "거래가 중지되었습니다",
"risk_level": "BLOCKED"
}
# 3. 일일 손실 한도 확인
daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0
if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO:
# 자동 중지
session.is_paused = True
session.pause_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
await db.commit()
return {
"allowed": False,
"reason": session.pause_reason,
"risk_level": "CRITICAL",
"daily_loss": float(session.daily_profit_loss),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2)
}
# 4. 연속 손실 제한 확인
if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES:
session.is_paused = True
session.pause_reason = f"연속 {session.consecutive_losses}회 손실 발생"
await db.commit()
return {
"allowed": False,
"reason": session.pause_reason,
"risk_level": "CRITICAL",
"consecutive_losses": session.consecutive_losses
}
# 5. 포지션 사이즈 제한 확인
position_ratio = order_value / account_balance if account_balance > 0 else 0
if position_ratio > MAX_POSITION_SIZE_RATIO:
return {
"allowed": False,
"reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)",
"risk_level": "HIGH",
"position_ratio": round(position_ratio * 100, 2),
"max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2)
}
# 모든 제한 통과
warnings = []
if session.consecutive_losses >= 2:
warnings.append(f"연속 {session.consecutive_losses}회 손실 중 (주의)")
if daily_loss_ratio > MAX_DAILY_LOSS_RATIO * 0.5:
warnings.append(f"일일 손실 {daily_loss_ratio*100:.1f}% (주의)")
return {
"allowed": True,
"risk_level": "WARNING" if warnings else "OK",
"warnings": warnings,
"position_ratio": round(position_ratio * 100, 2),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2),
"consecutive_losses": session.consecutive_losses
}
except Exception as e:
logger.error(f"Error checking risk limits: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-session")
async def update_trading_session(
trade_result: str = Query(..., description="win 또는 loss"),
profit_loss: float = Query(..., description="거래 손익"),
db: AsyncSession = Depends(get_db)
):
"""
🔄 거래 결과에 따라 세션 업데이트
- 연속 손실/승리 카운터
- 일일 손익 누적
"""
try:
today = date.today()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
)
session = result.scalar_one_or_none()
if not session:
session = TradingSession(session_date=today)
db.add(session)
# 손익 누적
current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0
session.daily_profit_loss = Decimal(str(current_pl + profit_loss))
session.daily_trades_count += 1
# 연속 손실/승리 업데이트
if trade_result == "loss":
session.consecutive_losses += 1
session.consecutive_wins = 0
elif trade_result == "win":
session.consecutive_wins += 1
session.consecutive_losses = 0
await db.commit()
await db.refresh(session)
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss),
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused
}
except Exception as e:
logger.error(f"Error updating session: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/win-rate")
async def get_win_rate(
days: int = Query(30, description="분석 기간 (일)"),
db: AsyncSession = Depends(get_db)
):
"""
📊 승률 및 손익 통계
"""
try:
since_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(TradeHistory)
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "FILLED"]))
)
trades = result.scalars().all()
if not trades:
return {
"win_rate": 0,
"total_trades": 0,
"message": "거래 내역이 없습니다"
}
wins = 0
losses = 0
total_profit = 0
total_loss = 0
for trade in trades:
pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0
if pl > 0:
wins += 1
total_profit += pl
elif pl < 0:
losses += 1
total_loss += abs(pl)
total_trades = wins + losses
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0
# Profit Factor (총 이익 / 총 손실)
profit_factor = (total_profit / total_loss) if total_loss > 0 else 0
# 평균 승리/손실
avg_win = total_profit / wins if wins > 0 else 0
avg_loss = total_loss / losses if losses > 0 else 0
# 손익비
risk_reward = avg_win / avg_loss if avg_loss > 0 else 0
return {
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"wins": wins,
"losses": losses,
"total_profit": round(total_profit, 2),
"total_loss": round(total_loss, 2),
"net_profit": round(total_profit - total_loss, 2),
"profit_factor": round(profit_factor, 2),
"avg_win": round(avg_win, 2),
"avg_loss": round(avg_loss, 2),
"risk_reward_ratio": round(risk_reward, 2)
}
except Exception as e:
logger.error(f"Error calculating win rate: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, HTTPException, Query
from typing import List
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_predictor():
try:
from app.services.tensorflow_predictor import TensorFlowPredictor, TensorFlowNotAvailable
try:
return TensorFlowPredictor()
except TensorFlowNotAvailable as e:
raise HTTPException(status_code=503, detail=f"TensorFlow predictor unavailable: {e}")
except Exception as e:
raise HTTPException(status_code=503, detail=f"TensorFlow unavailable: {e}")
@router.get("/tf-predictions")
async def get_tensorflow_predictions(
codes: str = Query("005930,000660,035420", description="Comma-separated stock codes"),
top: int = Query(10, ge=1, le=50, description="Top N by expected return")
):
predictor = _get_predictor()
stock_codes: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
results = []
for code in stock_codes:
pred = await predictor.predict_stock(code)
results.append(pred)
# Filter out errors, sort by expected_return
valid = [r for r in results if isinstance(r, dict) and "expected_return" in r]
valid.sort(key=lambda x: x.get("expected_return", 0.0), reverse=True)
return {
"total": len(valid),
"requested": stock_codes,
"predictions": valid[:top],
"errors": [r for r in results if "error" in r]
}

View File

@@ -1,15 +1,24 @@
"""
주문/체결 API 엔드포인트
- GET /: 거래 이력 조회 (손익 포함)
- GET /stats/summary: 거래 통계
- DELETE /pending: PENDING/FAILED 거래 정리
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List, Optional
from datetime import datetime, timedelta
from decimal import Decimal
import logging
from app.core.database import get_db
from app.models.models import TradeHistory
from pydantic import BaseModel
router = APIRouter()
logger = logging.getLogger(__name__)
class TradeHistoryResponse(BaseModel):
@@ -22,11 +31,17 @@ class TradeHistoryResponse(BaseModel):
order_quantity: int
executed_price: Optional[Decimal]
executed_quantity: Optional[int]
total_amount: Optional[Decimal]
status: str
order_id: Optional[str]
message: Optional[str]
entry_reason: Optional[str]
exit_reason: Optional[str]
signals: Optional[List[str]]
created_at: datetime
executed_at: Optional[datetime]
profit_loss: Optional[Decimal] = None
profit_loss_rate: Optional[float] = None
class Config:
from_attributes = True
@@ -42,7 +57,7 @@ async def get_trade_history(
days: int = 30,
db: AsyncSession = Depends(get_db)
):
"""거래 이력 조회"""
"""거래 이력 조회 (손익 포함)"""
query = select(TradeHistory)
# 날짜 필터
@@ -66,7 +81,64 @@ async def get_trade_history(
result = await db.execute(query)
trades = result.scalars().all()
return trades
# 손익 계산을 위해 매수/매도 매칭
trade_responses = []
buy_trades = {} # stock_code -> list of buy trades
for trade in trades:
response = TradeHistoryResponse.model_validate(trade)
# 매도 시 손익 정보 설정
if trade.order_type == "SELL" and trade.status == "EXECUTED":
import re
# 1. DB에 저장된 값 먼저 사용
if hasattr(trade, 'net_profit_loss') and trade.net_profit_loss is not None:
response.profit_loss = trade.net_profit_loss
logger.debug(f"✅ DB에서 손익 금액 로드: {response.profit_loss}")
if hasattr(trade, 'profit_loss_rate') and trade.profit_loss_rate is not None:
response.profit_loss_rate = float(trade.profit_loss_rate)
logger.debug(f"✅ DB에서 손익률 로드: {response.profit_loss_rate}%")
# 2. DB 값이 없으면 exit_reason/message에서 파싱 (이전 데이터 호환성)
if response.profit_loss is None or response.profit_loss_rate is None:
text_to_parse = trade.exit_reason or trade.message or ""
logger.debug(f"🔍 손익 파싱 시도 - 종목: {trade.stock_name}, 텍스트: {text_to_parse[:100]}")
# 손익 금액 추출
if response.profit_loss is None:
profit_match = re.search(r'손익:\s*([+-]?[\d,]+)원', text_to_parse)
if profit_match:
profit_str = profit_match.group(1).replace(',', '')
try:
response.profit_loss = Decimal(profit_str)
logger.info(f"✅ 파싱으로 손익 금액 추출: {response.profit_loss}")
except Exception as e:
logger.error(f"❌ 손익 금액 변환 실패: {e}")
# 손익률 추출
if response.profit_loss_rate is None:
message_rate_match = re.search(r'손익률\s*([+-]?[\d.]+)%', trade.message or "")
if message_rate_match:
try:
response.profit_loss_rate = float(message_rate_match.group(1))
logger.info(f"✅ 파싱으로 손익률 추출 (message): {response.profit_loss_rate}%")
except Exception as e:
logger.error(f"❌ 손익률 변환 실패: {e}")
else:
rate_match = re.search(r'([+-]?[\d.]+)%', text_to_parse)
if rate_match:
try:
response.profit_loss_rate = float(rate_match.group(1))
logger.info(f"✅ 파싱으로 손익률 추출 (exit_reason): {response.profit_loss_rate}%")
except Exception as e:
logger.error(f"❌ 손익률 변환 실패: {e}")
trade_responses.append(response)
return trade_responses
@router.get("/{trade_id}", response_model=TradeHistoryResponse)
@@ -116,3 +188,23 @@ async def get_trade_stats(
"success_rate": executed_trades / total_trades if total_trades > 0 else 0,
"total_amount": total_amount
}
@router.delete("/pending")
async def delete_pending_trades(db: AsyncSession = Depends(get_db)):
"""장 마감 후 PENDING과 FAILED 상태의 매수 시도 내역 정리"""
result = await db.execute(
select(TradeHistory).where(
TradeHistory.status.in_(['PENDING', 'FAILED'])
)
)
pending_trades = result.scalars().all()
count = len(pending_trades)
for trade in pending_trades:
await db.delete(trade)
await db.commit()
logger.info(f"✅ 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}

View File

@@ -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 # 최대 재시도 횟수

View File

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

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

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

View File

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

View File

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

View 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

View 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

View File

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

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

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

View 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

View 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

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

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

View File

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

View File

@@ -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():
"""메인 실행 함수"""

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

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

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

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

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

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

View File

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

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

View 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 '수익률 (%)';

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

View File

@@ -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 "🎉 모든 매매 규칙이 등록되었습니다!"

View File

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

View File

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

View File

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

View File

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