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

@@ -164,7 +164,14 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
/* ── 매매일지 탭 ── */
journals && journals.length > 0 ? (
journals.map((journal: Journal) => {
const journalDate = new Date(journal.journal_date || journal.created_at || '')
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
// (매매내역 경로 :102-103과 동일 규약 — 오프셋이 이미 있으면 그대로 둠)
const rawJournalDt = journal.journal_date || journal.created_at || ''
const journalDate = new Date(
rawJournalDt.includes('+') || rawJournalDt.includes('Z')
? rawJournalDt
: rawJournalDt + '+09:00'
)
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'

View File

@@ -154,10 +154,19 @@ export default function Analysis() {
})
// 일지 생성 뮤테이션
// F-J2 (2026-09-30): onSuccess가 alert만 하고 ['journals'] 쿼리를 무효화하지 않아
// 생성 후에도 목록이 갱신되지 않았다. 또한 일지 목록은 이 페이지가 아니라
// 대시보드 우측 패널의 "매매일지" 탭(components/JournalPanel.tsx)에서만 보인다 —
// 기존 문구의 "매매 로그"는 실재하지 않는 위치를 가리켰다.
// 생성은 db_worker 큐를 경유하는 비동기 처리라 요청 직후엔 아직 안 보일 수 있어
// "생성 요청됨"과 "생성 완료"를 구분해 안내한다.
const generateJournals = useMutation({
mutationFn: () => api.post('/analysis/generate-journals?days=7'),
onSuccess: () => {
alert('일지 생성을 시작했습니다. 잠시 후 매매 로그에서 확인하세요.')
alert('일지 생성을 요청했습니다. 비동기로 처리되어 즉시 반영되지 않을 수 있습니다 — 잠시 후 대시보드 우측 "매매일지" 탭에서 확인하세요.')
queryClient.invalidateQueries({ queryKey: ['journals'] })
// db_worker 큐 처리 지연을 감안해 지연 재조회도 함께 트리거
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['journals'] }), 5_000)
},
})

View File

@@ -438,13 +438,29 @@ const Dashboard = () => {
refetchOnWindowFocus: true,
})
// 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신)
// 매매일지 — 폴링 기반 (WebSocket journal_created는 보조 트리거일 뿐, 아래 참고)
//
// F-J1 (2026-09-30): 이전엔 staleTime: Infinity + refetchOnWindowFocus: false +
// refetchInterval 없음이라, WebSocket 'journal_created' 없이는 페이지 로드 순간
// 목록에서 영원히 멈췄다. 그런데 그 브로드캐스트 자체가 신뢰할 수 없다 —
// 실측 205건 중 13건(6%)만 발화(16:00 스케줄러/‘/analysis/generate-journals’
// 경로는 브로드캐스트 자체가 없음, backend/app/api/endpoints/portfolio.py 확인—
// 읽기만 함, 백엔드는 journal-fix-be 담당).
// 그래서 이벤트에 의존하지 않는 폴링을 기본으로 하고, 이벤트는 보조 트리거로만 쓴다.
//
// 60초 간격 근거: 일지는 포트폴리오(30초, usePortfolioData.ts)·매매내역(30초,
// 위 tradeHistory)만큼 실시간성이 필요 없다(매매 직후 화면에 바로 안 보여도
// 치명적이지 않음, 사후 기록 성격) — 그 두 배인 60초로 두어 불필요한 API 부하를
// 늘리지 않는다. 탭이 백그라운드일 때 폴링을 줄이려 장이 닫혀 있으면(포트폴리오
// 폴링과 동일 기준 재사용하지 않고, 일지는 장외에도 16:00 배치 등으로 계속
// 생성되므로) 장 상태와 무관하게 항상 60초로 유지한다.
const { data: journals } = useQuery({
queryKey: ['journals'],
queryFn: async () =>
(await api.get('/journals', { params: { limit: 20 } })).data,
staleTime: Infinity,
refetchOnWindowFocus: false,
staleTime: 30000,
refetchInterval: 60000,
refetchOnWindowFocus: true,
})
// ─────────────────────────────────────────────────────────────
@@ -492,7 +508,14 @@ const Dashboard = () => {
}
if (msg.type === 'journal_created') {
// 브로드캐스트가 db_worker INSERT 커밋 전에 발화하는 레이스가 있다
// (backend/app/api/endpoints/portfolio.py 확인 — 큐 적재 직후 즉시
// 브로드캐스트, 커밋은 비동기 워커가 나중에 수행). 즉시 1회 + 지연
// 재조회 1회로 레이스에 대비 — 최종적으로는 60초 폴링이 항상 따라잡는다.
queryClient.invalidateQueries({ queryKey: ['journals'] })
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['journals'] })
}, 3000)
}
}
}, [wsMessages, invalidatePortfolio, queryClient])