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

View File

@@ -317,6 +317,26 @@ class Settings(BaseSettings):
# ═════════════════════════════════════════════════════════════════════════════
# 박스권(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).**
# 이 전략은 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:
"""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",

View File

@@ -21,6 +21,81 @@ from app.core.market_schedule import (
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:
"""서로 다른 포맷의 추천 결과를 recommendations_by_rule 포맷으로 정규화.
@@ -91,16 +166,16 @@ async def batch_analysis_scheduler():
logger.info(f"📊 Batch analysis scheduler started (every {interval}s)")
try:
while True:
now_kst = pytz.datetime.datetime.now(_KST)
_now = pytz.datetime.datetime.now(_KST)
# 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 = (
_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)
)
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}시간 후 재개")
await asyncio.sleep(sleep_sec)
continue
@@ -179,37 +254,35 @@ async def post_market_analyzer_scheduler():
- 매일 16:00 KST 에 1회 실행 (이미 오늘 실행했으면 스킵)
- 최근 7일 내 SELL FILLED 거래 >= 5건이면 최적화 실행
- 마지막 최적화로부터 3일 이상 경과 시만 최적화 실행
실행 여부 판정은 순수 함수 `_post_market_decide()` 에 있다(테스트 대상).
"오늘 실행했는가" 마커는 Redis 에 영속화한다 — 인메모리만 쓰면 재기동마다
리셋돼 같은 날 두 번 돌고, 텔레그램 일일 리포트가 중복 발송된다.
"""
_KST = pytz.timezone("Asia/Seoul")
_last_run: _date | None = None
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:
while True:
now_kst = _dt.now(_KST)
today = now_kst.date()
_now = _dt.now(_KST)
today = _now.date()
if _last_run == today:
tomorrow_16 = _KST.localize(_dt(today.year, today.month, today.day, 16, 0, 0)) + _td(days=1)
sleep_sec = (tomorrow_16 - now_kst).total_seconds()
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)
action, sleep_sec = _post_market_decide(_now, _last_run)
if action == "sleep":
await asyncio.sleep(sleep_sec)
continue
logger.info("📝 [PostMarket] 매매일지 자동 생성 + 전략 최적화 시작")
_last_run = today
await _save_post_market_last_run(today)
try:
from app.services.trade_analyzer import (
@@ -268,8 +341,8 @@ async def post_market_analyzer_scheduler():
if _summary:
_pf_value = float(_summary.get("total_assets", 0))
_cash = float(_summary.get("available_cash", 0))
except Exception:
pass
except Exception as _pf_err:
logger.warning(f"[PostMarket] 포트폴리오 요약 조회 실패: {_pf_err}")
await telegram_notifier.notify_daily_report(
date_str=str(today),
total_pnl=_total_pnl,
@@ -280,7 +353,12 @@ async def post_market_analyzer_scheduler():
cash_balance=_cash,
)
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)
trade_count_result = await db.execute(
@@ -328,8 +406,8 @@ async def krx_data_collector_scheduler():
try:
while True:
now_kst = _dt.now(_KST)
today = now_kst.date()
_now = _dt.now(_KST)
today = _now.date()
# 오늘 이미 수집했으면 자정까지 대기
if _last_collected == today:
@@ -337,19 +415,19 @@ async def krx_data_collector_scheduler():
tomorrow_16 = _KST.localize(
_dt(today.year, today.month, today.day, 16, 0, 0)
) + _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}시간 대기")
await asyncio.sleep(max(sleep_sec, 60))
continue
# 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까지 남은 초
target = _KST.localize(
_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:
logger.info(f"📅 KRX 수집 대기 중... 15:40까지 {sleep_sec/60:.0f}분 남음")
await asyncio.sleep(min(sleep_sec, 1800)) # 최대 30분마다 재확인
@@ -654,7 +732,7 @@ async def news_collect_scheduler():
f"score={score:.3f}"
)
except Exception as e:
logger.debug(
logger.warning(
f"[NEWS_SCHEDULER] {code} 뉴스 수집 오류: {e}"
)
await asyncio.sleep(1) # 크롤링 간격 (서버 부하 방지)
@@ -694,15 +772,15 @@ async def pre_market_us_scheduler():
try:
while True:
now_kst = _dt.now(_KST)
today = now_kst.date()
_now = _dt.now(_KST)
today = _now.date()
# 오늘 이미 실행했으면 내일 07:00까지 대기
if _last_run == today:
tomorrow_07 = _KST.localize(
_dt(today.year, today.month, today.day, 7, 0, 0)
) + _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)
continue
@@ -712,17 +790,17 @@ async def pre_market_us_scheduler():
continue
# 07:00 이전이면 대기
if now_kst.hour < 7:
if _now.hour < 7:
target_07 = _KST.localize(
_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:
await asyncio.sleep(min(sleep_sec, 1800))
continue
# 09:00 이후면 오늘은 스킵 (이미 장 시작)
if now_kst.hour >= 9:
if _now.hour >= 9:
_last_run = today
await asyncio.sleep(3600)
continue

View File

@@ -231,7 +231,12 @@ class StrategyConfigService:
cfg.value = value
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.refresh(cfg)

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}")

View File

@@ -693,7 +693,10 @@ async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHis
since = now_kst() - timedelta(days=days)
# 이미 일지가 있는 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_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.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])