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

@@ -11,15 +11,43 @@ from app.core.security import require_api_key
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from typing import Optional
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import logging
# KST 고정 오프셋. pytz 의 `replace(tzinfo=KST)` 는 역사적 LMT(+08:28)를 물고 올 수
# 있어 쓰지 않는다. 한국은 1988 년 이후 DST 가 없으므로 고정 +09:00 으로 충분하다.
_KST_TZ = timezone(timedelta(hours=9))
def _utc_iso(dt: datetime | None) -> str | None:
"""UTC naive datetime → ISO 8601 with +00:00 (JS가 UTC로 올바르게 파싱)"""
def _kst_iso(dt: datetime | None) -> str | None:
"""KST naive datetime → **`+09:00` 오프셋을 명시한** ISO 8601 문자열.
[2026-09-29] 이전 구현은 UTC tzinfo 를 덧붙여 `+00:00` 라벨을 달았다. 그러나
`trade_journals.journal_date/created_at` 은 `timeutil.now_kst()`(KST naive) 로
저장되므로 UTC 라벨은 거짓이었고, 프론트가 `timeZone: 'Asia/Seoul'` 로 렌더하면서
**+9시간** 밀렸다(운영 실측: `2026-09-29T15:48:49.580747+00:00` → 화면 09-30 00:48).
**왜 raw naive(`/trades` 규약)가 아니라 오프셋 명시인가** — 프론트 소비 지점이
규약별로 짝이 맞춰져 있다:
- `/trades` 는 raw naive 를 주고 **프론트가 `+09:00` 을 덧붙여** 보정한다
(`JournalPanel.tsx:101-103`, `TradeHistory.tsx:11-16`).
- 일지 경로(`JournalPanel.tsx:167-169`)에는 **그 보정이 없다** — 백엔드 문자열을
`new Date()` 에 그대로 넘긴다.
따라서 일지를 raw naive 로 바꾸면 비-KST 브라우저에서 같은 증상이 형태만 바꿔
남는다. 오프셋을 명시하면 `new Date()` 가 어느 타임존에서도 정확히 파싱하고
프론트는 손댈 필요가 없다. `/trades` 의 프론트 보정은 `includes('+')` 조건부라
오프셋 문자열과 충돌하지 않는다(이중 보정 없음).
⚠️ **한계 — 2026-09-23(`ec2fa2b`) 이전 행에는 이 라벨이 거짓이다.** 그 시점 전
`TradeJournal.journal_date/created_at` 은 `default=datetime.utcnow`(UTC naive)
였으므로 `+09:00` 을 붙이면 **9시간 이르게** 표시된다(구 `+00:00` 구현에서는 그
행들만 맞았다). 두 규약이 한 테이블에 섞여 있어 행 단위 구분이 불가능하고,
영향은 `limit` 목록에 구 행이 올라올 때뿐이므로 보정 마이그레이션은 하지 않는다.
신규 행(대다수)이 맞는 쪽을 택한 것이다.
"""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc).isoformat()
return dt.replace(tzinfo=_KST_TZ).isoformat()
from app.core.database import get_db
from app.models.models import TradeJournal, TradeHistory
@@ -52,12 +80,12 @@ async def list_journals(
"trade_id": j.trade_id,
"stock_code": j.stock_code,
"stock_name": j.stock_name,
"journal_date": _utc_iso(j.journal_date) if j.journal_date else None,
"journal_date": _kst_iso(j.journal_date),
"content": j.content,
"strategy_name": j.strategy_name,
"mood": j.mood,
"tags": j.tags,
"created_at": _utc_iso(j.created_at),
"created_at": _kst_iso(j.created_at),
}
for j in journals
]
@@ -132,12 +160,12 @@ async def generate_journal_for_trade_id(
"trade_id": journal.trade_id,
"stock_code": journal.stock_code,
"stock_name": journal.stock_name,
"journal_date": _utc_iso(journal.journal_date),
"journal_date": _kst_iso(journal.journal_date),
"content": journal.content,
"strategy_name": journal.strategy_name,
"mood": journal.mood,
"tags": journal.tags,
"created_at": _utc_iso(journal.created_at),
"created_at": _kst_iso(journal.created_at),
}
@@ -153,10 +181,10 @@ async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)):
"trade_id": journal.trade_id,
"stock_code": journal.stock_code,
"stock_name": journal.stock_name,
"journal_date": journal.journal_date.isoformat() if journal.journal_date else None,
"journal_date": _kst_iso(journal.journal_date),
"content": journal.content,
"strategy_name": journal.strategy_name,
"mood": journal.mood,
"tags": journal.tags,
"created_at": journal.created_at.isoformat() if journal.created_at else None,
"created_at": _kst_iso(journal.created_at),
}

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",