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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user