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

@@ -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()}