Files
auto-stock/backend/app/api/endpoints/day_trading.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인
  config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%).
- DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신
  (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등)

## 백테스트를 실전 로직에 정합
- calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트
  `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향
- 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치
- us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명)
- backend/backtest_results/ 25건은 폐기 대상 (§8.4)

## 리스크 가드 복원
- 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로
- 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다)
- 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동
- MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용)
- 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정

## 죽은 코드 제거 (2,695줄)
- short_term_strategy, kosdaq_scanner, app/strategy/ 패키지
- app/trading/ + korea_investment_api_adapter (real_market_api 정본은
  korea_investment_api.py:1482, adapter 는 import 0건)

## 지표·사이징 단일화 (수치 무변경 증명)
- indicators_core.py: 3,274건 비교 최대 오차 0.000e+00
- sizing_core.py: 42,105건 비교 불일치 0
- 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교)

## 테스트 안전 (실 KIS 호출 차단)
- 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이
  close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로
  바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤
  참조 누수로 실제 잔고 조회를 타 141초를 소비했다.
- backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단.
  예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게),
  클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제
- 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed)

## 신규 (둘 다 플래그 OFF, 백테스트 미검증)
- range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선
- check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%)

## 문서
- CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약,
  §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거,
  §8.5 미해결 19항목
- 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:58:18 +09:00

1065 lines
42 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.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
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 _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가 빈 리스트([])로 오는 경우까지 방어 (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 _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 not _is_new_entry_allowed():
# 14:30(DAY_TRADE_ENTRY_CUTOFF) 이후 — 신규 진입 마감 (CLAUDE.md 7.3), 청산은 위에서 이미 처리됨
result["phase"] = "entry_cutoff"
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주도 살 수 없으면 스킵
# [항목10 2026-09-27] max(1, ...) 제거 — 예산 0/음수에서도 1주를
# 매수해 사이징 한도(일일 DT 예산·현금)를 무시하던 문제.
quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0
if quantity <= 0:
logger.info(
f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 "
f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)"
)
continue
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 엔드포인트 ────────────────────────────────────────────────────────────
# ── 실제 매매 엔진(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():
"""현재 진입 가능 신호 조회 (주문 미실행)"""
_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", dependencies=[Depends(require_api_key)])
async def execute_day_trade(req: ExecuteRequest):
"""특정 종목 데이트레이딩 신호 스캔 후 진입.
[2026-09-23] 실매수(dry_run=False)는 비활성화. 이 경로로 진입한 포지션은 실제 엔진의
`day_trading_state`에 등록되지 않아 손절·트레일링·EOD 청산을 전혀 받지 못한다.
신호 확인용 dry_run 만 허용한다.
"""
if not req.dry_run:
raise HTTPException(
status_code=410,
detail=(
"이 엔드포인트의 실매수는 비활성화되었습니다(엔진 미관리 포지션 방지). "
"자동 진입은 스케줄러가, 수동 매수는 /portfolio/buy 를 사용하세요."
),
)
_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).")
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, _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
)
# [항목10 2026-09-27] max(1, ...) 제거 — 예산이 0이어도 1주를 사던 문제.
quantity = int(position_size // signal.entry_price) if signal.entry_price > 0 else 0
if quantity <= 0:
logger.info(
f"⛔ 데이트레이딩 진입 스킵 [{code}]: 예산 부족 "
f"(예산 {position_size:,.0f}원 / 단가 {signal.entry_price:,.0f}원 → 0주)"
)
return {
"status": "no_budget",
"message": (
f"데이트레이딩 예산이 부족합니다 "
f"(가용 예산 {position_size:,.0f}원, 단가 {signal.entry_price:,.0f}원)."
),
}
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()
_, _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)
try:
current_price = await _get_current_price(code)
except Exception:
current_price = entry_price
pnl = (current_price - entry_price) * quantity
pnl_pct = ((current_price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
result.append({
"stock_code": code,
"stock_name": pos.get("stock_name", code),
"strategy": pos.get("strategy", ""),
"entry_price": entry_price,
"current_price": current_price,
"stop_loss": pos.get("stop_loss"),
"target_price": pos.get("target_price"),
"quantity": quantity,
"pnl": round(pnl, 0),
"pnl_pct": round(pnl_pct, 2),
"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초 뒤 같은 물량을 다시 매도한다(이중 매도). 엔진 경로만 사용한다.
"""
from app.services.daytrading.exit_service import force_close_all
_es, _st = _engine()
_st.reset_if_new_day()
if not _st.positions:
return {"status": "ok", "message": "활성 포지션 없음", "exits": [], "daily_pnl": round(_st.daily_pnl, 0)}
exits = await force_close_all(reason="수동 전량 청산 (API)")
return {
"status": "ok",
"message": f"데이트레이딩 포지션 {len(exits)}건 청산 완료",
"exits": exits,
"daily_pnl": round(_st.daily_pnl, 0),
}
@router.post("/cycle", dependencies=[Depends(require_api_key)])
async def manual_cycle():
"""1 사이클 수동 실행 — 실제 엔진의 청산·진입 로직 호출 (테스트/디버그용)."""
from app.services.daytrading.exit_service import check_and_exit_positions
from app.services.daytrading.entry_service import scan_and_enter
_es, _st = _engine()
_st.reset_if_new_day()
exits = await check_and_exit_positions()
entries = await scan_and_enter()
return {"exits": exits, "entries": entries, "positions": _st.position_count()}
@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),
"closed_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(),
}