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