- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
840 lines
37 KiB
Python
840 lines
37 KiB
Python
"""
|
||
데이트레이딩 API 엔드포인트
|
||
|
||
라우터 prefix: /api/daytrading
|
||
|
||
엔드포인트 (전부 실제 엔진 services/daytrading 상태 기준):
|
||
GET /watchlist — 감시 종목 목록 조회
|
||
POST /watchlist — 감시 종목 추가/교체
|
||
GET /signals — 현재 진입 신호 조회 (읽기 전용, 참고용)
|
||
POST /execute — 단일 종목 신호 확인 (dry_run 전용, 실매수 410)
|
||
GET /positions — 활성 데이트레이딩 포지션 조회
|
||
POST /close-all — 전체 포지션 강제 청산 (비어 있으면 전일·고아 잔고 청산)
|
||
POST /cycle — 1 사이클 수동 실행 (디버그용)
|
||
|
||
[2026-10-09] 레거시 매매 사이클(_check_exits/_scan_signals/_execute_entry/
|
||
_execute_eod_flat/restore_positions_from_db)은 제거했다. 아래 모듈 전역(_watchlist,
|
||
_positions, _orb_cache …)은 엔진과 공유되지 않는 잔재이며 매매 판정에 쓰지 않는다.
|
||
"""
|
||
from fastapi import APIRouter, HTTPException, Depends
|
||
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,
|
||
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||
calculate_day_trade_position_size, is_daily_loss_exceeded,
|
||
ENTRY_CUTOFF, 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.services.loss_tracker import loss_tracker
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from app.core.security import require_api_key
|
||
|
||
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()
|
||
|
||
|
||
def _is_new_entry_allowed() -> bool:
|
||
"""신규 진입 마감 시간(CLAUDE.md 7.3: 14:30, settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN)
|
||
이전인지 확인. 청산 경로에는 영향 없음 — 신규 진입 체크에서만 사용."""
|
||
return _kst_time() < ENTRY_CUTOFF
|
||
|
||
|
||
# 감시 종목 (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 _get_current_price(stock_code: str) -> float:
|
||
"""현재가 조회 (실패 시 0.0 — 기존 계약 유지. 신규 호출부는 _v2 를 쓸 것)"""
|
||
price = await _get_current_price_v2(stock_code)
|
||
return price if price is not None else 0.0
|
||
|
||
|
||
# [R7 2026-10-10] 현재가 조회 실패 경고 스로틀 (종목별 마지막 경고 시각, monotonic 초)
|
||
_PRICE_FAIL_LOG_INTERVAL_SEC: float = 60.0
|
||
_price_fail_logged_at: Dict[str, float] = {}
|
||
|
||
|
||
def _warn_price_fail_throttled(stock_code: str, detail: str) -> None:
|
||
import time as _time
|
||
now = _time.monotonic()
|
||
last = _price_fail_logged_at.get(stock_code)
|
||
if last is not None and now - last < _PRICE_FAIL_LOG_INTERVAL_SEC:
|
||
return
|
||
_price_fail_logged_at[stock_code] = now
|
||
logger.warning(f"[DT-API] {stock_code} 현재가 조회 실패 — {detail} "
|
||
f"(종목당 {_PRICE_FAIL_LOG_INTERVAL_SEC:.0f}초에 1회 기록)")
|
||
|
||
|
||
async def _get_current_price_v2(stock_code: str) -> Optional[float]:
|
||
"""[R7 2026-10-10] 현재가 조회 — 실패·0 이하·비유한이면 None.
|
||
|
||
구버전은 실패를 0.0 으로 삼켜 /positions 가 손익률 -100% 를 응답했다.
|
||
"""
|
||
import math
|
||
try:
|
||
result = await korea_investment_api.get_current_price(stock_code)
|
||
price = float(((result or {}).get("output") or {}).get("stck_prpr", 0) or 0)
|
||
except Exception as e:
|
||
_warn_price_fail_throttled(stock_code, f"{type(e).__name__}: {e}")
|
||
return None
|
||
if not math.isfinite(price) or price <= 0:
|
||
_warn_price_fail_throttled(stock_code, f"유효하지 않은 가격 응답({price})")
|
||
return None
|
||
return price
|
||
|
||
|
||
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가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨)
|
||
summary = (balance.get("output2") or [{}])[0]
|
||
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 _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,
|
||
# [2026-10-01] 명시 — 빠지면 모델 기본값 'swing' 으로 기록돼 DT 거래가 스윙 통계에 섞인다.
|
||
"strategy_type": "daytrading",
|
||
"executed_at": _kst_now(),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"TradeHistory 기록 실패: {e}")
|
||
|
||
|
||
# ── 레거시 사이클 (제거됨) ────────────────────────────────────────────────────
|
||
|
||
async def run_day_trading_cycle() -> Dict[str, Any]:
|
||
"""[DEPRECATED 2026-10-09] 레거시 1분 사이클 — 매매하지 않는 정지 스텁.
|
||
|
||
이 모듈의 레거시 상태(_positions/_orb_cache/_closed_today)는 실제 엔진
|
||
(services/daytrading, day_trading_state)과 공유되지 않는 별개 dict 다. 이 경로가
|
||
다시 돌면 엔진과 같은 종목을 이중 매수·이중 매도한다. 호출부는 deprecated
|
||
`schedulers.day_trade_scheduler`(main.py 미배선)의 지연 import 1곳뿐이라 함수명은
|
||
남기고(ImportError 방지, §4 시그니처 보존) 본문은 매매 없이 반환한다.
|
||
청산·진입·EOD·ORB 는 전부 `services/daytrading/scheduler.daytrading_scheduler` 가 한다.
|
||
"""
|
||
logger.error(
|
||
"[DayTrade] 레거시 run_day_trading_cycle 호출됨 — 매매하지 않음. "
|
||
"엔진(services/daytrading/scheduler.daytrading_scheduler)만 사용할 것"
|
||
)
|
||
return {
|
||
"time": _kst_now().strftime("%H:%M:%S"),
|
||
"phase": "deprecated",
|
||
"exits": [],
|
||
"entries": [],
|
||
"signals": [],
|
||
}
|
||
|
||
|
||
# ── 엔진 기준 읽기 전용 신호 평가 (v2) ───────────────────────────────────────
|
||
|
||
async def _evaluate_engine_signal_v2(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
total_assets: float,
|
||
available_cash: float,
|
||
current_day_trade_amount: float,
|
||
) -> Dict[str, Any]:
|
||
"""[2026-10-09] 엔진(`entry_service._try_enter`)과 같은 입력·순서로 신호만 판정한다.
|
||
|
||
주문·MCP 평가·슬롯 예약·DB 접근 없음(읽기 전용). 레거시 `_scan_signals` 는 이 모듈의
|
||
빈 `_orb_cache`/`_positions`/`_closed_today` 로 판정해 ORB 신호가 영영 안 나오고
|
||
보유·당일청산·DT 예산 차감이 빠져 실제 엔진과 어긋났다.
|
||
|
||
`_try_enter` 와 동일하게 맞춘 것: loss_tracker 밴 / VI 쿨다운 / 엔진 분봉 캐시
|
||
(`_get_candles_cached`, 전일 워밍업 포함) / 당일 봉 필터(`filter_today_candles`) /
|
||
5분봉 ≥5 / VWAP=당일 봉 / ORB=`day_trading_state.get_orb` / 현재가=당일 마지막 1분봉 /
|
||
ORB 입력 `DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED` 분기 / ORB→VWAP→RSI_BB 순서 /
|
||
사이징 = position_size → confidence 배율 → `_clamp_day_trade_budget` 재클램프.
|
||
`_try_enter` 를 바꾸면 이 함수도 같이 바꿔야 한다(테스트가 판정 입력을 고정).
|
||
|
||
Returns:
|
||
{"signal": DayTradeSignal | None, "quantity": int, "skip": str | None}
|
||
"""
|
||
_es, _st = _engine()
|
||
if await loss_tracker.is_banned_safe(stock_code):
|
||
return {"signal": None, "quantity": 0, "skip": "loss_tracker ban"}
|
||
from app.services.market_playbook import is_vi_active
|
||
if is_vi_active(stock_code):
|
||
return {"signal": None, "quantity": 0, "skip": "VI 쿨다운"}
|
||
|
||
candles = await _es._get_candles_cached(stock_code)
|
||
if not candles:
|
||
return {"signal": None, "quantity": 0, "skip": "분봉 없음"}
|
||
candles_today = _es.filter_today_candles(candles)
|
||
if not candles_today:
|
||
return {"signal": None, "quantity": 0, "skip": "당일 분봉 없음"}
|
||
candles_5m = aggregate_to_5min(candles)
|
||
if len(candles_5m) < 5:
|
||
return {"signal": None, "quantity": 0, "skip": "5분봉 부족"}
|
||
|
||
vwap = calculate_vwap(candles_today)
|
||
orb = _st.get_orb(stock_code)
|
||
current_price = float(candles_today[-1].close)
|
||
if current_price <= 0:
|
||
return {"signal": None, "quantity": 0, "skip": "현재가 없음"}
|
||
|
||
signal: Optional[DayTradeSignal] = None
|
||
if orb:
|
||
_orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today
|
||
signal = check_orb_signal(stock_code, stock_name, _orb_input, orb, current_price)
|
||
if not signal and vwap:
|
||
signal = check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, current_price)
|
||
if not signal:
|
||
signal = check_rsi_bb_signal(stock_code, stock_name, candles_5m, current_price)
|
||
if not signal:
|
||
return {"signal": None, "quantity": 0, "skip": None}
|
||
|
||
budget = calculate_day_trade_position_size(
|
||
total_assets=total_assets,
|
||
available_cash=available_cash,
|
||
current_day_trade_amount=current_day_trade_amount,
|
||
)
|
||
confidence_scale = min(1.2, signal.confidence / 0.7)
|
||
budget = _es._clamp_day_trade_budget(
|
||
budget * confidence_scale,
|
||
total_assets=total_assets,
|
||
available_cash=available_cash,
|
||
current_day_trade_amount=current_day_trade_amount,
|
||
)
|
||
quantity = int(budget // current_price) if current_price > 0 else 0
|
||
return {
|
||
"signal": signal,
|
||
"quantity": quantity,
|
||
"skip": None if quantity > 0 else f"예산 부족(예산 {budget:,.0f}원 / 단가 {current_price:,.0f}원)",
|
||
"budget": budget,
|
||
}
|
||
|
||
|
||
def _engine_dt_amount() -> float:
|
||
"""엔진 인메모리 DT 투입액(진입가×수량) — 엔진 `_try_enter` 와 같은 정의."""
|
||
_, _st = _engine()
|
||
return float(sum(
|
||
float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0)
|
||
for p in _st.positions.values()
|
||
))
|
||
|
||
|
||
# ── API 엔드포인트 ────────────────────────────────────────────────────────────
|
||
|
||
# ── 실제 매매 엔진(services/daytrading) 상태 연결 ─────────────────────────────
|
||
# main.py 는 services/daytrading/scheduler.daytrading_scheduler() 를 실행한다.
|
||
# 이 모듈의 _watchlist/_positions/_orb_cache 는 그 엔진과 공유되지 않는 별개 dict라
|
||
# 조회 API가 항상 0을 돌려주는 문제가 있었음(2026-09-22). 조회/설정 엔드포인트는
|
||
# 엔진 상태(entry_service._watchlist, day_trading_state)를 기준으로 응답한다.
|
||
def _engine():
|
||
from app.services.daytrading import entry_service as _es
|
||
from app.services.daytrading.position_manager import day_trading_state as _st
|
||
return _es, _st
|
||
|
||
|
||
@router.get("/watchlist")
|
||
async def get_watchlist():
|
||
"""감시 종목 목록 조회 (실제 엔진 기준)"""
|
||
_reset_if_new_day()
|
||
_es, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
wl = _es.get_watchlist()
|
||
return {
|
||
"watchlist": [
|
||
{"code": k, "name": v, "has_orb": _st.has_orb(k)}
|
||
for k, v in wl.items()
|
||
],
|
||
"count": len(wl),
|
||
}
|
||
|
||
|
||
@router.post("/watchlist", dependencies=[Depends(require_api_key)])
|
||
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}
|
||
_es, _ = _engine()
|
||
_es.set_watchlist(req.stocks)
|
||
logger.info(f"📋 감시 종목 설정: {list(_watchlist.keys())}")
|
||
return {"status": "ok", "watchlist": list(_watchlist.keys())}
|
||
|
||
|
||
@router.get("/signals")
|
||
async def get_current_signals():
|
||
"""현재 진입 가능 신호 조회 (주문 미실행, 실제 엔진 상태 기준).
|
||
|
||
[2026-10-09] 레거시 모듈 상태(항상 비어 있음) 대신 엔진의 감시종목·보유·당일청산·ORB·
|
||
DT 투입액으로 `_evaluate_engine_signal_v2` 를 돌린다. 참고용(advisory)이다 — MDD·
|
||
적응형·플레이북·국면·MCP 게이트는 네트워크/외부 판정이라 여기서 재현하지 않고, 엔진이
|
||
실제 주문 직전에 다시 판정한다. 로컬 게이트(일일손실·3연패·마감·슬롯)는 `gates` 로 노출.
|
||
"""
|
||
_reset_if_new_day()
|
||
_es, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
if not is_day_trade_entry_window():
|
||
return {
|
||
"phase": "outside_window",
|
||
"message": "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).",
|
||
"signals": [],
|
||
"source": "engine",
|
||
}
|
||
|
||
portfolio = await _get_portfolio_summary()
|
||
total_assets = portfolio["total_assets"]
|
||
available_cash = portfolio["available_cash"]
|
||
dt_amount = _engine_dt_amount()
|
||
open_slots = max(0, MAX_POSITIONS - _st.position_count())
|
||
gates = {
|
||
"entry_cutoff_passed": not _is_new_entry_allowed(),
|
||
"daily_loss_exceeded": bool(is_daily_loss_exceeded(_st.last_total_assets, _st.daily_pnl)),
|
||
"cooldown_active": bool(_st.is_cooldown_active()),
|
||
"open_slots": open_slots,
|
||
"total_assets_unavailable": total_assets <= 0,
|
||
}
|
||
|
||
results: List[Dict[str, Any]] = []
|
||
skipped_held = skipped_closed = 0
|
||
if total_assets > 0:
|
||
for code, name in list(_es.get_watchlist().items()):
|
||
if code in _st.positions:
|
||
skipped_held += 1
|
||
continue
|
||
if _st.is_closed_today(code):
|
||
skipped_closed += 1
|
||
continue
|
||
try:
|
||
ev = await _evaluate_engine_signal_v2(
|
||
code, name, total_assets, available_cash, dt_amount
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[DayTrade] /signals 판정 오류 [{code}]: {e}")
|
||
continue
|
||
if ev.get("signal") is not None and int(ev.get("quantity", 0) or 0) > 0:
|
||
results.append(ev)
|
||
|
||
results.sort(key=lambda x: x["signal"].confidence, reverse=True)
|
||
results = results[:open_slots]
|
||
return {
|
||
"phase": "active",
|
||
"source": "engine",
|
||
"advisory": True,
|
||
"gates": gates,
|
||
"skipped_held": skipped_held,
|
||
"skipped_closed_today": skipped_closed,
|
||
"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": _finite_or_none(s["signal"].target_price),
|
||
"reason": s["signal"].reason,
|
||
"confidence": s["signal"].confidence,
|
||
"quantity": s["quantity"],
|
||
}
|
||
for s in results
|
||
],
|
||
"count": len(results),
|
||
}
|
||
|
||
|
||
@router.post("/execute", dependencies=[Depends(require_api_key)])
|
||
async def execute_day_trade(req: ExecuteRequest):
|
||
"""특정 종목 데이트레이딩 신호 확인 (dry_run 전용, 실제 엔진 상태 기준).
|
||
|
||
[2026-09-23] 실매수(dry_run=False)는 비활성화. 이 경로로 진입한 포지션은 실제 엔진의
|
||
`day_trading_state`에 등록되지 않아 손절·트레일링·EOD 청산을 전혀 받지 못한다.
|
||
신호 확인용 dry_run 만 허용한다.
|
||
[2026-10-09] dry_run 판정도 레거시 빈 상태 대신 엔진 상태(`_evaluate_engine_signal_v2`).
|
||
"""
|
||
if not req.dry_run:
|
||
raise HTTPException(
|
||
status_code=410,
|
||
detail=(
|
||
"이 엔드포인트의 실매수는 비활성화되었습니다(엔진 미관리 포지션 방지). "
|
||
"자동 진입은 스케줄러가, 수동 매수는 /portfolio/buy 를 사용하세요."
|
||
),
|
||
)
|
||
_reset_if_new_day()
|
||
_es, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
code = req.stock_code
|
||
name = _es.get_watchlist().get(code, code)
|
||
|
||
if code in _st.positions:
|
||
raise HTTPException(400, f"{code}는 이미 포지션이 있습니다.")
|
||
if _st.is_closed_today(code):
|
||
raise HTTPException(400, f"{code}는 당일 청산(재진입 차단) 종목입니다.")
|
||
if _st.position_count() >= MAX_POSITIONS:
|
||
raise HTTPException(400, f"최대 포지션 수({MAX_POSITIONS}종목)에 도달했습니다.")
|
||
if not is_day_trade_entry_window():
|
||
raise HTTPException(400, "데이트레이딩 진입 시간이 아닙니다 (09:30~15:00).")
|
||
if not _is_new_entry_allowed():
|
||
raise HTTPException(400, f"신규 진입 마감 시간({ENTRY_CUTOFF.strftime('%H:%M')}) 이후에는 진입할 수 없습니다.")
|
||
|
||
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, _st.daily_pnl):
|
||
raise HTTPException(
|
||
400,
|
||
f"일일 손실 한도(-{abs(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:.1f}%)에 도달하여 신규 진입이 차단됩니다.",
|
||
)
|
||
|
||
ev = await _evaluate_engine_signal_v2(code, name, total_assets, available_cash, _engine_dt_amount())
|
||
signal = ev.get("signal")
|
||
if signal is None:
|
||
return {
|
||
"status": "no_signal",
|
||
"message": "현재 진입 조건을 충족하는 신호가 없습니다."
|
||
+ (f" ({ev['skip']})" if ev.get("skip") else ""),
|
||
}
|
||
quantity = int(ev.get("quantity", 0) or 0)
|
||
if quantity <= 0:
|
||
return {"status": "no_budget", "message": f"데이트레이딩 예산이 부족합니다 ({ev.get('skip')})."}
|
||
|
||
return {
|
||
"status": "dry_run",
|
||
"source": "engine",
|
||
"signal": {
|
||
"strategy": signal.strategy,
|
||
"entry_price": signal.entry_price,
|
||
"stop_loss": signal.stop_loss,
|
||
"target_price": _finite_or_none(signal.target_price),
|
||
"reason": signal.reason,
|
||
"quantity": quantity,
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/positions")
|
||
async def get_positions():
|
||
"""활성 데이트레이딩 포지션 조회 (실제 엔진 기준)"""
|
||
_reset_if_new_day()
|
||
_, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
result = []
|
||
for code, pos in _st.positions.items():
|
||
entry_price = float(pos.get("entry_price", 0) or 0)
|
||
quantity = int(pos.get("quantity", 0) or 0)
|
||
# [R7 2026-10-10] 조회 실패를 0.0 으로 받아 pnl=-진입가×수량, -100% 가 응답되던
|
||
# 문제 수정. 실패면 엔진이 최근 관측한 현재가, 그것도 없으면 None(손익 미표시).
|
||
current_price = await _get_current_price_v2(code)
|
||
price_source = "live"
|
||
if current_price is None:
|
||
cached = _finite_or_none(pos.get("current_price"))
|
||
if cached is not None and cached > 0:
|
||
current_price, price_source = cached, "engine_cached"
|
||
else:
|
||
current_price, price_source = None, "unavailable"
|
||
if current_price is not None and entry_price > 0:
|
||
pnl = (current_price - entry_price) * quantity
|
||
pnl_pct = (current_price - entry_price) / entry_price * 100
|
||
else:
|
||
pnl = pnl_pct = None
|
||
result.append({
|
||
"stock_code": code,
|
||
"stock_name": pos.get("stock_name", code),
|
||
"strategy": pos.get("strategy", ""),
|
||
"entry_price": entry_price,
|
||
"current_price": _finite_or_none(current_price),
|
||
# [M2 2026-10-02] 분할매도 후 target_price=inf(트레일링 전용 표식)가 그대로
|
||
# 직렬화돼 JSON 인코딩 오류로 이 엔드포인트가 500 이었다 → inf/NaN 은 None.
|
||
"stop_loss": _finite_or_none(pos.get("stop_loss")),
|
||
"target_price": _finite_or_none(pos.get("target_price")),
|
||
"partial_sold": bool(pos.get("partial_sold", False)),
|
||
"quantity": quantity,
|
||
"price_source": price_source,
|
||
"pnl": _finite_or_none(round(pnl, 0)) if pnl is not None else None,
|
||
"pnl_pct": _finite_or_none(round(pnl_pct, 2)) if pnl_pct is not None else None,
|
||
"entry_time": str(pos.get("entry_time", "")),
|
||
})
|
||
return {
|
||
"positions": result,
|
||
"count": len(result),
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
||
_st.last_total_assets,
|
||
_st.daily_pnl,
|
||
),
|
||
}
|
||
|
||
|
||
@router.post("/close-all", dependencies=[Depends(require_api_key)])
|
||
async def close_all_positions():
|
||
"""전체 포지션 수동 강제 청산 — 실제 엔진(services/daytrading)에 위임.
|
||
|
||
[2026-09-23] 이 모듈의 레거시 인메모리 상태로 청산하면 엔진 상태가 그대로 남아
|
||
30초 뒤 같은 물량을 다시 매도한다(이중 매도). 엔진 경로만 사용한다.
|
||
[R4 2026-10-09] 보유 경로도 스케줄러와 같은 `dt_cycle_lock` 안에서 force_close_all 을
|
||
부른다(→ `_force_close_all_locked_impl`).
|
||
"""
|
||
_es, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
if not _st.positions:
|
||
return await _close_stale_when_empty_v2()
|
||
return await _force_close_all_locked_impl()
|
||
|
||
|
||
async def _force_close_all_locked_impl() -> Dict[str, Any]:
|
||
"""[R4 2026-10-09] /close-all 보유 경로 — dt_cycle_lock 안에서 엔진 강제 청산.
|
||
|
||
예전에는 락 없이 force_close_all 을 돌아, 같은 순간 스케줄러 사이클(락 보유)의
|
||
scan_and_enter 가 새 종목을 진입시켜 응답은 'N건 청산 완료'인데 DT 포지션이 남았다.
|
||
락이 잡혀 있으면 /cycle·빈 메모리 경로와 같은 규약으로 기다리지 않고 409.
|
||
청산 후에도 남은 포지션(청산 주문 진행 중·현재가/주문 실패)은 `remaining_positions`
|
||
로 응답에 드러낸다 — '청산 완료' 문구가 잔존을 숨기지 않게.
|
||
"""
|
||
from app.services.daytrading import exit_service as _xs
|
||
from app.services.daytrading.position_manager import dt_cycle_lock
|
||
|
||
_, _st = _engine()
|
||
if dt_cycle_lock.locked():
|
||
raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도")
|
||
async with dt_cycle_lock:
|
||
_st.reset_if_new_day()
|
||
if not _st.positions:
|
||
# 락 확인 직후 다른 경로가 포지션을 비웠다 — 빈 메모리 경로를 락 안에서 수행.
|
||
if not is_market_open():
|
||
return _stale_market_closed_result_v2()
|
||
stale_exits = list(await _xs.close_stale_positions() or [])
|
||
return _stale_result_v2(stale_exits)
|
||
exits = list(await _xs.force_close_all(reason="수동 전량 청산 (API)") or [])
|
||
remaining = sorted(_st.positions.keys())
|
||
if remaining:
|
||
logger.warning(
|
||
f"[DayTrade] 수동 전량 청산 후 DT 포지션 {len(remaining)}건 잔존"
|
||
f"(청산 주문 진행 중·현재가/주문 실패 등 — 재시도 필요): {remaining}"
|
||
)
|
||
return {
|
||
"status": "ok",
|
||
"message": (
|
||
f"데이트레이딩 포지션 {len(exits)}건 청산 완료"
|
||
+ (f" — {len(remaining)}건 잔존(재시도 필요)" if remaining else "")
|
||
),
|
||
"exits": exits,
|
||
"remaining_positions": remaining,
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
}
|
||
|
||
|
||
def _stale_market_closed_result_v2() -> Dict[str, Any]:
|
||
_, _st = _engine()
|
||
return {
|
||
"status": "ok",
|
||
"message": "활성 포지션 없음 — 장 운영 시간이 아니라 전일·고아 DT 잔고 청산은 수행하지 않음",
|
||
"exits": [], "stale_exits": [], "stale_checked": False,
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
}
|
||
|
||
|
||
def _stale_result_v2(stale_exits: list) -> Dict[str, Any]:
|
||
from app.services.daytrading import exit_service as _xs
|
||
_, _st = _engine()
|
||
if stale_exits:
|
||
logger.warning(f"[DayTrade] 수동 전량 청산: 전일·고아 DT 잔고 {len(stale_exits)}건 청산")
|
||
return {
|
||
"status": "ok",
|
||
"message": (
|
||
f"활성 포지션 없음 — 전일·고아 DT 잔고 {len(stale_exits)}건 청산"
|
||
if stale_exits else "활성 포지션 없음 — 전일·고아 DT 잔고 없음"
|
||
),
|
||
"exits": stale_exits,
|
||
"stale_exits": stale_exits,
|
||
"stale_checked": True,
|
||
"stale_realized_pnl": round(_xs.get_stale_realized_pnl_v2(), 0),
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
}
|
||
|
||
|
||
async def _close_stale_when_empty_v2() -> Dict[str, Any]:
|
||
"""[2026-10-09] /close-all — 인메모리 포지션이 없을 때 전일·고아 DT 잔고 정리.
|
||
|
||
재기동·자정 reset 뒤에는 인메모리가 비어도 전일 미청산·고아 DT 잔고가 KIS 에 남는다.
|
||
예전에는 '활성 포지션 없음' 으로 즉시 반환해 수동 전량 청산이 아무것도 하지 않았다.
|
||
스케줄러 EOD 분기(scheduler.py)와 같이 `dt_cycle_lock` 안에서 `close_stale_positions()`
|
||
를 부른다 — 매도 수량 상한(KIS 주문가능·보유 − 타 전략 보유분, §3.5)·count_daily_pnl=False
|
||
(전일 손익 당일 미산입)·결과 불명 재주문 차단은 그 함수가 이미 보장한다.
|
||
사이클이 락을 잡고 있으면 기다리지 않고 409(/cycle 과 같은 규약) — 그 사이클 자체가
|
||
전일·고아 청산을 수행한다. 장외에는 close_stale_positions 가 주문하지 않으므로 그대로 알린다.
|
||
"""
|
||
from app.services.daytrading import exit_service as _xs
|
||
from app.services.daytrading.position_manager import dt_cycle_lock
|
||
|
||
_, _st = _engine()
|
||
if not is_market_open():
|
||
return _stale_market_closed_result_v2()
|
||
if dt_cycle_lock.locked():
|
||
raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도(사이클이 전일·고아 잔고도 정리함)")
|
||
async with dt_cycle_lock:
|
||
_st.reset_if_new_day()
|
||
if _st.positions:
|
||
# 락 대기 사이 다른 경로가 포지션을 복원했다 — 엔진 강제 청산으로 넘긴다.
|
||
exits = list(await _xs.force_close_all(reason="수동 전량 청산 (API)") or [])
|
||
remaining = sorted(_st.positions.keys())
|
||
return {
|
||
"status": "ok",
|
||
"message": (
|
||
f"데이트레이딩 포지션 {len(exits)}건 청산 완료"
|
||
+ (f" — {len(remaining)}건 잔존(재시도 필요)" if remaining else "")
|
||
),
|
||
"exits": exits, "stale_exits": [], "stale_checked": False,
|
||
"remaining_positions": remaining,
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
}
|
||
stale_exits = list(await _xs.close_stale_positions() or [])
|
||
return _stale_result_v2(stale_exits)
|
||
|
||
|
||
def _finite_or_none(value):
|
||
"""[M2] JSON 직렬화 가능한 유한 숫자만 통과 — inf/NaN/변환 불가는 None."""
|
||
import math
|
||
if value is None:
|
||
return None
|
||
try:
|
||
f = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return f if math.isfinite(f) else None
|
||
|
||
|
||
# [M5] 수동 사이클 단계별 상한(초) — 스케줄러 STEP_TIMEOUT 과 같은 값을 쓴다.
|
||
def _manual_cycle_step_timeout() -> int:
|
||
from app.services.daytrading.scheduler import STEP_TIMEOUT
|
||
return STEP_TIMEOUT
|
||
|
||
|
||
@router.post("/cycle", dependencies=[Depends(require_api_key)])
|
||
async def manual_cycle():
|
||
"""1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용).
|
||
|
||
[M5 2026-10-02] 스케줄러와 **같은 전역 락(dt_cycle_lock)** 을 잡는다. 예전에는
|
||
스케줄러 사이클과 동시에 돌아 같은 포지션을 두 번 매도하거나, 두 스캔이 슬롯·예산
|
||
검사를 동시에 통과해 한도를 넘겨 매수할 수 있었다. 락이 잡혀 있으면 기다리지 않고
|
||
409 를 돌려준다. 게이트: 장 운영 시간 · 락 / (진입만) DAY_TRADE_ENABLED · 진입 시간창.
|
||
|
||
[2026-10-09 R4] DAY_TRADE_ENABLED=False 여도 **청산 체크는 수행**한다. 스케줄러가
|
||
비활성 시 "신규 진입만 중단"하고 청산(손절·시간손절·트레일링)은 계속 돌리므로 수동
|
||
경로도 같은 규약을 따른다(청산은 매수보다 우선 — CLAUDE.md §8.1-11). 진입만 생략하고
|
||
응답 entry_skipped 에 사유를 표시한다.
|
||
"""
|
||
from app.services.daytrading.exit_service import check_and_exit_positions
|
||
from app.services.daytrading.entry_service import scan_and_enter
|
||
from app.services.daytrading.position_manager import dt_cycle_lock
|
||
|
||
if not is_market_open():
|
||
raise HTTPException(409, "장 운영 시간이 아님")
|
||
if dt_cycle_lock.locked():
|
||
raise HTTPException(409, "데이트레이딩 사이클 실행 중 — 잠시 후 재시도")
|
||
|
||
_es, _st = _engine()
|
||
_timeout = _manual_cycle_step_timeout()
|
||
async with dt_cycle_lock:
|
||
_st.reset_if_new_day()
|
||
try:
|
||
exits = await asyncio.wait_for(check_and_exit_positions(), timeout=_timeout)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"[DayTrade] 수동 사이클 청산 체크 {_timeout}초 초과 — 취소")
|
||
exits = []
|
||
entries: list = []
|
||
entry_skipped = None
|
||
# 진입 게이트는 청산 이후에, 사이클 시점의 설정값으로 판정한다(청산 판정은 막지 않음).
|
||
if not settings.DAY_TRADE_ENABLED:
|
||
entry_skipped = "DAY_TRADE_ENABLED=False"
|
||
elif is_day_trade_entry_window():
|
||
try:
|
||
entries = await asyncio.wait_for(scan_and_enter(), timeout=_timeout)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"[DayTrade] 수동 사이클 진입 스캔 {_timeout}초 초과 — 취소")
|
||
entries = []
|
||
else:
|
||
entry_skipped = "진입 시간창 밖 — 청산 체크만 수행"
|
||
return {
|
||
"exits": exits, "entries": entries, "positions": _st.position_count(),
|
||
"entry_skipped": entry_skipped,
|
||
}
|
||
|
||
|
||
@router.get("/status")
|
||
async def get_status():
|
||
"""데이트레이딩 시스템 상태 요약"""
|
||
_reset_if_new_day()
|
||
_es, _st = _engine()
|
||
_st.reset_if_new_day()
|
||
return {
|
||
"watchlist_count": len(_es.get_watchlist()),
|
||
"active_positions": _st.position_count(),
|
||
"orb_collected": len(_st.orb_cache),
|
||
"daily_pnl": round(_st.daily_pnl, 0),
|
||
# [2026-10-08] DT 엔진 카드 '청산' — DT 포지션 전량 청산 종목 수만 센다.
|
||
# closed_today 는 재진입 차단 집합이라 스윙 손절 종목(전략 간 당일 쿨다운)도 들어있다
|
||
# (10/08: DT 6 + 평균회귀 손절 2 = '청산 8'). 차단 집합 크기는 별도 키로 노출.
|
||
"closed_today": len(_st.dt_exited_today),
|
||
"reentry_blocked_today": len(_st.closed_today),
|
||
"cooldown_active": bool(_st.is_cooldown_active()),
|
||
"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(),
|
||
}
|