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:
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.background_tasks import spawn_tracked_v2
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
@@ -135,11 +136,44 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
|
||||
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
|
||||
|
||||
# ── 주문 (세마포어 + 분산락) ─────────────────────────────────────────
|
||||
from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio
|
||||
from app.services.trade_executor import (
|
||||
safe_place_order, _delayed_sync_portfolio, _is_order_outcome_unknown_v2,
|
||||
)
|
||||
order_result = await safe_place_order(
|
||||
stock_code=stock_code, order_type="BUY", quantity=quantity,
|
||||
price=request.price, is_domestic=True,
|
||||
)
|
||||
# [R3 2026-10-09] 전송 후 결과 불명(KIS_TRANSPORT_UNKNOWN·msg_cd 없음·응답 없음)은 실제
|
||||
# 접수·체결됐을 수 있다. 단순 실패로 처리하면 쿨다운 없이 재클릭 → Portfolio 미동기 상태에서
|
||||
# 4% 누적 상한이 기존 보유분을 못 봐 이중 매수. 쿨다운을 세우고 PENDING 으로 기록한다.
|
||||
if _is_order_outcome_unknown_v2(order_result):
|
||||
from app.services.db_worker import enqueue_trade_history as _eth
|
||||
_now = _kst_now()
|
||||
_last_done[stock_code] = time.monotonic()
|
||||
logger.error(
|
||||
f"[MANUAL_BUY] {stock_code} 주문 결과 불명(전송 후 응답 유실) — 쿨다운 {int(_DUP_COOLDOWN_SEC)}초, "
|
||||
f"PENDING 기록 후 잔고 동기화. 포트폴리오 확인 전 재시도 금지"
|
||||
)
|
||||
await _eth({
|
||||
"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": "수동 매수 결과 불명 (접수 여부 미확인 — 포트폴리오 확인 필요)",
|
||||
"strategy_type": "manual",
|
||||
"created_at": _now,
|
||||
})
|
||||
spawn_tracked_v2(_delayed_sync_portfolio(3))
|
||||
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}")
|
||||
@@ -154,23 +188,27 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
|
||||
# ── DB 기록 (db_worker 큐) ────────────────────────────────────────────
|
||||
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
|
||||
now = _kst_now()
|
||||
# [R3 2026-10-09] 접수(rt_cd 0)만으로 체결을 단정하지 않는다. 지정가는 미체결일 수 있어
|
||||
# PENDING·미체결 수량 0 으로 기록하고 Portfolio 선생성을 생략한다(체결 확인/sync 담당).
|
||||
is_limit = bool(request.price)
|
||||
await enqueue_trade_history({
|
||||
"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": float(order_price),
|
||||
"executed_quantity": quantity,
|
||||
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 주문 기준가를 체결가로 쓰지 않는다.
|
||||
"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": "수동 매수 (추천 패널)",
|
||||
"strategy_type": "manual",
|
||||
"created_at": now,
|
||||
"executed_at": now,
|
||||
"executed_at": None if is_limit else now,
|
||||
})
|
||||
if holding is None:
|
||||
if holding is None and not is_limit:
|
||||
await enqueue_portfolio_upsert({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
@@ -182,8 +220,26 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
|
||||
"currency_code": "KRW",
|
||||
"exchange_code": "KRX",
|
||||
})
|
||||
# [R7] 접수된 주문(ODNO)은 체결 확인 태스크로 PENDING→COMPLETED/CANCELLED 확정한다.
|
||||
# 없으면 체결된 지정가 수동 매수가 영원히 PENDING·0주로 남아 통계·저널에서 빠진다.
|
||||
# (미체결 잔량이 KIS 에 살아 있으면 PENDING 유지 — 취소는 하지 않는다.)
|
||||
if order_id:
|
||||
from app.services.trade_executor import _verify_and_sync_execution_v3
|
||||
spawn_tracked_v2(_verify_and_sync_execution_v3(
|
||||
order_id=order_id,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
ordered_quantity=quantity,
|
||||
order_price=float(order_price),
|
||||
order_type="BUY",
|
||||
is_domestic=True,
|
||||
exchange_code="KRX",
|
||||
db_trade_id=0,
|
||||
delay=3,
|
||||
sell_pnl_basis={"strategy_type": "manual"},
|
||||
))
|
||||
# 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화)
|
||||
asyncio.create_task(_delayed_sync_portfolio(3))
|
||||
spawn_tracked_v2(_delayed_sync_portfolio(3))
|
||||
|
||||
_last_done[stock_code] = time.monotonic()
|
||||
logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})")
|
||||
|
||||
Reference in New Issue
Block a user