Files
auto-stock/backend/tests/test_journal_fixes_20260929.py
shuim b5e0d6a9bb 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>
2026-09-29 17:37:48 +09:00

600 lines
27 KiB
Python

"""매매일지 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