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
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -22,6 +22,14 @@ from app.core.database import get_db
_KST = timezone(timedelta(hours=9))
def _fill_or_created_at_v2():
"""체결 시각 표현 — executed_at 이 NULL 인 DT·스윙 청산·MDD 행은 created_at 으로 대체.
trade_executor._fill_or_created_at_v2 와 동일 정의(무거운 import 회피를 위해 로컬 정의).
"""
return func.coalesce(TradeHistory.executed_at, TradeHistory.created_at)
def _today_kst() -> date:
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
return datetime.now(_KST).date()
@@ -38,13 +46,125 @@ MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.04 (4%) —
# 데이트레이딩은 설계상 종목당 6%(DAY_TRADE_MAX_CAPITAL_RATIO, 5종목×6%=30%)이므로
# 스윙 상한(4%)으로 검사하면 정상 DT 주문이 '위반'으로 오판정된다 → 전략별로 분기한다.
DT_POSITION_SIZE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단
# 한도는 settings 우선(config.py 에 키가 생기면 자동 반영), 없으면 기존 값 유지 — 값 변경 없음.
MAX_DAILY_LOSS_RATIO = float(getattr(settings, "RISK_MAX_DAILY_LOSS_RATIO", 0.03)) # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = int(getattr(settings, "RISK_MAX_CONSECUTIVE_LOSSES", 3)) # 3연속 손실 시 중단
_DT_STRATEGIES = ("daytrading", "day_trade", "dt")
COMMISSION_RATE = 0.00015 # 수수료 0.015%
TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만)
TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26%
def _resolve_account_balance(caller_balance: float):
"""호출자 잔고와 서버 잔고(mdd_manager 최근 갱신값, 없으면 peak 미사용)를 대조한다.
서버값이 있으면 min(호출자, 서버) — 큰 값 주입으로 한도를 희석하는 우회를 막는 보수적 선택.
서버값이 없으면 호출자 값을 쓰되 note 로 미검증을 표시한다.
"""
from app.services.mdd_manager import mdd_manager
try:
server = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
except (TypeError, ValueError):
server = 0.0
caller = float(caller_balance)
if server > 0:
if caller > server:
logger.warning(
f"[check-risk-limits] 호출자 잔고 {caller:,.0f} > 서버 잔고 {server:,.0f} — 서버값 사용"
)
return server, "server_balance_used"
return caller, "ok"
return caller, "server_balance_unverified"
async def _server_side_block_reason_v2(strategy: str, account_balance: float) -> Optional[str]:
"""TradingSession 행(기록 주체 없음)과 무관하게 서버 인메모리 상태로 신규 주문 차단 사유를 낸다.
- mdd_manager.can_open_new_position(): MDD -10% 정지 / 갱신 블라인드 fail-safe
- DT 전략: day_trading_state.daily_pnl 이 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT) 이하
조회 예외는 fail-closed(차단)로 처리한다.
"""
from app.services.mdd_manager import mdd_manager
try:
if not await mdd_manager.can_open_new_position():
return "MDD 정지 또는 MDD 갱신 불가(fail-safe) — 신규 진입 차단"
if (strategy or "").strip().lower() in ("daytrading", "day_trade", "dt"):
from app.services.daytrading.position_manager import day_trading_state
from app.services.day_trading_strategy import is_daily_loss_exceeded
pnl = float(day_trading_state.daily_pnl or 0.0)
assets = float(day_trading_state.last_total_assets or 0.0) or float(account_balance)
if is_daily_loss_exceeded(assets, pnl):
return (f"DT 일일 손실한도 도달 (pnl {pnl:,.0f}, "
f"한도 {float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%)")
except Exception as e:
logger.warning(f"[check-risk-limits] 서버 상태 조회 실패 — fail-closed: {e}")
return "서버 리스크 상태 조회 실패 — 신규 진입 차단(fail-closed)"
return None
_STREAK_EXCLUDED_STRATEGIES = ("manual", "mdd_reduction", "external")
async def _swing_realized_today_v2(db, stock_code: Optional[str] = None) -> tuple:
"""TradingSession 기록 주체가 없으므로 TradeHistory 에서 스윙 당일 실현손익·연속 손실을 직접 계산한다.
반환: (당일 순손익 합, 최근 연속 손실 횟수). SELECT 전용. 조회 실패 시 예외를 그대로 올린다
(호출부가 fail-closed 처리). DT 행은 제외한다.
연패는 §5.1 의미(동일 종목)에 맞춰 stock_code 가 주어질 때만 해당 종목으로 집계하고,
manual/mdd_reduction/external 청산은 제외한다. stock_code 가 없으면 0
(종목 무관 전역 연패는 §5.1 정의가 아니므로 loss_tracker 에 맡긴다).
"""
start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0)
res = await db.execute(
select(TradeHistory.net_profit_loss, TradeHistory.stock_code, TradeHistory.strategy_type)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.created_at >= start)
.where(TradeHistory.net_profit_loss.isnot(None))
.where(TradeHistory.strategy_type.notin_(["daytrading", "day_trade"]))
.order_by(desc(TradeHistory.created_at))
)
rows = res.all()
total = sum(float(r[0]) for r in rows)
streak = 0
if stock_code:
for pl, code, st in rows:
if code != stock_code or st in _STREAK_EXCLUDED_STRATEGIES:
continue
if float(pl) < 0:
streak += 1
else:
break
return total, streak
def _mdd_state_v2() -> dict:
"""실제 MDD 게이트(mdd_manager) 상태 스냅샷. 읽기 전용, DB 접근 없음.
drawdown_pct 는 (peak - 최근 관측 잔고) / peak * 100 (양수 = 하락폭).
peak 또는 최근 잔고를 모르면 None (0% '안전' 으로 오표시하지 않는다).
"""
from app.services.mdd_manager import mdd_manager
peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0)
last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
dd_pct = round((peak - last) / peak * 100, 2) if peak > 0 and last > 0 else None
return {
"peak_balance": peak if peak > 0 else None,
"last_balance": last if last > 0 else None,
"drawdown_pct": dd_pct,
"is_paused": bool(mdd_manager.is_paused),
"is_short_term_strategy_paused": bool(getattr(mdd_manager, "is_short_term_strategy_paused", False)),
"reduction_applied": mdd_manager.reduction_applied,
"update_health": mdd_manager.get_update_health(),
}
@router.get("/mdd-status")
async def get_mdd_status_v2():
"""실제 MDD 게이트(-10%/-20%) 상태. /risk-metrics 의 손익곡선과 정의가 다르다."""
return _mdd_state_v2()
@router.get("/risk-metrics")
async def get_risk_metrics(
days: int = Query(30, description="분석 기간 (일)"),
@@ -53,7 +173,7 @@ async def get_risk_metrics(
"""
📊 리스크 관리 메트릭 조회
- Maximum Drawdown (MDD): 최대 손실 깊이
- 실현손익 누적곡선의 최대 낙폭(원, 참고용). 실제 MDD 는 mdd_status / /mdd-status
- 누적 손익 곡선
- 현재 계좌 상태
"""
@@ -66,14 +186,15 @@ async def get_risk_metrics(
.where(TradeHistory.created_at >= since_date)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.order_by(TradeHistory.executed_at)
.order_by(_fill_or_created_at_v2(), TradeHistory.id)
)
trades = result.scalars().all()
if not trades:
return {
"max_drawdown": 0,
"max_drawdown_pct": 0,
"max_drawdown_pct": None,
"mdd_status": _mdd_state_v2(),
"current_equity": 0,
"peak_equity": 0,
"trades_count": 0,
@@ -112,24 +233,16 @@ async def get_risk_metrics(
if drawdown > max_drawdown:
max_drawdown = drawdown
max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0
# MDD 위험 레벨
risk_level = "안전"
if max_drawdown_pct > 30:
risk_level = "위험 (시스템 중지 권장)"
elif max_drawdown_pct > 20:
risk_level = "경고 (포지션 축소 권장)"
elif max_drawdown_pct > 10:
risk_level = "주의"
# 참고용 실현손익 곡선 — 낙폭 % 는 peak 가 손익 누적값이라 의미가 없어 판정하지 않는다.
return {
"max_drawdown": round(float(max_drawdown), 2),
"max_drawdown_pct": round(max_drawdown_pct, 2),
"max_drawdown_pct": None,
"current_equity": round(cumulative_pl, 2),
"peak_equity": round(peak, 2),
"trades_count": len(trades),
"risk_level": risk_level,
"risk_level": None,
"note": "실현손익 누적곡선(참고용). 리스크 수준 판단은 mdd_status 를 사용",
"mdd_status": _mdd_state_v2(),
"equity_curve": equity_curve[-50:], # 최근 50개만
"drawdown_curve": drawdown_curve[-50:]
}
@@ -161,6 +274,7 @@ async def get_sharpe_ratio(
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.profit_loss_rate.isnot(None))
.order_by(_fill_or_created_at_v2(), TradeHistory.id)
)
trades = result.scalars().all()
@@ -240,28 +354,33 @@ async def get_today_session(db: AsyncSession = Depends(get_db)):
)
session = result.scalar_one_or_none()
_mdd = _mdd_state_v2()
if not session:
# 오늘 세션이 없으면 생성
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
await db.commit()
await db.refresh(session)
# [하드닝 R4] GET 은 DB 를 쓰지 않는다(§3.2: 쓰기는 db_task_queue 경유, 무인증 GET 부하 방지).
# 세션 행을 쓰는 엔드포인트는 없다(R1 에서 /update-session 제거).
return {
"session_date": today.isoformat(),
"consecutive_losses": 0,
"consecutive_wins": 0,
"daily_profit_loss": 0,
"daily_trades_count": 0,
"is_paused": _mdd["is_paused"],
"pause_reason": "MDD 정지" if _mdd["is_paused"] else None,
"mdd_status": _mdd,
"starting_balance": None,
"ending_balance": None,
"peak_balance": None,
}
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0,
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused,
"pause_reason": session.pause_reason,
"is_paused": bool(session.is_paused) or _mdd["is_paused"],
"pause_reason": session.pause_reason or ("MDD 정지" if _mdd["is_paused"] else None),
"mdd_status": _mdd,
"starting_balance": float(session.starting_balance) if session.starting_balance else None,
"ending_balance": float(session.ending_balance) if session.ending_balance else None,
"peak_balance": float(session.peak_balance) if session.peak_balance else None
@@ -277,11 +396,15 @@ async def check_risk_limits(
order_value: float = Query(..., description="주문 금액"),
account_balance: float = Query(..., description="계좌 잔고"),
strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"),
stock_code: Optional[str] = Query(None, description="종목 단위 연패 집계용(없으면 연패 가드 생략)"),
db: AsyncSession = Depends(get_db)
):
"""
⚠️ 주문 전 리스크 제한 확인
[미사용 표기] 2026-10 기준 코드베이스(py/ts/mcp-server)에 호출자가 없다. 실제 진입 가드는
trade_executor/entry_service/buy_guards 에 있으며 이 엔드포인트 보강은 매매 안전에 기여하지 않는다.
- 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO)
- 일일 손실 한도
- 연속 손실 제한
@@ -289,6 +412,31 @@ async def check_risk_limits(
[2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변).
이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다.
"""
# [하드닝 R4] 호출자 제공 잔고 신뢰 제거 — 서버 잔고(mdd_manager)가 있으면 더 작은 쪽 사용.
# (가드는 try 밖) 큰 값을 넘겨 손실비율·포지션비율을 희석하는 우회를 막는다.
account_balance, _bal_note = _resolve_account_balance(account_balance)
# [하드닝 R1] 서버측 실상태 가드(try 밖): MDD 정지/블라인드, DT 일일 손실한도.
_block = await _server_side_block_reason_v2(strategy, account_balance)
if _block:
return {"allowed": False, "reason": _block, "risk_level": "BLOCKED"}
# [하드닝 R3] 스윙 일일손실·연패는 TradingSession(기록 주체 없음) 대신 TradeHistory 실계산으로 가드(try 밖, fail-closed).
_sw_pnl, _sw_streak = 0.0, 0
if (strategy or "swing").strip().lower() not in _DT_STRATEGIES:
try:
_sw_pnl, _sw_streak = await _swing_realized_today_v2(db, stock_code=stock_code)
except Exception as e:
logger.warning(f"[check-risk-limits] 스윙 당일 손익 조회 실패 — fail-closed: {e}")
return {"allowed": False, "reason": "당일 손익 조회 실패 — 신규 진입 차단(fail-closed)",
"risk_level": "BLOCKED"}
if _sw_pnl < 0 and account_balance > 0 and abs(_sw_pnl) / account_balance > MAX_DAILY_LOSS_RATIO:
_r = abs(_sw_pnl) / account_balance
return {"allowed": False,
"reason": f"일일 손실 한도 도달 ({_r*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)",
"risk_level": "CRITICAL", "daily_loss": _sw_pnl,
"daily_loss_ratio": round(_r * 100, 2)}
if _sw_streak >= MAX_CONSECUTIVE_LOSSES:
return {"allowed": False, "reason": f"연속 {_sw_streak}회 손실 발생",
"risk_level": "CRITICAL", "consecutive_losses": _sw_streak}
try:
# 1. 오늘 세션 확인
today = _today_kst()
@@ -299,18 +447,16 @@ async def check_risk_limits(
session = result.scalar_one_or_none()
if not session:
# [하드닝 R4] 비영속 기본값 — 호출자 제공 잔고를 하루 기준으로 영속하지 않고,
# 직접 INSERT/commit(동시 호출 시 session_date 중복 경합)도 하지 않는다.
session = TradingSession(
session_date=today,
starting_balance=Decimal(str(account_balance)),
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
await db.commit()
await db.refresh(session)
# 2. 거래 중지 상태 확인
if session.is_paused:
@@ -324,13 +470,12 @@ async def check_risk_limits(
daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0
if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO:
# 자동 중지
session.is_paused = True
session.pause_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
await db.commit()
# is_paused 영속은 하지 않는다(직접 commit 제거). 손익·연패는 세션에 남아 있어
# 매 호출마다 같은 판정이 재현된다.
_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
return {
"allowed": False,
"reason": session.pause_reason,
"reason": _reason,
"risk_level": "CRITICAL",
"daily_loss": float(session.daily_profit_loss),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2)
@@ -338,13 +483,9 @@ async def check_risk_limits(
# 4. 연속 손실 제한 확인
if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES:
session.is_paused = True
session.pause_reason = f"연속 {session.consecutive_losses}회 손실 발생"
await db.commit()
return {
"allowed": False,
"reason": session.pause_reason,
"reason": f"연속 {session.consecutive_losses}회 손실 발생",
"risk_level": "CRITICAL",
"consecutive_losses": session.consecutive_losses
}
@@ -380,7 +521,8 @@ async def check_risk_limits(
"warnings": warnings,
"position_ratio": round(position_ratio * 100, 2),
"daily_loss_ratio": round(daily_loss_ratio * 100, 2),
"consecutive_losses": session.consecutive_losses
"consecutive_losses": session.consecutive_losses,
"balance_note": _bal_note,
}
except Exception as e:
@@ -388,65 +530,9 @@ async def check_risk_limits(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/update-session", dependencies=[Depends(require_api_key)])
async def update_trading_session(
trade_result: str = Query(..., description="win 또는 loss"),
profit_loss: float = Query(..., description="거래 손익"),
db: AsyncSession = Depends(get_db)
):
"""
🔄 거래 결과에 따라 세션 업데이트
- 연속 손실/승리 카운터
- 일일 손익 누적
"""
try:
today = _today_kst()
result = await db.execute(
select(TradingSession)
.where(TradingSession.session_date == today)
)
session = result.scalar_one_or_none()
if not session:
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
db.add(session)
# 손익 누적
current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0
session.daily_profit_loss = Decimal(str(current_pl + profit_loss))
session.daily_trades_count += 1
# 연속 손실/승리 업데이트
if trade_result == "loss":
session.consecutive_losses += 1
session.consecutive_wins = 0
elif trade_result == "win":
session.consecutive_wins += 1
session.consecutive_losses = 0
await db.commit()
await db.refresh(session)
return {
"session_date": session.session_date.isoformat(),
"consecutive_losses": session.consecutive_losses,
"consecutive_wins": session.consecutive_wins,
"daily_profit_loss": float(session.daily_profit_loss),
"daily_trades_count": session.daily_trades_count,
"is_paused": session.is_paused
}
except Exception as e:
logger.error(f"Error updating session: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# [하드닝 R1] POST /update-session 제거: 직접 commit(§3.2 위반)·입력 무검증이었고 호출부가 0건이라
# 세션 행이 생기지 않아 check-risk-limits 의 일일손실/연패 가드가 항상 통과했다.
# 서버측 실상태(mdd_manager, DT daily_pnl)를 check-risk-limits 가 직접 읽는다.
@router.get("/win-rate")
@@ -533,7 +619,7 @@ async def reset_mdd():
await mdd_manager.reset()
logger.info("✅ MDD 수동 초기화 완료")
return {
"message": "MDD 초기화 완료 — 신규 진입 가능",
"message": "MDD 초기화 완료 — peak 를 현재 잔고로 재설정(이후 다시 -10% 하락 시 축소 재발동), 신규 진입 가능",
"before": before,
"after": {
"is_paused": mdd_manager.is_paused,