fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -883,6 +883,117 @@ async def _handle_upsert_portfolio(session, data: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
# [2026-10-01] update_trade_history_by_order_id 의 식별·필터 전용 키 — setattr 대상이 아니다.
|
||||
# strategy_type/order_type 은 중복 행을 좁히는 데만 쓴다(좁히기에 실패해 다른 전략 행이
|
||||
# 선택됐을 때 그 행의 전략 구분을 덮어쓰지 않도록).
|
||||
_ORDER_ID_UPDATE_META_KEYS: frozenset = frozenset(
|
||||
{"order_id", "order_date", "strategy_type", "order_type"}
|
||||
)
|
||||
|
||||
|
||||
def _norm_kis_order_id(value) -> str:
|
||||
"""KIS 주문번호 정규화(앞자리 0 제거). 저장 형식은 응답 ODNO 원문(0 패딩)이다."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0")
|
||||
|
||||
|
||||
def _order_id_ref_date(data: dict) -> date:
|
||||
"""조회 기준일(KST). 우선순위: order_date(메타) > created_at > executed_at > 오늘.
|
||||
|
||||
executed_at 은 체결 확인 시각(주문 후 수 초)이라 주문일과 같다. DLQ 재처리로 다음 날
|
||||
실행돼도 payload 의 원래 날짜를 쓰므로 당일 필터가 행을 놓치지 않는다.
|
||||
"""
|
||||
for key in ("order_date", "created_at", "executed_at"):
|
||||
v = data.get(key)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return datetime.fromisoformat(v).date() if "T" in v or " " in v else date.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("⚠️ [Worker] order_id 갱신 기준일 파싱 실패 %s=%r — 다음 후보 사용", key, v)
|
||||
return now_kst().date()
|
||||
|
||||
|
||||
async def _handle_update_trade_history_by_order_id(session, data: dict) -> None:
|
||||
"""TradeHistory UPDATE by KIS 주문번호 — 정상 경로 단일 구현.
|
||||
|
||||
[2026-10-01] KIS ODNO 는 **일별 순번**이라 날짜가 바뀌면 같은 번호가 반복된다. 예전 구현
|
||||
(`order_id == X` + `scalar_one_or_none()`)은 과거 행과 겹치면 MultipleResultsFound →
|
||||
단일 워커가 1+2+4초 재시도로 큐 전체를 멈추고 DLQ 로 보냈고, 당일 INSERT 가 유실된
|
||||
상태면 **과거 행을 덮어써** 원장을 오염시켰다.
|
||||
- 기준일(KST 00:00~다음날 00:00) created_at 범위로 한정한다(_order_id_ref_date).
|
||||
- order_id 는 원문 일치 OR 앞자리 0 제거 일치.
|
||||
- 그래도 여러 행이면 payload 의 strategy_type·order_type 으로 좁히고 created_at desc
|
||||
첫 행만 갱신 + warning (예외 없음 → 큐 정체 없음).
|
||||
- 0건이면 warning 후 종료(재시도·DLQ 없음 — 기존 동작 유지).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
|
||||
order_id = data.get("order_id")
|
||||
if not order_id:
|
||||
return
|
||||
user_id = data.get("user_id")
|
||||
ref = _order_id_ref_date(data)
|
||||
start = datetime.combine(ref, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
raw = str(order_id).strip()
|
||||
norm = _norm_kis_order_id(raw)
|
||||
id_cond = TradeHistory.order_id == raw
|
||||
if norm:
|
||||
id_cond = or_(id_cond, func.ltrim(TradeHistory.order_id, "0") == norm)
|
||||
|
||||
query = select(TradeHistory).where(
|
||||
id_cond,
|
||||
TradeHistory.created_at >= start,
|
||||
TradeHistory.created_at < end,
|
||||
)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
query = query.order_by(TradeHistory.created_at.desc(), TradeHistory.id.desc())
|
||||
rows = list((await session.execute(query)).scalars().all())
|
||||
|
||||
if not rows:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s (기준일 %s KST) — 갱신 생략",
|
||||
order_id, ref.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
if len(rows) > 1:
|
||||
narrowed = rows
|
||||
for key in ("strategy_type", "order_type"):
|
||||
want = data.get(key)
|
||||
if want is None:
|
||||
continue
|
||||
cand = [r for r in narrowed if getattr(r, key, None) == want]
|
||||
if cand:
|
||||
narrowed = cand
|
||||
logger.warning(
|
||||
"⚠️ [Worker] order_id=%s 기준일 %s 에 TradeHistory %d건 중복 — "
|
||||
"strategy_type/order_type 으로 좁힌 %d건 중 최신 1건(id=%s)만 갱신",
|
||||
order_id, ref.isoformat(), len(rows), len(narrowed), narrowed[0].id,
|
||||
)
|
||||
rows = narrowed
|
||||
|
||||
trade = rows[0]
|
||||
for key, val in data.items():
|
||||
if key in _ORDER_ID_UPDATE_META_KEYS or val is None or not hasattr(trade, key):
|
||||
continue
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s (id=%s) status=%s",
|
||||
order_id, trade.id, data.get("status", "?"),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_upsert_active_trade(session, data: dict) -> None:
|
||||
"""ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import ActiveTrade
|
||||
@@ -1054,29 +1165,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
|
||||
elif task_type == "update_trade_history_by_order_id":
|
||||
order_id = data.get("order_id")
|
||||
user_id = data.get("user_id")
|
||||
if order_id:
|
||||
query = select(TradeHistory).where(TradeHistory.order_id == order_id)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
row = await session.execute(query)
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade:
|
||||
for key, val in data.items():
|
||||
if key != "order_id" and val is not None and hasattr(trade, key):
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s status=%s",
|
||||
order_id,
|
||||
data.get("status", "?"),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s",
|
||||
order_id,
|
||||
)
|
||||
await _handle_update_trade_history_by_order_id(session, data)
|
||||
|
||||
# ── 3. 단건 StockAnalysis UPSERT ──────────────────────────
|
||||
elif task_type == "upsert_stock_analysis":
|
||||
|
||||
Reference in New Issue
Block a user