Files
auto-stock/backend/app/api/endpoints/day_trading.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

840 lines
37 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
엔드포인트 (전부 실제 엔진 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(),
}