1. trade_executor: 손절값 -6.0 하드코딩 → -settings.STOP_LOSS_PERCENT (7%) 2. mdd_manager: MDD 강제매도 시 loss_tracker 호출 추가 3. risk_validator, day_trading: 가격필터 < → <= (5,000원 정확히 차단) 4. daytrading/entry_service: 글로벌 자본 사용률(95%) 가드 추가 5. trade_analyzer: 수익률 0%를 손실에서 제외 (< 0으로 변경) 6. db_worker: 종료 시 큐 drain + DLQ 백업으로 데이터 유실 방지 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
980 lines
37 KiB
Python
980 lines
37 KiB
Python
"""
|
||
데이트레이딩 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.services.loss_tracker import loss_tracker
|
||
from app.core.database import get_db, AsyncSessionLocal
|
||
from app.models.models import ActiveTrade
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려)
|
||
from app.api.endpoints.mcp import request_mcp_risk_assessment
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
KST = pytz.timezone("Asia/Seoul")
|
||
|
||
# API 호출 간격 (settings.API_MIN_REQUEST_INTERVAL 사용, 기본 0.5초)
|
||
_API_CALL_DELAY: float = settings.API_MIN_REQUEST_INTERVAL
|
||
|
||
|
||
# ── 인메모리 상태 ──────────────────────────────────────────────────────────────
|
||
|
||
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
|
||
|
||
# 마지막으로 조회된 총자산 (is_daily_loss_exceeded 계산용 캐시)
|
||
_last_total_assets: float = 0.0
|
||
|
||
# 당일 청산 종목 (재진입 방지 — 청산 후 당일 동일 종목 재매수 차단)
|
||
_closed_today: set = set()
|
||
|
||
# 마지막 초기화 날짜
|
||
_last_reset_date: Optional[date] = None
|
||
|
||
# 매매 실행 lock — _positions/_daily_pnl/_closed_today 변경 시 반드시 획득
|
||
_trade_lock = asyncio.Lock()
|
||
|
||
# 포트폴리오 요약 캐시 (get_balance() 호출 최소화)
|
||
_portfolio_summary_cache: Dict[str, float] = {}
|
||
_portfolio_summary_ts: Optional[datetime] = None
|
||
_portfolio_cache_dirty: bool = True # 최초 또는 매매 발생 시 강제 갱신
|
||
_PORTFOLIO_CACHE_TTL: float = 300.0 # 매매 없을 때 최대 5분 캐시 유지
|
||
|
||
|
||
def _reset_if_new_day() -> None:
|
||
"""날짜가 바뀌면 일일 상태 초기화"""
|
||
global _daily_pnl, _orb_cache, _last_reset_date, _last_total_assets, _closed_today
|
||
global _portfolio_summary_cache, _portfolio_summary_ts, _portfolio_cache_dirty
|
||
today = _today_kst()
|
||
if _last_reset_date != today:
|
||
_daily_pnl = 0.0
|
||
_orb_cache = {}
|
||
_last_total_assets = 0.0
|
||
_closed_today = set()
|
||
_last_reset_date = today
|
||
_portfolio_summary_cache = {}
|
||
_portfolio_summary_ts = None
|
||
_portfolio_cache_dirty = True
|
||
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(force: bool = False) -> Dict[str, float]:
|
||
"""포트폴리오 요약 (총자산, 가용 현금).
|
||
|
||
매매가 발생했거나 캐시 TTL이 만료된 경우에만 KIS get_balance() 호출.
|
||
그 외에는 인메모리 캐시 반환 (API 호출 최소화).
|
||
"""
|
||
global _portfolio_summary_cache, _portfolio_summary_ts, _portfolio_cache_dirty
|
||
|
||
now = _kst_now()
|
||
cache_age = (now - _portfolio_summary_ts).total_seconds() if _portfolio_summary_ts else 9999.0
|
||
cache_valid = (
|
||
not _portfolio_cache_dirty
|
||
and cache_age < _PORTFOLIO_CACHE_TTL
|
||
and bool(_portfolio_summary_cache)
|
||
)
|
||
|
||
if not force and cache_valid:
|
||
logger.debug(f"⏭️ Portfolio summary cache hit (age={cache_age:.0f}s) — skipping get_balance()")
|
||
return _portfolio_summary_cache
|
||
|
||
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)
|
||
_portfolio_summary_cache = {"total_assets": total_assets, "available_cash": available_cash}
|
||
_portfolio_summary_ts = now
|
||
_portfolio_cache_dirty = False
|
||
return _portfolio_summary_cache
|
||
except Exception as e:
|
||
logger.error(f"포트폴리오 조회 실패: {e}")
|
||
return _portfolio_summary_cache or {"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 = 시장가 (None으로 변환)
|
||
) -> bool:
|
||
"""매도 주문 실행"""
|
||
try:
|
||
result = await korea_investment_api.place_order(
|
||
stock_code=stock_code,
|
||
order_type="SELL",
|
||
quantity=quantity,
|
||
price=price if price > 0 else None, # 0이면 None → 시장가(01)
|
||
)
|
||
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,
|
||
order_price: Optional[float] = None, # 주문가 (슬리피지 분석용)
|
||
) -> None:
|
||
"""TradeHistory DB 기록 (db_worker 큐 경유 -- CLAUDE.md 3.2 준수).
|
||
|
||
db 파라미터는 시그니처 호환을 위해 유지하되 사용하지 않음 (읽기 전용).
|
||
"""
|
||
try:
|
||
from app.services.db_worker import enqueue_trade_history
|
||
total = price * quantity
|
||
slippage = (price - order_price) if order_price is not None else None
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": order_type,
|
||
"order_price": order_price,
|
||
"order_quantity": quantity,
|
||
"executed_price": price,
|
||
"executed_quantity": quantity,
|
||
"total_amount": total,
|
||
"net_profit_loss": profit_loss,
|
||
"profit_loss_rate": (profit_loss / total * 100) if total > 0 else 0,
|
||
"slippage": slippage,
|
||
"status": "COMPLETED",
|
||
"entry_reason": reason if order_type == "BUY" else None,
|
||
"exit_reason": reason if order_type == "SELL" else None,
|
||
"executed_at": _kst_now(),
|
||
})
|
||
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. 매매 가능 구간 ──
|
||
in_entry_window = is_day_trade_entry_window() # 09:30~15:00
|
||
|
||
# 포지션이 있으면 입장 창 밖(15:00~15:20)에서도 청산 체크 수행
|
||
if not in_entry_window and not _positions:
|
||
result["phase"] = "outside_window"
|
||
return result
|
||
|
||
result["phase"] = "active"
|
||
|
||
# 사이클 단위 분봉 캐시 — 청산 체크와 신규 진입에서 동일 종목 중복 fetch 방지
|
||
_cycle_candle_cache: Dict[str, List[MinuteCandle]] = {}
|
||
|
||
# ── 4. 활성 포지션 청산 체크 ──
|
||
exits = await _check_exits(candle_cache=_cycle_candle_cache)
|
||
result["exits"] = exits
|
||
|
||
# ── 5. 신규 진입 신호 체크 (진입 창 안에서만) ──
|
||
if not in_entry_window:
|
||
result["phase"] = "exit_only"
|
||
return result
|
||
|
||
# MDD -10% 시 단기 전략(데이트레이딩 포함) 중단 체크 (CLAUDE.md 섹션 5.3)
|
||
from app.services.mdd_manager import mdd_manager
|
||
if not await mdd_manager.is_short_term_strategy_enabled():
|
||
logger.info("⏸️ [DayTrade] MDD -10% 이상 — 단기 전략 중단 (신규 진입 불가)")
|
||
result["phase"] = "active"
|
||
return result
|
||
|
||
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"]
|
||
global _last_total_assets
|
||
if total_assets > 0:
|
||
_last_total_assets = total_assets
|
||
else:
|
||
# 총자산 조회 실패 시 신규 진입 차단 — 손실 한도 체크 우회 방지
|
||
logger.warning("⚠️ 총자산 조회 실패(0원) → 신규 진입 차단")
|
||
return result
|
||
|
||
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,
|
||
candle_cache=_cycle_candle_cache,
|
||
)
|
||
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(_API_CALL_DELAY)
|
||
|
||
|
||
async def _check_exits(
|
||
candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""활성 포지션 청산 조건 체크 및 실행
|
||
|
||
Args:
|
||
candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch.
|
||
"""
|
||
global _daily_pnl, _portfolio_cache_dirty
|
||
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
|
||
|
||
if candle_cache is not None and code in candle_cache:
|
||
candles = candle_cache[code]
|
||
else:
|
||
candles = await _fetch_candles(code)
|
||
if candle_cache is not None:
|
||
candle_cache[code] = candles
|
||
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
|
||
_portfolio_cache_dirty = True # 매도 체결 → 잔고 변경됨
|
||
async with _trade_lock:
|
||
_daily_pnl += pnl
|
||
_positions.pop(code, None)
|
||
_closed_today.add(code) # 당일 재진입 차단
|
||
|
||
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
|
||
from app.services.db_worker import enqueue_close_active_trade
|
||
await _record_trade(
|
||
None, code, pos["stock_name"], "SELL",
|
||
current_price, quantity, exit_signal.reason, pnl,
|
||
)
|
||
await enqueue_close_active_trade(code, strategy_name="day_trade")
|
||
|
||
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
|
||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||
|
||
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(_API_CALL_DELAY)
|
||
|
||
return exits
|
||
|
||
|
||
async def _scan_signals(
|
||
total_assets: float,
|
||
available_cash: float,
|
||
current_day_trade_amount: float,
|
||
candle_cache: Optional[Dict[str, List[MinuteCandle]]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""감시 종목에서 진입 신호 스캔
|
||
|
||
Args:
|
||
candle_cache: 사이클 단위 분봉 캐시 {stock_code: candles}. None이면 직접 fetch.
|
||
"""
|
||
results = []
|
||
for code, name in list(_watchlist.items()):
|
||
if code in _positions:
|
||
continue
|
||
if code in _closed_today: # 당일 청산 종목 재진입 차단
|
||
continue
|
||
try:
|
||
if candle_cache is not None and code in candle_cache:
|
||
candles_1m = candle_cache[code]
|
||
else:
|
||
candles_1m = await _fetch_candles(code)
|
||
if candle_cache is not None:
|
||
candle_cache[code] = candles_1m
|
||
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(_API_CALL_DELAY)
|
||
|
||
# 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]:
|
||
"""진입 주문 실행 및 포지션 등록.
|
||
|
||
주문 직전 MCP RISK_ASSESSMENT를 호출해 approve 시에만 매수한다 (이슈 4).
|
||
MCP 장애/타임아웃은 fail-open(approve=True)으로 처리해 매매를 차단하지 않는다.
|
||
"""
|
||
async with _trade_lock:
|
||
if len(_positions) >= MAX_POSITIONS:
|
||
return None
|
||
if signal.stock_code in _positions:
|
||
return None
|
||
if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어
|
||
logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}")
|
||
return None
|
||
|
||
# ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4 — day_trading 측) ────────────
|
||
_mcp_trade_plan = {
|
||
"action": "BUY",
|
||
"strategy": signal.strategy,
|
||
"stock_code": signal.stock_code,
|
||
"stock_name": signal.stock_name,
|
||
"entry_price": signal.entry_price,
|
||
"stop_loss": signal.stop_loss,
|
||
"target_price": signal.target_price,
|
||
"quantity": quantity,
|
||
"reason": signal.reason,
|
||
}
|
||
_mcp_signals = {
|
||
"strategy": signal.strategy,
|
||
"confidence": signal.confidence,
|
||
"reason": signal.reason,
|
||
}
|
||
_approve, _mcp_reason = await request_mcp_risk_assessment(
|
||
_mcp_trade_plan, {}, _mcp_signals
|
||
)
|
||
if not _approve:
|
||
logger.warning(
|
||
f"🛑 MCP RISK_ASSESSMENT DENY [{signal.stock_code}] "
|
||
f"→ 주문 스킵. 사유: {_mcp_reason}"
|
||
)
|
||
return None
|
||
if _mcp_reason:
|
||
logger.info(
|
||
f"✅ MCP RISK_ASSESSMENT APPROVE [{signal.stock_code}]. 참고: {_mcp_reason}"
|
||
)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
order_no = await _place_buy_order(
|
||
signal.stock_code, quantity, int(signal.entry_price)
|
||
)
|
||
if not order_no:
|
||
return None
|
||
|
||
global _portfolio_cache_dirty
|
||
_portfolio_cache_dirty = True # 매수 체결 → 잔고 변경됨
|
||
|
||
_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,
|
||
}
|
||
|
||
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
|
||
from app.services.db_worker import enqueue_active_trade_upsert
|
||
await _record_trade(
|
||
None, signal.stock_code, signal.stock_name, "BUY",
|
||
signal.entry_price, quantity, signal.reason,
|
||
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
|
||
)
|
||
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
|
||
await enqueue_active_trade_upsert({
|
||
"stock_code": signal.stock_code,
|
||
"stock_name": signal.stock_name,
|
||
"quantity": quantity,
|
||
"entry_price": signal.entry_price,
|
||
"stop_loss": signal.stop_loss,
|
||
"take_profit": signal.target_price,
|
||
"highest_since_entry": signal.entry_price,
|
||
"strategy_name": "day_trade",
|
||
"entry_reason": signal.strategy,
|
||
"status": "active",
|
||
})
|
||
|
||
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, _portfolio_cache_dirty
|
||
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"]
|
||
_portfolio_cache_dirty = True # EOD 청산 → 잔고 변경됨
|
||
async with _trade_lock:
|
||
_daily_pnl += pnl
|
||
_positions.pop(code, None)
|
||
_closed_today.add(code) # 당일 재진입 차단
|
||
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
|
||
|
||
# db_worker 큐 경유 — CLAUDE.md 3.2 준수
|
||
from app.services.db_worker import enqueue_close_active_trade
|
||
await _record_trade(
|
||
None, code, pos["stock_name"], "SELL",
|
||
current_price, pos["quantity"], "EOD_flat", pnl,
|
||
)
|
||
await enqueue_close_active_trade(code, strategy_name="day_trade")
|
||
|
||
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
|
||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||
|
||
await websocket_manager.broadcast(_dumps({
|
||
"type": "day_trade_exit",
|
||
"stock_code": code,
|
||
"reason": "EOD_flat",
|
||
"pnl": pnl,
|
||
}))
|
||
except Exception as e:
|
||
logger.error(f"EOD 청산 오류 [{code}]: {e}")
|
||
await asyncio.sleep(_API_CALL_DELAY)
|
||
|
||
|
||
async def restore_positions_from_db() -> None:
|
||
"""앱 재시작 후 오늘 데이트레이딩 포지션 DB 복구.
|
||
|
||
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
|
||
인메모리 _positions를 재구성한다.
|
||
|
||
created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행.
|
||
한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로
|
||
UTC 기준으로 비교해도 동일한 결과를 얻는다.
|
||
"""
|
||
if _positions:
|
||
return # 이미 포지션 있으면 스킵
|
||
from datetime import timezone
|
||
today_utc = datetime.now(timezone.utc).date()
|
||
try:
|
||
async with AsyncSessionLocal() as db:
|
||
result = await db.execute(
|
||
select(ActiveTrade).where(
|
||
ActiveTrade.strategy_name == "day_trade",
|
||
ActiveTrade.status == "active",
|
||
)
|
||
)
|
||
rows = result.scalars().all()
|
||
for row in rows:
|
||
if row.created_at and row.created_at.date() != today_utc:
|
||
continue
|
||
entry_price = float(row.entry_price)
|
||
if row.created_at:
|
||
created_kst = row.created_at.replace(tzinfo=pytz.utc).astimezone(KST)
|
||
entry_time_str = created_kst.strftime("%H:%M:%S")
|
||
else:
|
||
entry_time_str = "??:??:??"
|
||
_positions[row.stock_code] = {
|
||
"stock_name": row.stock_name or row.stock_code,
|
||
"strategy": row.entry_reason or "unknown",
|
||
"entry_price": entry_price,
|
||
"stop_loss": float(row.stop_loss) if row.stop_loss else entry_price * 0.97,
|
||
"target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03,
|
||
"quantity": row.quantity,
|
||
"highest_price": float(row.highest_since_entry or row.entry_price),
|
||
"entry_time": entry_time_str,
|
||
"reason": row.entry_reason or "",
|
||
}
|
||
if _positions:
|
||
logger.info(f"📋 데이트레이딩 포지션 복구: {list(_positions.keys())}")
|
||
except Exception as e:
|
||
logger.error(f"데이트레이딩 포지션 복구 실패: {e}")
|
||
|
||
|
||
# ── 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 total_assets <= 0:
|
||
raise HTTPException(503, "총자산 조회에 실패했습니다. 잠시 후 다시 시도해주세요.")
|
||
|
||
if is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||
raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.")
|
||
|
||
candles_1m = await _fetch_candles(code)
|
||
# 분봉 데이터 없으면 신호 분석 불가 → early return
|
||
if not candles_1m:
|
||
return {"status": "no_signal", "message": "분봉 데이터를 가져올 수 없습니다."}
|
||
|
||
candles_5m = aggregate_to_5min(candles_1m)
|
||
current_price = candles_1m[-1].close
|
||
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(
|
||
_last_total_assets,
|
||
_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(),
|
||
}
|