Files
auto-stock/backend/app/api/endpoints/day_trading.py
2026-02-22 17:23:20 +09:00

732 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
데이트레이딩 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(),
}