fix: 매매일지 표시·중복·스케줄러 중단 + 전략 파라미터 자동적용 차단

매매일지는 정상 생성되고 있었다(205건, 커버리지 100%, 16:00 자동 생성도 동작).
"작성이 안 된다"의 실체는 아래 표시 계층 문제였다.

## 표시
- Dashboard journals 쿼리가 staleTime:Infinity + refetchInterval 없음이라
  페이지 로드 순간의 목록에서 영구 고정됐다 → 30s/60s 폴링
- WS journal_created 는 205건 중 13건(6%)만 발화하고(16:00 스케줄러와
  /analysis/generate-journals 는 브로드캐스트 없음) 그마저 커밋 전에 나간다
  → 이벤트 의존 제거, 수신 시 즉시+3초 재invalidate 로 레이스 보강
- 수동 버튼이 목록을 갱신하지 않고 안내가 "매매 로그"(없는 위치)를 가리켰다
  → invalidateQueries + 실제 위치·비동기 지연 명시

## 시각 +9h
- journals 가 KST naive 값에 UTC 라벨을 붙여 화면이 +9h 밀렸다
  (실측 KST 15:48 → 화면 09-30 00:48, 즉 당일 16:00 일지가 "다음날 01:00")
- _utc_iso → _kst_iso, +09:00 명시. 세 응답 지점 통일(기존엔 목록/상세가 달랐다)
- /trades 는 raw naive + 프론트 보정으로 짝이 맞아 있어 건드리지 않았다
  (회귀 테스트가 trades.py 에 +09:00 유입을 차단)
- JournalPanel 에 조건부 +09:00 정규화 추가(매매내역 경로와 동일 패턴)

## now_kst 섀도잉 4곳
- from timeutil import now_kst 해놓고 함수 내 now_kst = _dt.now(_KST) 로 대입해
  같은 함수의 now_kst() 가 TypeError. post_market 에서 실장애였고 나머지 3곳은
  무증상 잠재 → 전부 _now 로 리네임 (AST 전수, backend+mcp-server 잔존 0)
- TypeError 를 은폐하던 logger.debug → warning(exc_info=True), except: pass 1곳도

## trade_id=0 중복
- db_worker 에 _handle_insert_trade_journal 신설 — order_id 로 TradeHistory.id
  해소, 해소 실패 시 None(0 아님). 정상경로·종료 drain 공용
- 커버 판정 trade_id > 0 (id 는 PK라 0 은 애초에 아무 행도 커버하지 않음)
- order_id 추출이 raw KIS 응답에 없는 top-level "odno" 였다 → output.ODNO 우선

## 전략 파라미터 자동 적용 차단 (STRATEGY_AUTO_APPLY_ENABLED=False)
섀도잉 수정이 post_market 의 자동 최적화를 부활시킨다 — run_optimization 이
strategy_cfg.upsert() 로 실매매 파라미터(RSI 진입·손절·포지션 크기)를 덮어쓴다.
게이트가 "최근 7일 SELL>=5건"이라 5거래 표본 그리드 서치가 alembic 0012 로
정합시킨 값을 조용히 바꿀 수 있다 — §8.1-5·§8.4·§8.5-1 과 정면 충돌.
- 계산·분석·로그는 유지하고 upsert 만 차단. 가드 2단, 둘 다 try 밖
- 차단 시 키별 현재값→제안값 + IS/OOS Sharpe 를 warning + apply_reason 에 기록
- 켜기 전 §8.4 백테스트 요건 충족 필요(config 주석에 명시)

## 부수 발견
strategy_config_service.upsert 가 __import__("datetime").now_kst() 로 항상
AttributeError → 전략 파라미터 저장이 UI·일괄·optimizer 전 경로에서 500이었다.
역설적으로 이것이 자동적용의 2차 방어선이었다 — 고쳤으므로 플래그가 필수다.
⚠️ 배포 후 UI 파라미터 저장이 처음으로 실제 동작한다(§0 정합 주의).

테스트 677 → 715 passed / 0 failed. 전략 임계값 정적 변경 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 17:37:48 +09:00
parent 7abbb3411c
commit b5e0d6a9bb
12 changed files with 949 additions and 77 deletions

View File

@@ -1494,6 +1494,12 @@ async def check_and_execute_exit_trades(db: AsyncSession):
# 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용
_out = order_result.get("output", {})
_raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price
# [2026-09-29 C1] `place_order()` 는 raw KIS 응답을 그대로 준다 —
# 주문번호는 **`output.ODNO`** 이고 top-level `"odno"` 는 없다.
# 이전 코드가 `order_result.get("odno")` 를 써서 TradeHistory.order_id
# 가 항상 None 이었다(이 함수는 deprecated·미배선이라 드러나지 않았다).
# 다른 6개 주문 경로·us_market_scheduler.py:359 와 같은 형태로 통일.
_order_id = _out.get("ODNO") or order_result.get("odno")
executed_price = Decimal(str(_raw_price))
total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_price) * sell_quantity
@@ -1511,7 +1517,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
"executed_quantity": sell_quantity,
"total_amount": total_amount,
"status": _trade_status,
"order_id": order_result.get("odno"),
"order_id": _order_id,
"message": f"자동 청산: {profit_loss_rate:.2f}%",
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
"created_at": _get_kst_now(),
@@ -1587,9 +1593,10 @@ async def check_and_execute_exit_trades(db: AsyncSession):
})
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
trade_id=0,
trade_id=None,
order_id=_order_id,
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",
@@ -1614,7 +1621,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
async def _create_journal_background(
trade_id: int,
trade_id: Optional[int],
stock_code: str,
stock_name: str,
order_type: str,
@@ -1623,8 +1630,16 @@ async def _create_journal_background(
profit_loss: float,
profit_loss_rate: float,
exit_reason: str,
order_id: Optional[str] = None,
):
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크)"""
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크).
[2026-09-29 B3] `trade_id` 는 주문 직후엔 알 수 없다(TradeHistory INSERT 가
db_worker 큐에 있어 PK 미확정). 이전에는 `0` 을 넣었는데, 그 값은 존재하지 않는
PK 라 `get_uncovered_trades()` 의 커버 판정을 통과하지 못하고 같은 매도에 16:00
배치가 두 번째 일지를 만들었다. 이제 `order_id` 를 함께 넘겨
`db_worker._handle_insert_trade_journal()` 이 INSERT 시점에 해소한다.
"""
try:
import httpx
from app.core.config import settings
@@ -1659,7 +1674,8 @@ async def _create_journal_background(
mood = result.get("mood")
tags = result.get("tags", [])
except Exception as mcp_err:
logger.debug(f"MCP 일지 생성 실패 ({stock_code}): {mcp_err}")
# CLAUDE.md §4: 예외 로그는 최소 warning (폴백이 있어도 원인은 남겨야 한다)
logger.warning(f"MCP 일지 생성 실패 → 규칙 기반 폴백 ({stock_code}): {mcp_err}")
# 폴백: 규칙 기반 간단 일지
if not content:
@@ -1677,7 +1693,9 @@ async def _create_journal_background(
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_journal_raw
await enqueue_trade_journal_raw({
"trade_id": trade_id,
"trade_id": trade_id or None,
# order_id 는 TradeJournal 컬럼이 아니다 — 워커가 trade_id 해소 후 제거한다.
"order_id": order_id,
"stock_code": stock_code,
"stock_name": stock_name,
"journal_date": now_kst(),
@@ -1872,9 +1890,10 @@ async def sell_stock(
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
trade_id=0,
trade_id=None,
order_id=order_result.get('output', {}).get('ODNO'),
stock_code=stock_code,
stock_name=stock_name,
order_type="SELL",