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.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func from sqlalchemy import select, desc, func
from typing import Optional from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
import logging 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: if dt is None:
return 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.core.database import get_db
from app.models.models import TradeJournal, TradeHistory from app.models.models import TradeJournal, TradeHistory
@@ -52,12 +80,12 @@ async def list_journals(
"trade_id": j.trade_id, "trade_id": j.trade_id,
"stock_code": j.stock_code, "stock_code": j.stock_code,
"stock_name": j.stock_name, "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, "content": j.content,
"strategy_name": j.strategy_name, "strategy_name": j.strategy_name,
"mood": j.mood, "mood": j.mood,
"tags": j.tags, "tags": j.tags,
"created_at": _utc_iso(j.created_at), "created_at": _kst_iso(j.created_at),
} }
for j in journals for j in journals
] ]
@@ -132,12 +160,12 @@ async def generate_journal_for_trade_id(
"trade_id": journal.trade_id, "trade_id": journal.trade_id,
"stock_code": journal.stock_code, "stock_code": journal.stock_code,
"stock_name": journal.stock_name, "stock_name": journal.stock_name,
"journal_date": _utc_iso(journal.journal_date), "journal_date": _kst_iso(journal.journal_date),
"content": journal.content, "content": journal.content,
"strategy_name": journal.strategy_name, "strategy_name": journal.strategy_name,
"mood": journal.mood, "mood": journal.mood,
"tags": journal.tags, "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, "trade_id": journal.trade_id,
"stock_code": journal.stock_code, "stock_code": journal.stock_code,
"stock_name": journal.stock_name, "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, "content": journal.content,
"strategy_name": journal.strategy_name, "strategy_name": journal.strategy_name,
"mood": journal.mood, "mood": journal.mood,
"tags": journal.tags, "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 사용 # 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용
_out = order_result.get("output", {}) _out = order_result.get("output", {})
_raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price _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)) executed_price = Decimal(str(_raw_price))
total_amount = Decimal(str(executed_price)) * sell_quantity total_amount = Decimal(str(executed_price)) * sell_quantity
profit_loss = (float(executed_price) - avg_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, "executed_quantity": sell_quantity,
"total_amount": total_amount, "total_amount": total_amount,
"status": _trade_status, "status": _trade_status,
"order_id": order_result.get("odno"), "order_id": _order_id,
"message": f"자동 청산: {profit_loss_rate:.2f}%", "message": f"자동 청산: {profit_loss_rate:.2f}%",
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원", "exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
"created_at": _get_kst_now(), "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}원") logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성 — trade_id 미확보이므로 0 전달 # 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background( asyncio.create_task(_create_journal_background(
trade_id=0, trade_id=None,
order_id=_order_id,
stock_code=stock_code, stock_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
order_type="SELL", order_type="SELL",
@@ -1614,7 +1621,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
async def _create_journal_background( async def _create_journal_background(
trade_id: int, trade_id: Optional[int],
stock_code: str, stock_code: str,
stock_name: str, stock_name: str,
order_type: str, order_type: str,
@@ -1623,8 +1630,16 @@ async def _create_journal_background(
profit_loss: float, profit_loss: float,
profit_loss_rate: float, profit_loss_rate: float,
exit_reason: str, 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: try:
import httpx import httpx
from app.core.config import settings from app.core.config import settings
@@ -1659,7 +1674,8 @@ async def _create_journal_background(
mood = result.get("mood") mood = result.get("mood")
tags = result.get("tags", []) tags = result.get("tags", [])
except Exception as mcp_err: 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: if not content:
@@ -1677,7 +1693,9 @@ async def _create_journal_background(
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유 # DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
from app.services.db_worker import enqueue_trade_journal_raw from app.services.db_worker import enqueue_trade_journal_raw
await 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_code": stock_code,
"stock_name": stock_name, "stock_name": stock_name,
"journal_date": now_kst(), "journal_date": now_kst(),
@@ -1872,9 +1890,10 @@ async def sell_stock(
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)") 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( 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_code=stock_code,
stock_name=stock_name, stock_name=stock_name,
order_type="SELL", order_type="SELL",

View File

@@ -317,6 +317,26 @@ class Settings(BaseSettings):
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# 박스권(SIDEWAYS) 레인지 매매 (CLAUDE.md §8.5 항목 9) # 박스권(SIDEWAYS) 레인지 매매 (CLAUDE.md §8.5 항목 9)
# ═════════════════════════════════════════════════════════════════════════════
# 전략 자동 최적화(strategy_optimizer)의 **DB 파라미터 자동 반영** 스위치.
#
# ⚠️ **기본 False 유지 — True 로 바꾸기 전에 CLAUDE.md §8.4 백테스트 요건 7개를
# 충족해야 한다.** False 여도 최적화 계산·IS/OOS 평가·`strategy_optimization_logs`
# 기록은 그대로 돌아간다. 막는 것은 `strategy_optimizer.apply_params()` →
# `strategy_cfg.upsert()` 뿐이다(= §0 우선순위 2위인 DB `strategy_configs` 쓰기).
#
# 왜 필요한가: 16:00 배치의 자동 적용 게이트는 "최근 7일 SELL ≥ 5건"이라
# **5거래 표본의 그리드 서치가 RSI 진입·손절·포지션 크기를 덮어쓴다.**
# §8.4 는 파라미터당 최소 100거래를 요구하고, §8.5-1 은 현행 산출물 전부를
# 폐기 상태로 둔다. 게다가 alembic 0012 로 방금 정합시킨 값(RSI 46 / 손절 -7%)이
# 조용히 바뀌어 §0 의 3중 소스가 다시 어긋난다.
# (이 경로는 `schedulers.py` 의 now_kst 섀도잉 TypeError 때문에 실행된 적이
# 없었고, 2026-09-29 섀도잉 수정으로 되살아났다.)
#
# 차단 시 무엇이 적용될 뻔했는지 `logger.warning` 으로 키·현재값·제안값·Sharpe 를
# 남긴다 — 적용은 운영자가 전략설정 UI 에서 수동으로 한다.
STRATEGY_AUTO_APPLY_ENABLED: bool = False
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# ⚠️ **백테스트 미검증 — True 로 바꾸지 말 것 (CLAUDE.md §8.1-4).** # ⚠️ **백테스트 미검증 — True 로 바꾸지 말 것 (CLAUDE.md §8.1-4).**
# 이 전략은 SIDEWAYS 국면 전용이며, market_analyzer 의 SIDEWAYS 전면 차단(§6.1)을 # 이 전략은 SIDEWAYS 국면 전용이며, market_analyzer 의 SIDEWAYS 전면 차단(§6.1)을

View File

@@ -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: async def _handle_upsert_portfolio(session, data: dict) -> None:
"""Portfolio UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" """Portfolio UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
from app.models.models import Portfolio from app.models.models import Portfolio
@@ -910,14 +964,7 @@ async def db_writer_worker() -> None:
# ── 9. TradeJournal INSERT ──────────────────────────────────── # ── 9. TradeJournal INSERT ────────────────────────────────────
elif task_type == "insert_trade_journal": elif task_type == "insert_trade_journal":
from app.models.models import TradeJournal await _handle_insert_trade_journal(session, data)
session.add(TradeJournal(**data))
await session.commit()
logger.debug(
"✅ [Worker] TradeJournal saved: %s",
data.get("stock_code", "?"),
)
else: else:
logger.warning("⚠️ [Worker] Unknown task type: %s", task_type) 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": elif drain_type == "close_active_trade":
await _handle_close_active_trade(drain_session, drain_data) await _handle_close_active_trade(drain_session, drain_data)
elif drain_type == "insert_trade_journal": elif drain_type == "insert_trade_journal":
from app.models.models import TradeJournal await _handle_insert_trade_journal(drain_session, drain_data)
drain_session.add(TradeJournal(**drain_data))
await drain_session.commit()
elif drain_type in ( elif drain_type in (
"update_trade_history", "update_trade_history",
"update_trade_history_by_order_id", "update_trade_history_by_order_id",

View File

@@ -21,6 +21,81 @@ from app.core.market_schedule import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_KST_TZ = pytz.timezone("Asia/Seoul")
# 16:00 배치의 "오늘 실행했는가" 마커. 인메모리 `_last_run` 만 쓰면 재기동마다
# 리셋돼 같은 날 두 번 돈다(2026-09-29 15:5x 배포가 정확히 이 상황이었다).
# DT 의 `daytrading:daily:{date}` 와 같은 패턴. TTL 48h — 날짜 비교를 별도로 하므로
# 값이 하루 넘게 남아도 무해하고, 무한정 남지 않게만 제한한다.
_POST_MARKET_LAST_RUN_KEY = "scheduler:post_market:last_run"
_POST_MARKET_LAST_RUN_TTL = 172_800
def _post_market_decide(now: _dt, last_run: "_date | None") -> tuple[str, float]:
"""16:00 배치 루프의 **순수** 결정 함수 — ("run"|"sleep", 초).
루프 본문에서 분리한 이유: "16:00 직후에 기동하면 당일을 건너뛰는가?" 를
테스트로 확정할 수 있어야 한다. 의미는 기존 인라인 로직과 동일하다.
- `last_run == today` → 익일 16:00 까지 대기 (최소 60초)
- 16:00 이전 → 16:00 까지 대기 (한 번에 최대 1800초 — 시계 점프 대비 재평가)
- 주말 → 1시간 대기 (`last_run` 을 남기지 않는다)
- 그 외(= 평일 16:00 이후이고 아직 미실행) → **즉시 실행**.
`hour >= 16` 이면 `tomorrow_16` 분기로 새지 않으므로 **건너뜀 경로는 없다.**
"""
today = now.date()
if last_run == today:
tomorrow_16 = _KST_TZ.localize(
_dt(today.year, today.month, today.day, 16, 0, 0)
) + _td(days=1)
return "sleep", max((tomorrow_16 - now).total_seconds(), 60)
if (now.hour, now.minute) < (16, 0):
target = _KST_TZ.localize(_dt(today.year, today.month, today.day, 16, 0, 0))
sleep_sec = (target - now).total_seconds()
if sleep_sec > 0:
return "sleep", min(sleep_sec, 1800)
if today.weekday() >= 5:
return "sleep", 3600.0
return "run", 0.0
async def _load_post_market_last_run() -> "_date | None":
"""Redis 에서 마지막 16:00 배치 실행일 복원. 실패·부재 시 None(= 인메모리 동작)."""
try:
from app.core.redis import get_json
data = await get_json(_POST_MARKET_LAST_RUN_KEY)
if not data:
return None
return _date.fromisoformat(str(data.get("date") or ""))
except Exception as e:
logger.warning(
f"[PostMarket] 마지막 실행일 복원 실패 — 재기동 당일 중복 실행 가능: {e}"
)
return None
async def _save_post_market_last_run(day: _date) -> None:
"""16:00 배치 실행일을 Redis 에 기록(재기동 후 중복 실행 방지)."""
try:
from app.core.redis import setex_json
ok = await setex_json(
_POST_MARKET_LAST_RUN_KEY, _POST_MARKET_LAST_RUN_TTL, {"date": day.isoformat()}
)
if not ok:
logger.warning(
"[PostMarket] ⚠️ 마지막 실행일 저장 실패(Redis) — 재기동 시 같은 날 "
"배치가 다시 돈다(일지는 uncovered 판정으로 멱등, 텔레그램 리포트는 중복)"
)
except Exception as e:
logger.warning(f"[PostMarket] ⚠️ 마지막 실행일 저장 실패: {e}")
def _normalize_recommendations(rec: dict) -> dict: def _normalize_recommendations(rec: dict) -> dict:
"""서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화. """서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화.
@@ -91,16 +166,16 @@ async def batch_analysis_scheduler():
logger.info(f"📊 Batch analysis scheduler started (every {interval}s)") logger.info(f"📊 Batch analysis scheduler started (every {interval}s)")
try: try:
while True: while True:
now_kst = pytz.datetime.datetime.now(_KST) _now = pytz.datetime.datetime.now(_KST)
# 15:30 이후 → 장 종료, 익일 08:50까지 대기 # 15:30 이후 → 장 종료, 익일 08:50까지 대기
if now_kst.hour > 15 or (now_kst.hour == 15 and now_kst.minute >= 30): if _now.hour > 15 or (_now.hour == 15 and _now.minute >= 30):
tomorrow_start = ( tomorrow_start = (
_KST.localize( _KST.localize(
pytz.datetime.datetime(now_kst.year, now_kst.month, now_kst.day, 8, 50, 0) pytz.datetime.datetime(_now.year, _now.month, _now.day, 8, 50, 0)
) + _td(days=1) ) + _td(days=1)
) )
sleep_sec = max((tomorrow_start - now_kst).total_seconds(), 3600) sleep_sec = max((tomorrow_start - _now).total_seconds(), 3600)
logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개") logger.info(f"⏸️ [Batch] 장 종료 (15:30 이후). {sleep_sec / 3600:.1f}시간 후 재개")
await asyncio.sleep(sleep_sec) await asyncio.sleep(sleep_sec)
continue continue
@@ -179,37 +254,35 @@ async def post_market_analyzer_scheduler():
- 매일 16:00 KST 에 1회 실행 (이미 오늘 실행했으면 스킵) - 매일 16:00 KST 에 1회 실행 (이미 오늘 실행했으면 스킵)
- 최근 7일 내 SELL FILLED 거래 >= 5건이면 최적화 실행 - 최근 7일 내 SELL FILLED 거래 >= 5건이면 최적화 실행
- 마지막 최적화로부터 3일 이상 경과 시만 최적화 실행 - 마지막 최적화로부터 3일 이상 경과 시만 최적화 실행
실행 여부 판정은 순수 함수 `_post_market_decide()` 에 있다(테스트 대상).
"오늘 실행했는가" 마커는 Redis 에 영속화한다 — 인메모리만 쓰면 재기동마다
리셋돼 같은 날 두 번 돌고, 텔레그램 일일 리포트가 중복 발송된다.
""" """
_KST = pytz.timezone("Asia/Seoul") _KST = pytz.timezone("Asia/Seoul")
_last_run: _date | None = None
await asyncio.sleep(60) # 부팅 안정화 await asyncio.sleep(60) # 부팅 안정화
logger.info("📝 Post-market analyzer scheduler started (daily 16:00 KST)")
# 재기동 내구성: 인메모리 변수만 쓰면 리셋돼 같은 날 배치가 두 번 돈다.
_last_run: _date | None = await _load_post_market_last_run()
logger.info(
"📝 Post-market analyzer scheduler started (daily 16:00 KST, last_run=%s)",
_last_run,
)
try: try:
while True: while True:
now_kst = _dt.now(_KST) _now = _dt.now(_KST)
today = now_kst.date() today = _now.date()
if _last_run == today: action, sleep_sec = _post_market_decide(_now, _last_run)
tomorrow_16 = _KST.localize(_dt(today.year, today.month, today.day, 16, 0, 0)) + _td(days=1) if action == "sleep":
sleep_sec = (tomorrow_16 - now_kst).total_seconds() await asyncio.sleep(sleep_sec)
await asyncio.sleep(max(sleep_sec, 60))
continue
if (now_kst.hour, now_kst.minute) < (16, 0):
target = _KST.localize(_dt(today.year, today.month, today.day, 16, 0, 0))
sleep_sec = (target - now_kst).total_seconds()
if sleep_sec > 0:
await asyncio.sleep(min(sleep_sec, 1800))
continue
if today.weekday() >= 5:
await asyncio.sleep(3600)
continue continue
logger.info("📝 [PostMarket] 매매일지 자동 생성 + 전략 최적화 시작") logger.info("📝 [PostMarket] 매매일지 자동 생성 + 전략 최적화 시작")
_last_run = today _last_run = today
await _save_post_market_last_run(today)
try: try:
from app.services.trade_analyzer import ( from app.services.trade_analyzer import (
@@ -268,8 +341,8 @@ async def post_market_analyzer_scheduler():
if _summary: if _summary:
_pf_value = float(_summary.get("total_assets", 0)) _pf_value = float(_summary.get("total_assets", 0))
_cash = float(_summary.get("available_cash", 0)) _cash = float(_summary.get("available_cash", 0))
except Exception: except Exception as _pf_err:
pass logger.warning(f"[PostMarket] 포트폴리오 요약 조회 실패: {_pf_err}")
await telegram_notifier.notify_daily_report( await telegram_notifier.notify_daily_report(
date_str=str(today), date_str=str(today),
total_pnl=_total_pnl, total_pnl=_total_pnl,
@@ -280,7 +353,12 @@ async def post_market_analyzer_scheduler():
cash_balance=_cash, cash_balance=_cash,
) )
except Exception as _tg_err: except Exception as _tg_err:
logger.debug(f"[PostMarket] Telegram 리포트 전송 실패: {_tg_err}") # [2026-09-29] logger.debug 였다. now_kst 섀도잉으로 인한
# TypeError 가 여기서 조용히 삼켜져 일일 리포트가 무기한
# 미발송 상태였다 — CLAUDE.md §4: 예외 로그는 최소 warning.
logger.warning(
f"[PostMarket] Telegram 리포트 전송 실패: {_tg_err}", exc_info=True
)
since_7d = now_kst() - _td(days=7) since_7d = now_kst() - _td(days=7)
trade_count_result = await db.execute( trade_count_result = await db.execute(
@@ -328,8 +406,8 @@ async def krx_data_collector_scheduler():
try: try:
while True: while True:
now_kst = _dt.now(_KST) _now = _dt.now(_KST)
today = now_kst.date() today = _now.date()
# 오늘 이미 수집했으면 자정까지 대기 # 오늘 이미 수집했으면 자정까지 대기
if _last_collected == today: if _last_collected == today:
@@ -337,19 +415,19 @@ async def krx_data_collector_scheduler():
tomorrow_16 = _KST.localize( tomorrow_16 = _KST.localize(
_dt(today.year, today.month, today.day, 16, 0, 0) _dt(today.year, today.month, today.day, 16, 0, 0)
) + _td(days=1) ) + _td(days=1)
sleep_sec = (tomorrow_16 - now_kst).total_seconds() sleep_sec = (tomorrow_16 - _now).total_seconds()
logger.info(f"📅 KRX 오늘 수집 완료. 다음 수집까지 {sleep_sec/3600:.1f}시간 대기") logger.info(f"📅 KRX 오늘 수집 완료. 다음 수집까지 {sleep_sec/3600:.1f}시간 대기")
await asyncio.sleep(max(sleep_sec, 60)) await asyncio.sleep(max(sleep_sec, 60))
continue continue
# 15:40 이전이면 대기 # 15:40 이전이면 대기
collect_hour, collect_min = 15, 40 collect_hour, collect_min = 15, 40
if (now_kst.hour, now_kst.minute) < (collect_hour, collect_min): if (_now.hour, _now.minute) < (collect_hour, collect_min):
# 오늘 15:40까지 남은 초 # 오늘 15:40까지 남은 초
target = _KST.localize( target = _KST.localize(
_dt(today.year, today.month, today.day, collect_hour, collect_min, 0) _dt(today.year, today.month, today.day, collect_hour, collect_min, 0)
) )
sleep_sec = (target - now_kst).total_seconds() sleep_sec = (target - _now).total_seconds()
if sleep_sec > 0: if sleep_sec > 0:
logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음") logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음")
await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인 await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인
@@ -654,7 +732,7 @@ async def news_collect_scheduler():
f"score={score:.3f}" f"score={score:.3f}"
) )
except Exception as e: except Exception as e:
logger.debug( logger.warning(
f"[NEWS_SCHEDULER] {code} 뉴스 수집 오류: {e}" f"[NEWS_SCHEDULER] {code} 뉴스 수집 오류: {e}"
) )
await asyncio.sleep(1) # 크롤링 간격 (서버 부하 방지) await asyncio.sleep(1) # 크롤링 간격 (서버 부하 방지)
@@ -694,15 +772,15 @@ async def pre_market_us_scheduler():
try: try:
while True: while True:
now_kst = _dt.now(_KST) _now = _dt.now(_KST)
today = now_kst.date() today = _now.date()
# 오늘 이미 실행했으면 내일 07:00까지 대기 # 오늘 이미 실행했으면 내일 07:00까지 대기
if _last_run == today: if _last_run == today:
tomorrow_07 = _KST.localize( tomorrow_07 = _KST.localize(
_dt(today.year, today.month, today.day, 7, 0, 0) _dt(today.year, today.month, today.day, 7, 0, 0)
) + _td(days=1) ) + _td(days=1)
sleep_sec = max((tomorrow_07 - now_kst).total_seconds(), 3600) sleep_sec = max((tomorrow_07 - _now).total_seconds(), 3600)
await asyncio.sleep(sleep_sec) await asyncio.sleep(sleep_sec)
continue continue
@@ -712,17 +790,17 @@ async def pre_market_us_scheduler():
continue continue
# 07:00 이전이면 대기 # 07:00 이전이면 대기
if now_kst.hour < 7: if _now.hour < 7:
target_07 = _KST.localize( target_07 = _KST.localize(
_dt(today.year, today.month, today.day, 7, 0, 0) _dt(today.year, today.month, today.day, 7, 0, 0)
) )
sleep_sec = (target_07 - now_kst).total_seconds() sleep_sec = (target_07 - _now).total_seconds()
if sleep_sec > 0: if sleep_sec > 0:
await asyncio.sleep(min(sleep_sec, 1800)) await asyncio.sleep(min(sleep_sec, 1800))
continue continue
# 09:00 이후면 오늘은 스킵 (이미 장 시작) # 09:00 이후면 오늘은 스킵 (이미 장 시작)
if now_kst.hour >= 9: if _now.hour >= 9:
_last_run = today _last_run = today
await asyncio.sleep(3600) await asyncio.sleep(3600)
continue continue

View File

@@ -231,7 +231,12 @@ class StrategyConfigService:
cfg.value = value cfg.value = value
cfg.updated_by = updated_by cfg.updated_by = updated_by
cfg.updated_at = __import__("datetime").now_kst() # [2026-09-29] `__import__("datetime").now_kst()` 였다 —
# `datetime` 모듈에 `now_kst` 가 없어 **AttributeError** 로 항상 터졌고,
# 아래 `except Exception: raise` 가 그대로 올려서 전략 파라미터 저장이
# 전 경로(UI `PUT /strategy-config/{key}`, 일괄 저장, optimizer)에서
# 500 이었다. 모듈 최상단(:32)에 이미 `now_kst` 가 import 되어 있다.
cfg.updated_at = now_kst()
await session.commit() await session.commit()
await session.refresh(cfg) await session.refresh(cfg)

View File

@@ -6,6 +6,7 @@
- BacktestEngine을 사용한 그리드 서치로 최적 파라미터 탐색 - BacktestEngine을 사용한 그리드 서치로 최적 파라미터 탐색
- 평가 지표: Sharpe * 0.4 + win_rate * 0.3 + (1 - |MDD|/100) * 0.3 - 평가 지표: Sharpe * 0.4 + win_rate * 0.3 + (1 - |MDD|/100) * 0.3
- Sharpe 10% 이상 개선 시 strategy_config 자동 적용 - Sharpe 10% 이상 개선 시 strategy_config 자동 적용
(단 `settings.STRATEGY_AUTO_APPLY_ENABLED` 기본 False — 계산·로그만 하고 DB 쓰기는 차단)
- StrategyOptimizationLog 테이블에 이력 저장 - 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: 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: try:
for key, value in params.items(): for key, value in params.items():
await strategy_cfg.upsert(key, str(value), updated_by=reason) await strategy_cfg.upsert(key, str(value), updated_by=reason)
@@ -408,7 +429,22 @@ async def run_optimization(
) )
# 최적화된 파라미터만 적용 (prev와 다른 키만) # 최적화된 파라미터만 적용 (prev와 다른 키만)
changed = {k: v for k, v in best_params.items() if str(v) != str(prev_params.get(k, ""))} 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") applied = await apply_params(changed, reason="auto_optimizer")
if applied: if applied:
logger.info(f"자동 적용 완료: {apply_reason}") logger.info(f"자동 적용 완료: {apply_reason}")

View File

@@ -693,7 +693,10 @@ async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHis
since = now_kst() - timedelta(days=days) since = now_kst() - timedelta(days=days)
# 이미 일지가 있는 trade_id 목록 # 이미 일지가 있는 trade_id 목록
covered_stmt = select(TradeJournal.trade_id).where(TradeJournal.trade_id.is_not(None)) # [2026-09-29 B3] `> 0` 조건이 필수다. 과거 호출부가 trade_id 미확보 시 `0` 을
# 넣어서, 0 이 covered 집합에 섞이면 존재하지 않는 PK 를 커버로 세게 된다
# (역으로 실제 거래는 계속 uncovered → 중복 일지). NULL/0 은 둘 다 "커버 안 함".
covered_stmt = select(TradeJournal.trade_id).where(TradeJournal.trade_id > 0)
covered_result = await db.execute(covered_stmt) covered_result = await db.execute(covered_stmt)
covered_ids = {row[0] for row in covered_result.fetchall()} covered_ids = {row[0] for row in covered_result.fetchall()}

View File

@@ -0,0 +1,599 @@
"""매매일지 3대 결함 회귀 테스트 (2026-09-29).
B1 일지 시각이 +9시간 밀렸다 — `journal_date/created_at` 은 `now_kst()`(KST naive)
로 저장되는데 `journals._utc_iso()` 가 `+00:00` 라벨을 붙였고, 프론트가
`timeZone: 'Asia/Seoul'` 로 렌더하면서 9시간 밀렸다(16:00 일지 → 다음날 01:00).
게다가 `GET /` 와 `GET /{id}` 가 서로 다른 직렬화를 써서 값이 달랐다.
B2 `post_market_analyzer_scheduler()` 안의 `now_kst = _dt.now(_KST)` 대입이 모듈
전역 함수 `now_kst` 를 함수 전체 범위에서 지역변수로 만들어, 뒤쪽의 `now_kst()`
호출이 `TypeError: 'datetime.datetime' object is not callable` 로 죽었다.
예외는 `logger.debug` 로 삼켜져 텔레그램 일일 리포트·전략 자동 최적화가
조용히 멈춰 있었다.
B3 `trade_id=0` 플레이스홀더 일지가 아무 거래도 커버하지 못해, 실제 TradeHistory
행이 영구 uncovered 로 남고 16:00 배치가 두 번째 일지를 만들었다(실측 13건).
"""
import ast
import inspect
import pathlib
from datetime import datetime, timedelta, timezone
import pytest
KST_TZ = timezone(timedelta(hours=9))
APP_ROOT = pathlib.Path(__file__).resolve().parents[1] / "app"
# ─────────────────────────────────────────────────────────────────────────────
# 공용 fake 세션
# ─────────────────────────────────────────────────────────────────────────────
class _FakeScalars:
def __init__(self, items):
self._items = items
def all(self):
return list(self._items)
class _FakeResult:
def __init__(self, items):
self._items = list(items)
def scalars(self):
return _FakeScalars(self._items)
def scalar_one_or_none(self):
return self._items[0] if self._items else None
def fetchall(self):
return list(self._items)
class _FakeDB:
"""execute() 를 호출 순서대로 미리 지정한 결과로 응답하고 statement 를 기록."""
def __init__(self, results):
self._results = list(results)
self.statements = []
self.added = []
self.commits = 0
async def execute(self, stmt, *a, **k):
self.statements.append(stmt)
if self._results:
return _FakeResult(self._results.pop(0))
return _FakeResult([])
def add(self, obj):
self.added.append(obj)
async def commit(self):
self.commits += 1
# ─────────────────────────────────────────────────────────────────────────────
# B1 — 직렬화 규약
# ─────────────────────────────────────────────────────────────────────────────
def test_kst_iso_labels_plus_nine_not_utc():
"""KST naive 값에 `+09:00` 을 붙인다 — `+00:00` 이면 화면이 +9시간 밀린다."""
from app.api.endpoints.journals import _kst_iso
dt = datetime(2026, 9, 29, 15, 48, 49, 580747)
out = _kst_iso(dt)
assert out == "2026-09-29T15:48:49.580747+09:00"
assert out.endswith("+09:00")
assert "+00:00" not in out and not out.endswith("Z")
assert _kst_iso(None) is None
def test_journals_module_has_no_utc_relabeling():
"""`tzinfo=timezone.utc` 재라벨링이 되살아나면 다시 +9시간 밀린다."""
src = (APP_ROOT / "api" / "endpoints" / "journals.py").read_text()
assert "tzinfo=timezone.utc" not in src
# 주석/docstring 이 아닌 실제 호출·정의가 남아 있으면 안 된다
code = "\n".join(l.split("#", 1)[0] for l in src.splitlines())
assert "_utc_iso(" not in code
@pytest.mark.asyncio
async def test_both_journal_routes_return_identical_timestamps():
"""`GET /` 와 `GET /{id}` 가 같은 일지에 대해 같은 문자열을 낸다."""
from app.api.endpoints.journals import list_journals, get_journal
from app.models.models import TradeJournal
stamp = datetime(2026, 9, 29, 16, 0, 12)
journal = TradeJournal(
id=205, trade_id=334, stock_code="009830", stock_name="한화솔루션",
journal_date=stamp, created_at=stamp, content="x",
strategy_name="AUTO_TRADE", mood="좋음", tags=None,
)
listed = await list_journals(limit=20, offset=0, stock_code=None, db=_FakeDB([[journal]]))
detail = await get_journal(205, db=_FakeDB([[journal]]))
assert listed, "목록 라우트가 예외를 삼키고 빈 리스트를 반환했다"
assert listed[0]["journal_date"] == detail["journal_date"]
assert listed[0]["created_at"] == detail["created_at"]
# 프론트(`new Date(...)` + `toLocaleTimeString(timeZone:'Asia/Seoul')`)와 동일한
# 해석: 문자열을 파싱해 KST 로 환산한 벽시계가 저장값과 같아야 한다.
for key in ("journal_date", "created_at"):
parsed = datetime.fromisoformat(detail[key])
assert parsed.tzinfo is not None, f"{key} 에 오프셋이 없다"
assert parsed.utcoffset() == timedelta(hours=9)
assert parsed.astimezone(KST_TZ).replace(tzinfo=None) == stamp
assert parsed.astimezone(KST_TZ).hour == 16 # 다음날 01:00 이 아니다
# 브라우저 타임존이 KST 가 아니어도 같은 순간을 가리킨다(UTC 로 환산해 확인)
utc = datetime.fromisoformat(detail["created_at"]).astimezone(timezone.utc)
assert (utc.hour, utc.minute) == (7, 0) # KST 16:00 == UTC 07:00
def test_trades_endpoint_convention_untouched():
"""`/trades` 는 raw naive 규약을 유지해야 한다 — 프론트가 `+09:00` 을 덧붙여
보정하는 짝이 맞춰져 있어(`TradeHistory.tsx:11-16`), 여기서 오프셋을 붙이면
매매내역 표시가 깨진다. 일지와 규약이 다른 것은 **의도된 것**이다."""
src = (APP_ROOT / "api" / "endpoints" / "trades.py").read_text()
code = "\n".join(l.split("#", 1)[0] for l in src.splitlines())
assert "+09:00" not in code
assert "_kst_iso" not in code
def test_journal_offset_survives_frontend_trades_normalizer():
"""프론트 `/trades` 보정(`includes('+') ? raw : raw+'+09:00'`)을 일지 문자열에
적용해도 이중 보정이 되지 않는다 — dash-fix 가 같은 헬퍼를 재사용해도 안전."""
from app.api.endpoints.journals import _kst_iso
raw = _kst_iso(datetime(2026, 9, 29, 15, 48, 0))
normalized = raw if ("+" in raw or raw.endswith("Z")) else raw + "+09:00"
assert normalized == raw
assert datetime.fromisoformat(normalized).utcoffset() == timedelta(hours=9)
# ─────────────────────────────────────────────────────────────────────────────
# B2 — now_kst 섀도잉 재발 방지
# ─────────────────────────────────────────────────────────────────────────────
def _shadow_offenders(root: pathlib.Path):
offenders = []
for path in sorted(root.rglob("*.py")):
if "__pycache__" in str(path):
continue
try:
tree = ast.parse(path.read_text())
except SyntaxError: # pragma: no cover
continue
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "timeutil" in node.module:
for alias in node.names:
imported.add(alias.asname or alias.name)
if not imported:
continue
class _V(ast.NodeVisitor):
def _fn(self, node):
for sub in ast.walk(node):
if (
isinstance(sub, ast.Name)
and isinstance(sub.ctx, ast.Store)
and sub.id in imported
):
offenders.append(
f"{path.relative_to(root)}:{sub.lineno} {node.name}() → {sub.id}"
)
if isinstance(sub, ast.arg) and sub.arg in imported:
offenders.append(
f"{path.relative_to(root)}:{sub.lineno} {node.name}(arg {sub.arg})"
)
self.generic_visit(node)
visit_FunctionDef = _fn
visit_AsyncFunctionDef = _fn
_V().visit(tree)
return offenders
def test_no_function_shadows_timeutil_imports():
"""timeutil 에서 import 한 이름을 함수 안에서 재대입하면 그 함수 전체가 지역변수가
되어 같은 함수의 `now_kst()` 호출이 TypeError 로 죽는다(조용히)."""
offenders = _shadow_offenders(APP_ROOT)
assert not offenders, f"timeutil import 섀도잉: {offenders}"
def test_post_market_scheduler_calls_global_now_kst():
from app.services import schedulers
src = inspect.getsource(schedulers.post_market_analyzer_scheduler)
assert "now_kst()" in src, "전역 now_kst() 호출이 사라졌다"
tree = ast.parse(src)
stores = [
n.id for n in ast.walk(tree)
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)
]
assert "now_kst" not in stores
def test_schedulers_except_blocks_do_not_use_logger_debug():
"""CLAUDE.md §4 — 예외 로그에 logger.debug 금지(가드·기능이 조용히 죽는다)."""
tree = ast.parse((APP_ROOT / "services" / "schedulers.py").read_text())
offenders = []
for node in ast.walk(tree):
if not isinstance(node, ast.ExceptHandler):
continue
for sub in ast.walk(node):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "debug"
and isinstance(sub.func.value, ast.Name)
and sub.func.value.id == "logger"
):
offenders.append(sub.lineno)
assert not offenders, f"except 블록의 logger.debug: schedulers.py:{offenders}"
# ─────────────────────────────────────────────────────────────────────────────
# B3 — trade_id 해소 / 커버 판정
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_journal_handler_resolves_trade_id_from_order_id():
from app.services.db_worker import _handle_insert_trade_journal
db = _FakeDB([[334]]) # order_id → TradeHistory.id
await _handle_insert_trade_journal(db, {
"trade_id": None,
"order_id": "0001234567",
"stock_code": "009830",
"stock_name": "한화솔루션",
"journal_date": datetime(2026, 9, 29, 16, 0),
"content": "x",
"strategy_name": "AUTO_TRADE",
})
assert db.commits == 1
assert len(db.added) == 1
assert db.added[0].trade_id == 334
# order_id 는 TradeJournal 컬럼이 아니다 — 남아 있으면 INSERT 가 터진다
assert not hasattr(db.added[0], "order_id") or db.added[0].order_id is None
@pytest.mark.asyncio
async def test_journal_handler_converts_zero_trade_id_to_none():
"""0 은 존재하지 않는 PK 다 — 거짓 커버 판정을 만들지 않도록 None 이어야 한다."""
from app.services.db_worker import _handle_insert_trade_journal
db = _FakeDB([[]]) # 해소 실패
await _handle_insert_trade_journal(db, {
"trade_id": 0,
"order_id": None,
"stock_code": "009830",
"journal_date": datetime(2026, 9, 29, 16, 0),
"content": "x",
})
assert db.added[0].trade_id is None
@pytest.mark.asyncio
async def test_journal_handler_keeps_explicit_trade_id():
"""스케줄러 경로(`enqueue_trade_journal`)처럼 trade_id 를 이미 아는 경우는 보존."""
from app.services.db_worker import _handle_insert_trade_journal
db = _FakeDB([[999]])
await _handle_insert_trade_journal(db, {
"trade_id": 334,
"stock_code": "009830",
"journal_date": datetime(2026, 9, 29, 16, 0),
"content": "x",
})
assert db.added[0].trade_id == 334
assert not db.statements, "trade_id 를 이미 알면 해소 조회를 하지 않는다"
@pytest.mark.asyncio
async def test_uncovered_trades_ignores_nonpositive_trade_ids():
"""커버 판정은 `trade_id > 0` 이어야 한다 — 0/NULL 은 '커버 안 함'."""
from app.services.trade_analyzer import get_uncovered_trades
db = _FakeDB([[(334,)], []])
await get_uncovered_trades(db, days=7)
covered_sql = str(db.statements[0].compile(compile_kwargs={"literal_binds": True}))
assert "trade_journals.trade_id > 0" in covered_sql.replace("\n", " "), covered_sql
def test_journal_callsites_do_not_pass_zero_trade_id():
src = (APP_ROOT / "api" / "endpoints" / "portfolio.py").read_text()
assert "trade_id=0" not in src, "trade_id=0 플레이스홀더가 되살아났다"
assert src.count("order_id=order_result") >= 1
def test_drain_path_shares_journal_handler():
"""종료 drain 이 별도 구현을 쓰면 trade_id 해소가 빠진다(CLAUDE.md §4.1 공통 종단)."""
src = (APP_ROOT / "services" / "db_worker.py").read_text()
assert src.count("_handle_insert_trade_journal(") >= 3 # def + 정상 경로 + drain
assert "drain_session.add(TradeJournal(" not in src
# ─────────────────────────────────────────────────────────────────────────────
# B2-4 — 16:00 배치의 실행/대기 판정 (건너뜀 경로가 없음을 확정)
# ─────────────────────────────────────────────────────────────────────────────
import pytz # noqa: E402
_SCHED_KST = pytz.timezone("Asia/Seoul")
def _at(y, m, d, hh, mm, ss=0):
return _SCHED_KST.localize(datetime(y, m, d, hh, mm, ss))
@pytest.mark.parametrize(
"hh,mm",
[(16, 0), (16, 1), (16, 5), (17, 3), (20, 0), (23, 59)],
)
def test_post_market_runs_immediately_when_booted_after_1600(hh, mm):
"""16:00 이후에 프로세스가 기동하면 **당일 즉시 실행**한다.
2026-09-29 15:5x 배포 후 16:00 일지가 0건이었던 관측의 원인 후보였다.
`tomorrow_16` 분기는 `last_run == today` 일 때만 도달하므로, 미실행 상태로
16:00 이후에 기동하면 건너뛰지 않는다.
"""
from app.services.schedulers import _post_market_decide
action, _ = _post_market_decide(_at(2026, 9, 29, hh, mm), None) # 화요일
assert action == "run"
@pytest.mark.parametrize("hh,mm,expect_max", [(15, 50, 600), (15, 59, 60), (9, 0, 1800)])
def test_post_market_waits_until_1600_then_runs(hh, mm, expect_max):
"""16:00 이전 기동은 16:00 까지 대기했다가 실행한다(한 번에 최대 1800초)."""
from app.services.schedulers import _post_market_decide
action, secs = _post_market_decide(_at(2026, 9, 29, hh, mm), None)
assert action == "sleep"
assert 0 < secs <= expect_max
# 대기 후 16:00 시점 재평가 → 실행
assert _post_market_decide(_at(2026, 9, 29, 16, 0), None)[0] == "run"
def test_post_market_does_not_rerun_same_day():
from app.services.schedulers import _post_market_decide
from datetime import date
action, secs = _post_market_decide(_at(2026, 9, 29, 17, 0), date(2026, 9, 29))
assert action == "sleep"
assert secs > 20 * 3600 # 익일 16:00 까지
def test_post_market_runs_next_day_after_previous_run():
from app.services.schedulers import _post_market_decide
from datetime import date
assert _post_market_decide(_at(2026, 9, 30, 16, 0), date(2026, 9, 29))[0] == "run"
def test_post_market_skips_weekend_without_burning_marker():
from app.services.schedulers import _post_market_decide
action, secs = _post_market_decide(_at(2026, 10, 3, 16, 30), None) # 토요일
assert (action, secs) == ("sleep", 3600.0)
def test_post_market_last_run_is_persisted_across_restart():
"""`_last_run` 이 Redis 에 저장·복원되지 않으면 재기동마다 같은 날 재실행된다."""
from app.services import schedulers
src = inspect.getsource(schedulers.post_market_analyzer_scheduler)
assert "_load_post_market_last_run()" in src
assert "_save_post_market_last_run(today)" in src
@pytest.mark.asyncio
async def test_post_market_last_run_roundtrip_degrades_safely(monkeypatch):
"""Redis 부재/장애 시 None 을 반환해 기존 인메모리 동작으로 안전하게 퇴화한다."""
from app.services import schedulers
async def _boom(*a, **k):
raise RuntimeError("redis down")
import app.core.redis as core_redis
monkeypatch.setattr(core_redis, "get_json", _boom)
monkeypatch.setattr(core_redis, "setex_json", _boom)
assert await schedulers._load_post_market_last_run() is None
await schedulers._save_post_market_last_run(datetime(2026, 9, 29).date()) # 예외 전파 금지
# ─────────────────────────────────────────────────────────────────────────────
# C1 — order_id 추출 (KIS 응답은 output.ODNO 이고 top-level "odno" 는 없다)
# ─────────────────────────────────────────────────────────────────────────────
_KIS_SELL_RESPONSE = {
"rt_cd": "0",
"msg_cd": "APBK0013",
"msg1": "주문 전송 완료 되었습니다.",
"output": {"KRX_FWDG_ORD_ORGNO": "00950", "ODNO": "0000117057", "ORD_TMD": "154829"},
}
def _eval_order_id_expressions(path: pathlib.Path):
"""소스에서 `order_id` 를 만드는 **실제 식**을 꺼내 실제 응답 shape 로 평가한다.
문자열 존재 확인은 `order_result.get("odno")` 같은 틀린 키를 통과시킨다.
"""
tree = ast.parse(path.read_text())
exprs = []
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == "_order_id":
exprs.append(ast.unparse(node.value))
if isinstance(node, ast.Call):
for kw in node.keywords:
if kw.arg == "order_id":
exprs.append(ast.unparse(kw.value))
env = {"order_result": _KIS_SELL_RESPONSE, "_out": _KIS_SELL_RESPONSE["output"]}
out = []
# `_order_id = <식>` 을 먼저 평가해 바인딩한 뒤, 그 변수를 참조하는 호출부 식을 본다
for e in exprs:
if e == "_order_id" and "_order_id" not in env:
continue
env.setdefault("_order_id", None)
value = eval(e, {}, dict(env)) # noqa: S307 — 자기 소스만 평가
out.append((e, value))
if e != "_order_id":
env["_order_id"] = value
return out
def test_order_id_extraction_uses_output_odno():
"""`portfolio.py` 의 모든 order_id 식이 실제 KIS 응답에서 주문번호를 뽑아낸다."""
results = _eval_order_id_expressions(APP_ROOT / "api" / "endpoints" / "portfolio.py")
assert results, "order_id 식을 찾지 못했다"
for expr, value in results:
assert value == "0000117057", f"주문번호 추출 실패: {expr!r} → {value!r}"
def test_top_level_odno_alone_would_fail():
"""대조군 — 이전 코드(`order_result.get('odno')` 단독)는 None 을 낸다."""
assert _KIS_SELL_RESPONSE.get("odno") is None
# ─────────────────────────────────────────────────────────────────────────────
# C2 — 전략 파라미터 자동 적용 차단 (기본 OFF)
# ─────────────────────────────────────────────────────────────────────────────
def test_auto_apply_flag_defaults_off():
"""CLAUDE.md §8.4 백테스트 요건 충족 전까지 False 여야 한다."""
from app.core.config import settings
assert settings.STRATEGY_AUTO_APPLY_ENABLED is False
@pytest.mark.asyncio
async def test_apply_params_blocked_when_flag_off(monkeypatch, caplog):
"""플래그 OFF 면 `strategy_cfg.upsert()` 가 **한 번도** 호출되지 않는다."""
import logging
from app.core.config import settings
from app.services import strategy_optimizer as so
monkeypatch.setattr(settings, "STRATEGY_AUTO_APPLY_ENABLED", False, raising=False)
calls = []
async def _spy(key, value, updated_by=None):
calls.append((key, value, updated_by))
monkeypatch.setattr(so.strategy_cfg, "upsert", _spy)
with caplog.at_level(logging.WARNING, logger=so.__name__):
applied = await so.apply_params(
{"MEAN_REVERSION_RSI_THRESHOLD": 30.0, "STOP_LOSS_PERCENT": -6.0}
)
assert applied is False
assert calls == [], f"차단됐어야 하는데 DB 쓰기가 발생했다: {calls}"
blob = " ".join(r.getMessage() for r in caplog.records)
assert "STRATEGY_AUTO_APPLY_ENABLED" in blob
assert "MEAN_REVERSION_RSI_THRESHOLD" in blob # 무엇이 적용될 뻔했는지 남는다
@pytest.mark.asyncio
async def test_apply_params_blocked_even_if_upsert_would_raise(monkeypatch):
"""가드가 `try` 밖이므로, 쓰기 경로가 터지든 말든 차단 결과는 동일하다."""
from app.core.config import settings
from app.services import strategy_optimizer as so
monkeypatch.setattr(settings, "STRATEGY_AUTO_APPLY_ENABLED", False, raising=False)
async def _boom(*a, **k):
raise RuntimeError("DB down")
monkeypatch.setattr(so.strategy_cfg, "upsert", _boom)
assert await so.apply_params({"STOP_LOSS_PERCENT": -6.0}) is False
@pytest.mark.asyncio
async def test_apply_params_applies_when_flag_on(monkeypatch):
"""플래그 ON 이면 기존 동작 그대로 — 키마다 upsert 한다."""
from app.core.config import settings
from app.services import strategy_optimizer as so
monkeypatch.setattr(settings, "STRATEGY_AUTO_APPLY_ENABLED", True, raising=False)
calls = []
async def _spy(key, value, updated_by=None):
calls.append((key, value, updated_by))
monkeypatch.setattr(so.strategy_cfg, "upsert", _spy)
assert await so.apply_params({"A": 1, "B": 2}, reason="manual") is True
assert sorted(c[0] for c in calls) == ["A", "B"]
assert all(c[2] == "manual" for c in calls)
def _guard_is_outside_try(func_src: str, flag: str) -> bool:
"""`flag` 를 읽는 If 가 어떤 Try 본문에도 들어 있지 않은지 확인."""
tree = ast.parse(func_src)
in_try = set()
for node in ast.walk(tree):
if isinstance(node, ast.Try):
for sub in ast.walk(node):
in_try.add(id(sub))
found = False
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
if any(
isinstance(n, ast.Attribute) and n.attr == flag for n in ast.walk(node.test)
):
found = True
if id(node) in in_try:
return False
return found
def test_auto_apply_guards_are_outside_try_blocks():
"""CLAUDE.md §4 — 리스크 가드를 `try` 안에 두면 예외로 조용히 통과한다."""
import inspect as _inspect
from app.services import strategy_optimizer as so
for fn in (so.apply_params, so.run_optimization):
src = _inspect.getsource(fn)
assert _guard_is_outside_try(src, "STRATEGY_AUTO_APPLY_ENABLED"), (
f"{fn.__name__}() 의 자동적용 가드가 try 안에 있거나 없다"
)
def test_optimizer_still_computes_and_logs_when_blocked():
"""차단은 '적용'만 막는다 — 계산·IS/OOS 평가·로그 저장은 그대로 돌아야 한다."""
import inspect as _inspect
from app.services import strategy_optimizer as so
src = _inspect.getsource(so.run_optimization)
assert "_evaluate_params" in src # 그리드 서치 유지
assert "oos_sharpe" in src # OOS 검증 유지
assert "apply_reason" in src # 사유가 로그/DB 에 남는다
# 차단 경로에서도 결과 객체를 반환한다(호출부가 save_optimization_log 로 기록)
assert "return OptimizationResult(" in src
# ─────────────────────────────────────────────────────────────────────────────
# C2 부수 발견 — strategy_cfg.upsert() 가 항상 AttributeError 로 터지고 있었다
# ─────────────────────────────────────────────────────────────────────────────
def test_datetime_module_has_no_now_kst():
"""대조군 — `__import__("datetime").now_kst()` 는 존재하지 않는 속성이다."""
import datetime as _dtmod
assert not hasattr(_dtmod, "now_kst")
def test_strategy_config_upsert_uses_imported_now_kst():
"""전략 파라미터 저장이 `updated_at` 한 줄 때문에 전 경로 500 이었다.
C2 의 대안("적용은 운영자가 전략설정 UI 에서 수동으로")이 성립하려면 이 경로가
살아 있어야 한다. 되살아나면 자동 적용 차단의 우회로가 아니라 **저장 자체**가 막힌다.
"""
src = (APP_ROOT / "services" / "strategy_config_service.py").read_text()
code = "\n".join(l.split("#", 1)[0] for l in src.splitlines())
assert '__import__("datetime").now_kst()' not in code
assert "from app.core.timeutil import now_kst" in code
assert "cfg.updated_at = now_kst()" in code

View File

@@ -164,7 +164,14 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
/* ── 매매일지 탭 ── */ /* ── 매매일지 탭 ── */
journals && journals.length > 0 ? ( journals && journals.length > 0 ? (
journals.map((journal: Journal) => { 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 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 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' 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({ const generateJournals = useMutation({
mutationFn: () => api.post('/analysis/generate-journals?days=7'), mutationFn: () => api.post('/analysis/generate-journals?days=7'),
onSuccess: () => { 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, 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({ const { data: journals } = useQuery({
queryKey: ['journals'], queryKey: ['journals'],
queryFn: async () => queryFn: async () =>
(await api.get('/journals', { params: { limit: 20 } })).data, (await api.get('/journals', { params: { limit: 20 } })).data,
staleTime: Infinity, staleTime: 30000,
refetchOnWindowFocus: false, refetchInterval: 60000,
refetchOnWindowFocus: true,
}) })
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -492,7 +508,14 @@ const Dashboard = () => {
} }
if (msg.type === 'journal_created') { if (msg.type === 'journal_created') {
// 브로드캐스트가 db_worker INSERT 커밋 전에 발화하는 레이스가 있다
// (backend/app/api/endpoints/portfolio.py 확인 — 큐 적재 직후 즉시
// 브로드캐스트, 커밋은 비동기 워커가 나중에 수행). 즉시 1회 + 지연
// 재조회 1회로 레이스에 대비 — 최종적으로는 60초 폴링이 항상 따라잡는다.
queryClient.invalidateQueries({ queryKey: ['journals'] }) queryClient.invalidateQueries({ queryKey: ['journals'] })
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['journals'] })
}, 3000)
} }
} }
}, [wsMessages, invalidatePortfolio, queryClient]) }, [wsMessages, invalidatePortfolio, queryClient])