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

@@ -6,6 +6,7 @@
- BacktestEngine을 사용한 그리드 서치로 최적 파라미터 탐색
- 평가 지표: Sharpe * 0.4 + win_rate * 0.3 + (1 - |MDD|/100) * 0.3
- Sharpe 10% 이상 개선 시 strategy_config 자동 적용
(단 `settings.STRATEGY_AUTO_APPLY_ENABLED` 기본 False — 계산·로그만 하고 DB 쓰기는 차단)
- StrategyOptimizationLog 테이블에 이력 저장
[사용법]
@@ -230,7 +231,27 @@ async def _get_representative_stocks(period_days: int = 90) -> List[str]:
# ─────────────────────────────────────────────────────────────────────────────
async def apply_params(params: Dict[str, Any], reason: str = "auto_optimizer") -> bool:
"""strategy_cfg에 파라미터를 일괄 적용."""
"""strategy_cfg에 파라미터를 일괄 적용.
[2026-09-29] `settings.STRATEGY_AUTO_APPLY_ENABLED` (기본 **False**) 가 꺼져 있으면
**아무것도 쓰지 않고 False 를 반환한다.** 이 함수가 DB `strategy_configs` 를 바꾸는
유일한 지점이고(§0 우선순위 2위), 호출 게이트가 "최근 7일 SELL ≥ 5건"이라 5거래
표본의 그리드 서치가 RSI 진입·손절·포지션 크기를 덮어쓸 수 있다 — CLAUDE.md
§8.1-5 / §8.4(파라미터당 100거래) / §8.5-1 과 정면 충돌한다.
가드는 **`try` 밖**에 둔다(§4). try 안에 두면 예외 시 가드가 조용히 통과한다.
호출부(`run_optimization`)에도 같은 가드가 있고 상세 로그를 남긴다 — 여기는
다른 호출부가 생겨도 뚫리지 않게 하는 최종 방어선이다.
"""
if not settings.STRATEGY_AUTO_APPLY_ENABLED:
logger.warning(
"🚫 전략 파라미터 자동 적용 차단 (STRATEGY_AUTO_APPLY_ENABLED=False) — "
"적용하려던 값: %s (사유: %s). 반영하려면 전략설정 UI 에서 수동으로 하거나 "
"CLAUDE.md §8.4 백테스트 요건 충족 후 플래그를 켤 것.",
params, reason,
)
return False
try:
for key, value in params.items():
await strategy_cfg.upsert(key, str(value), updated_by=reason)
@@ -408,7 +429,22 @@ async def run_optimization(
)
# 최적화된 파라미터만 적용 (prev와 다른 키만)
changed = {k: v for k, v in best_params.items() if str(v) != str(prev_params.get(k, ""))}
if changed:
# 가드는 try 밖 (§4) — 예외로 통과되면 실매매 파라미터가 바뀐다
if changed and not settings.STRATEGY_AUTO_APPLY_ENABLED:
_diff = ", ".join(
f"{k}: {prev_params.get(k, '(없음)')} → {v}" for k, v in sorted(changed.items())
)
logger.warning(
"🚫 [Optimizer] 자동 적용 차단 (STRATEGY_AUTO_APPLY_ENABLED=False). "
"적용될 뻔한 변경 %d건 — %s | IS Sharpe %.3f → %.3f, OOS Sharpe %.3f, "
"IS/OOS %.2f, 승률 %.1f%%, MDD %.1f%% | %s",
len(changed), _diff, cur_sharpe, best_sharpe, oos_sharpe, is_oos_ratio,
best_win_rate, best_mdd, apply_reason,
)
apply_reason = (
f"자동 적용 차단(STRATEGY_AUTO_APPLY_ENABLED=False) — 제안: {_diff} | 근거: {apply_reason}"
)
elif changed:
applied = await apply_params(changed, reason="auto_optimizer")
if applied:
logger.info(f"자동 적용 완료: {apply_reason}")