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:
@@ -551,6 +551,60 @@ async def enqueue_trade_journal(trade, content: str) -> None:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_insert_trade_journal(session, data: dict) -> None:
|
||||
"""TradeJournal INSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.
|
||||
|
||||
[2026-09-29 B3] 주문 직후 백그라운드로 만드는 일지는 `TradeHistory.id` 를
|
||||
모르기 때문에 `trade_id=0` 을 넣고 있었다. `get_uncovered_trades()` 가
|
||||
`TradeJournal.trade_id` 로 커버 판정을 하므로 그 일지는 **아무 거래도 커버하지
|
||||
못하고**, 실제 TradeHistory 행이 영구 uncovered 로 남아 16:00 배치가 같은 매도에
|
||||
두 번째 일지를 만들었다(실측 중복 13건).
|
||||
|
||||
호출부는 `order_id`(KIS 주문번호)를 알고 있으므로, 여기(이미 DB 세션 안 —
|
||||
CLAUDE.md §3 위반 없음)에서 `order_id → TradeHistory.id` 로 해소한다.
|
||||
해소 실패 시 `trade_id` 는 **None** 으로 남는다(0 이 아니다 — 0 은 존재하지 않는
|
||||
PK 를 가리키는 거짓 값이다).
|
||||
"""
|
||||
from app.models.models import TradeJournal
|
||||
|
||||
payload = dict(data)
|
||||
# order_id 는 TradeJournal 컬럼이 아니다 — 해소용 입력이므로 반드시 분리한다.
|
||||
order_id = payload.pop("order_id", None)
|
||||
|
||||
if not payload.get("trade_id") and order_id:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(TradeHistory.id)
|
||||
.where(TradeHistory.order_id == str(order_id))
|
||||
.order_by(TradeHistory.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
resolved = result.scalar_one_or_none()
|
||||
if resolved:
|
||||
payload["trade_id"] = resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeJournal trade_id 미해소 (order_id=%s, %s) — "
|
||||
"uncovered 판정에서 제외됨",
|
||||
order_id, payload.get("stock_code", "?"),
|
||||
)
|
||||
except Exception as resolve_err:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] trade_id 해소 실패 (order_id=%s): %s", order_id, resolve_err
|
||||
)
|
||||
|
||||
# 0 은 과거 호출부의 플레이스홀더였다 — 거짓 커버 판정을 만들지 않도록 None 으로.
|
||||
if payload.get("trade_id") == 0:
|
||||
payload["trade_id"] = None
|
||||
|
||||
session.add(TradeJournal(**payload))
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] TradeJournal saved: %s (trade_id=%s)",
|
||||
payload.get("stock_code", "?"), payload.get("trade_id"),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_upsert_portfolio(session, data: dict) -> None:
|
||||
"""Portfolio UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import Portfolio
|
||||
@@ -910,14 +964,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
# ── 9. TradeJournal INSERT ────────────────────────────────────
|
||||
elif task_type == "insert_trade_journal":
|
||||
from app.models.models import TradeJournal
|
||||
|
||||
session.add(TradeJournal(**data))
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] TradeJournal saved: %s",
|
||||
data.get("stock_code", "?"),
|
||||
)
|
||||
await _handle_insert_trade_journal(session, data)
|
||||
|
||||
else:
|
||||
logger.warning("⚠️ [Worker] Unknown task type: %s", task_type)
|
||||
@@ -961,9 +1008,7 @@ async def db_writer_worker() -> None:
|
||||
elif drain_type == "close_active_trade":
|
||||
await _handle_close_active_trade(drain_session, drain_data)
|
||||
elif drain_type == "insert_trade_journal":
|
||||
from app.models.models import TradeJournal
|
||||
drain_session.add(TradeJournal(**drain_data))
|
||||
await drain_session.commit()
|
||||
await _handle_insert_trade_journal(drain_session, drain_data)
|
||||
elif drain_type in (
|
||||
"update_trade_history",
|
||||
"update_trade_history_by_order_id",
|
||||
|
||||
Reference in New Issue
Block a user