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

@@ -48,7 +48,7 @@ from app.services.manual_sell_service import (
)
from app.services.tenant.context import UserContext
from app.services.tenant.registry import get_user_context, user_context_registry
from app.services.trade_executor import safe_place_order_v2
from app.services.trade_executor import _is_order_outcome_unknown_v2, safe_place_order_v2
from app.services.utils import _is_domestic
from app.api.endpoints.manual_buy import BuyRequest
@@ -376,6 +376,35 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
ctx, stock_code=stock_code, order_type="BUY", quantity=quantity,
price=request.price, is_domestic=True,
)
# [R7 2026-10-10] manual_buy R3 와 동일: 전송 후 결과 불명은 접수됐을 수 있다 — 쿨다운 + PENDING.
if _is_order_outcome_unknown_v2(order_result):
_last_done_v2[(ctx.user_id, stock_code)] = time.monotonic()
logger.error(
f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — "
f"쿨다운 {int(_DUP_COOLDOWN_SEC)}초, PENDING 기록. 포트폴리오 확인 전 재시도 금지"
)
_unk = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "BUY",
"order_price": float(request.price) if request.price else None,
"order_quantity": quantity,
"executed_price": None,
"executed_quantity": 0,
"total_amount": float(order_price * quantity),
"status": "PENDING",
"order_id": None,
"message": "수동 매수 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)",
"strategy_type": "manual",
"created_at": _kst_now(),
}
if not ctx.is_legacy:
_unk["user_id"] = ctx.user_id
await enqueue_trade_history(_unk)
raise HTTPException(
status_code=504,
detail="매수 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요",
)
if not order_result or order_result.get("rt_cd") != "0":
msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}")
@@ -385,6 +414,8 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
from app.services.system_buy_registry import note_system_buy
note_system_buy(stock_code, quantity)
# [R7] 접수(rt_cd 0)≠체결. 지정가는 PENDING·체결 0 으로 기록하고 Portfolio 선생성을 생략한다.
is_limit = bool(request.price)
now = _kst_now()
trade_history_payload = {
"stock_code": stock_code,
@@ -392,15 +423,17 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
"order_type": "BUY",
"order_price": float(request.price) if request.price else None,
"order_quantity": quantity,
"executed_price": float(order_price),
"executed_quantity": quantity,
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다.
# total_amount 는 확인 전 추정치.
"executed_price": None,
"executed_quantity": 0 if is_limit else quantity,
"total_amount": float(order_price * quantity),
"status": "COMPLETED",
"status": "PENDING" if is_limit else "COMPLETED",
"order_id": order_id,
"message": "수동 매수 (v2, 사용자 스코프)",
"strategy_type": "manual",
"created_at": now,
"executed_at": now,
"executed_at": None if is_limit else now,
}
# M3: legacy 컨텍스트는 user_id를 payload에 넣지 않는다 — 스윙/데이트레이딩 등
# 기존 레거시 쓰기 경로가 전부 user_id 없이 쓰므로(NULL), 정합을 맞춰야
@@ -408,7 +441,7 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
if not ctx.is_legacy:
trade_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(trade_history_payload)
if holding is None:
if holding is None and not is_limit:
portfolio_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
@@ -485,6 +518,32 @@ async def sell_stock_v2(
order_result = await safe_place_order_v2(
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
)
# [R7 2026-10-10] 결과 불명 SELL: 체결 여부를 모르므로 원장·보유 상태를 바꾸지 않고
# PENDING 만 남긴다(잘못 차감하면 재매도 과다/추적 상실). 잔고 sync 가 보정한다.
if _is_order_outcome_unknown_v2(order_result):
logger.error(f"[SELL v2] user_id={ctx.user_id} {stock_code} 주문 결과 불명 — PENDING 기록, 상태 미변경")
_unk = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": None,
"executed_quantity": 0,
"total_amount": float((price or float(holding.current_price or 0)) * quantity),
"status": "PENDING",
"order_id": None,
"message": "수동 매도 결과 불명 (v2, 접수 여부 미확인 — 포트폴리오 확인 필요)",
"strategy_type": "manual",
"created_at": _kst_now(),
}
if not ctx.is_legacy:
_unk["user_id"] = ctx.user_id
await enqueue_trade_history(_unk)
raise HTTPException(
status_code=504,
detail="매도 주문 결과 불명 — 접수·체결됐을 수 있습니다. 포트폴리오 확인 후 재시도하세요",
)
if not order_result or order_result.get("rt_cd") != "0":
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
# order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409)
@@ -495,28 +554,42 @@ async def sell_stock_v2(
profit_loss = (executed_price - float(average_price or 0)) * quantity
profit_loss_rate = ((executed_price / float(average_price)) - 1) * 100 if average_price else 0.0
# [R7] 접수≠체결: 지정가 매도는 PENDING·체결 0, 보유/ActiveTrade/연패 상태를 건드리지 않는다
# (미체결인데 차감하면 손절·청산 추적이 끊긴다). 체결 후 sync 가 반영한다.
is_limit_sell = bool(price)
sell_history_payload = {
"stock_code": stock_code,
"stock_name": stock_name,
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
"executed_quantity": quantity,
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다. 금액·손익은 확인 전 추정치.
"executed_price": None,
"executed_quantity": 0 if is_limit_sell else quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
"status": "PENDING" if is_limit_sell else "COMPLETED",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"message": f"수동 청산 (v2): 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"net_profit_loss": None if is_limit_sell else profit_loss,
"profit_loss_rate": None if is_limit_sell else profit_loss_rate,
"strategy_type": "manual",
"created_at": _kst_now(),
"executed_at": _kst_now(),
"executed_at": None if is_limit_sell else _kst_now(),
}
if not ctx.is_legacy:
sell_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(sell_history_payload)
if is_limit_sell:
logger.info(f"[SELL v2] user_id={ctx.user_id} {stock_code} 지정가 매도 접수 — 체결 확인 전 상태 미변경")
return {
"success": True,
"message": f"{stock_name} {quantity}주 지정가 매도 주문 접수 (체결 대기)",
"order_id": (order_result.get("output") or {}).get("ODNO"),
"profit_loss": 0.0,
"profit_loss_rate": 0.0,
"pending": True,
}
# [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영.
# 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서