From 3f005835e4240e48525bee99e690ab699360db1c Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 7 Jul 2026 11:15:12 +0900 Subject: [PATCH] =?UTF-8?q?fix:=201=EC=9D=BC=201=EB=A7=A4=EB=A7=A4=20?= =?UTF-8?q?=ED=8C=A8=EC=8A=A4=20=EA=B0=80=EB=93=9C=20=E2=80=94=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=AC=EC=8B=A4=ED=96=89=EC=9D=98=20=ED=9A=8C?= =?UTF-8?q?=EC=A0=84=EC=83=81=ED=95=9C=20=EC=9A=B0=ED=9A=8C=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit operation_events 점검에서 일일 태스크가 사이클을 2회 연속 실행한 날을 실측(7/3·7/7, CYCLE_START/END 두 쌍). 멱등 upsert라 스냅샷은 무해하지만 드리프트가 큰 날엔 실행마다 회전 상한(1회 15%)이 따로 적용돼 하루 30%가 돌 수 있다 — 6/10 진입 뭉침(하루 4회 실행 → 61% 집중 매입)과 같은 계열로, 지금까지 문서 경고로만 막던 것을 코드로 강제한다. - run_rebalance: 바스켓별 당일 체결 이력이 있으면 매매 패스(plan/execute) 생략, 스냅샷·리포트·관측성은 그대로 + REBALANCE_SKIPPED_DAILY_LIMIT 기록 - 판정 실패 시 기존 동작 유지(방어선이지 게이트가 아님), dry-run 미적용 - 우회는 --force-rebalance (운영자 명시 결정) - 부수 효과: 결측 복구 재시도(11/14시)가 매매 없이 스냅샷만 복구하게 돼 더 안전해짐 함께: 일일 태스크 프롬프트에 명령 1회 실행 명시(근원 방어), 문서 §3에 코드 강제 반영. 테스트 5케이스 추가(가드 발동/우회/무체결/dry-run/판정실패 폴백), 실CLI 스모크 정상. 전체 스위트 1643 통과. --- docs/PAPER_MONTH1_REVIEW_AND_PLAN.md | 4 ++ main.py | 72 +++++++++++++++++------- tests/test_rebalance_snapshot.py | 83 +++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 21 deletions(-) diff --git a/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md b/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md index de849c61..07be1b68 100644 --- a/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md +++ b/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md @@ -162,6 +162,10 @@ KS11 +0.71% ┘ = 균등가중 10종목 vs 반도체 쏠림 시총 — P2-9(분할 진입 기능)는 별도 코드가 필요 없다. 재시작 당일 결과가 "일부만 매수"로 보여도 정상이며, 배치율 게이지가 수렴 과정을 보여준다. (11:00/14:00 재시도 크론은 스냅샷 결측 시에만 재실행하므로 진입을 가속하지 않는다.) +→ **(2026-07-07) 코드로도 강제됨**: 같은 날 이미 체결이 있으면 두 번째 실행은 매매를 +건너뛴다(1일 1매매 가드 — 스냅샷·리포트는 진행). 실측으로 일일 태스크가 사이클을 2회 +연속 실행하는 날(7/3·7/7)이 발견돼 문서 경고를 가드로 승격했다. 우회는 `--force-rebalance` +(운영자 명시 결정)뿐이다. **트레이드오프를 정직하게**: 17일 기록을 아카이브하고 0/60부터 다시 센다. 아깝지만 — (a) 17일은 전체의 28%이고, (b) 그 17일은 "설계와 다른 배치"의 기록이며, (c) 평가 문서가 diff --git a/main.py b/main.py index 36591c26..0ff740b3 100644 --- a/main.py +++ b/main.py @@ -668,6 +668,7 @@ def run_rebalance(args): notifier = Notifier(config) basket_name = getattr(args, "basket", None) dry_run = getattr(args, "dry_run", False) + force_rebalance = getattr(args, "force_rebalance", False) mode = str(config.trading.get("mode", "paper")).lower() live_rebalance_confirmed = False @@ -758,28 +759,57 @@ def run_rebalance(args): report = rebalancer.get_status_report() logger.info("\n{}", report) - should, reason = rebalancer.should_rebalance() + # 1일 1매매 패스 원칙 강제: 같은 날 두 번째 실행(중복 스케줄·수동 재실행)이 + # 회전 상한(1회 15%)을 사실상 2배로 만드는 것을 차단한다 — 6/10 진입 뭉침 + # (하루 4회 실행 → 61% 집중 매입, 타이밍 비용 -1%p)의 재발 방지를 코드로. + # 실측: 7/3·7/7 일일 태스크가 사이클을 2회 연속 실행함. 결측 복구 재시도 + # 크론은 목적이 스냅샷이라 이 가드로 오히려 더 안전해진다(매매 없이 복구). + # 판정 실패 시 기존 동작 유지(가드는 방어선이지 게이트가 아님). + # 우회: --force-rebalance (운영자 명시 결정). + already_traded_today = False + if not dry_run and not force_rebalance: + try: + from database.repositories import get_trade_history + today0 = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + already_traded_today = bool(get_trade_history( + mode=mode, start_date=today0, account_key=live_strategy_name, + )) + except Exception as guard_exc: + logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc) + executed = False - if not should and not dry_run: - logger.info("바스켓 '{}' 리밸런싱 불필요: {}", name, reason) + if already_traded_today: + logger.info( + "바스켓 '{}' 오늘 이미 체결 있음 — 1일 1매매 패스 원칙으로 매매 건너뜀 " + "(스냅샷·리포트는 진행, 우회: --force-rebalance)", name, + ) + record_cycle_event( + "REBALANCE_SKIPPED_DAILY_LIMIT", + f"바스켓 '{name}' 당일 재실행 — 매매 생략(1일 1매매 패스)", + strategy=live_strategy_name, mode=mode, + ) else: - orders = rebalancer.plan_rebalance() - if not orders: - logger.info("바스켓 '{}' 리밸런싱 주문 없음", name) + should, reason = rebalancer.should_rebalance() + if not should and not dry_run: + logger.info("바스켓 '{}' 리밸런싱 불필요: {}", name, reason) else: - result = rebalancer.execute( - orders, - dry_run=dry_run, - live_confirmed=live_rebalance_confirmed, - ) - executed = True - summary = ( - f"바스켓 '{name}' 리밸런싱 {'(DRY RUN) ' if dry_run else ''}" - f"완료: 실행 {result['executed']}건, 스킵 {result['skipped']}건, " - f"실패 {result['failed']}건" - ) - logger.info(summary) - notifier.send_message(summary) + orders = rebalancer.plan_rebalance() + if not orders: + logger.info("바스켓 '{}' 리밸런싱 주문 없음", name) + else: + result = rebalancer.execute( + orders, + dry_run=dry_run, + live_confirmed=live_rebalance_confirmed, + ) + executed = True + summary = ( + f"바스켓 '{name}' 리밸런싱 {'(DRY RUN) ' if dry_run else ''}" + f"완료: 실행 {result['executed']}건, 스킵 {result['skipped']}건, " + f"실패 {result['failed']}건" + ) + logger.info(summary) + notifier.send_message(summary) # 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다. # 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert. @@ -2091,6 +2121,10 @@ def main(): "--dry-run", action="store_true", help="[rebalance 모드] 실제 주문 없이 계획만 출력", ) + parser.add_argument( + "--force-rebalance", action="store_true", + help="[rebalance 모드] 1일 1매매 패스 가드 우회 — 같은 날 이미 체결이 있어도 매매 재실행 (운영자 명시 결정)", + ) parser.add_argument( "--as-of", type=str, default=None, help="[deploy_check 모드] 가격 기준일(YYYY-MM-DD). 미지정 시 오늘", diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py index c1c4b8fa..a9d9cb4d 100644 --- a/tests/test_rebalance_snapshot.py +++ b/tests/test_rebalance_snapshot.py @@ -14,8 +14,11 @@ import pytest -def _args(basket="kr_test", dry_run=False): - return SimpleNamespace(basket=basket, dry_run=dry_run, confirm_live=False) +def _args(basket="kr_test", dry_run=False, force_rebalance=False): + return SimpleNamespace( + basket=basket, dry_run=dry_run, confirm_live=False, + force_rebalance=force_rebalance, + ) @pytest.fixture @@ -111,6 +114,82 @@ def test_dry_run_rebalance_does_not_send_daily_report(patched_rebalance, monkeyp assert not fake_notifier.send_daily_report.called +# --- 1일 1매매 패스 가드 (같은 날 재실행 시 회전상한 우회 차단) --- + +def _one_trade_today(): + """당일 체결 1건짜리 조회 결과 흉내(내용은 안 봄 — truthy 여부만 판정).""" + return [SimpleNamespace(symbol="069500")] + + +def test_second_run_same_day_skips_trading_but_keeps_snapshot(patched_rebalance, monkeypatch): + """당일 체결이 이미 있으면 plan/execute 생략 — 회전 상한이 2배로 뚫리는 것 차단. + 스냅샷·리포트는 그대로 진행(재시도 크론의 결측 복구가 매매 없이 안전해짐).""" + import main as main_mod + + monkeypatch.setattr( + "database.repositories.get_trade_history", + lambda **kw: _one_trade_today(), + ) + patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}} + patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = { + "total_value": 1_000_000, "cash": 1_000_000, "total_return": 0.0, + "mdd": 0.0, "position_count": 0, + } + main_mod.run_rebalance(_args(dry_run=False)) + + patched_rebalance.should_rebalance.assert_not_called() + patched_rebalance.plan_rebalance.assert_not_called() + patched_rebalance.execute.assert_not_called() + patched_rebalance.save_daily_nav_snapshot.assert_called_once() # 스냅샷은 유지 + + +def test_force_rebalance_bypasses_daily_guard(patched_rebalance, monkeypatch): + import main as main_mod + + monkeypatch.setattr( + "database.repositories.get_trade_history", + lambda **kw: _one_trade_today(), + ) + main_mod.run_rebalance(_args(dry_run=False, force_rebalance=True)) + + patched_rebalance.should_rebalance.assert_called_once() # 가드 우회 → 정상 경로 + + +def test_no_trades_today_runs_normal_path(patched_rebalance, monkeypatch): + import main as main_mod + + monkeypatch.setattr("database.repositories.get_trade_history", lambda **kw: []) + main_mod.run_rebalance(_args(dry_run=False)) + + patched_rebalance.should_rebalance.assert_called_once() + patched_rebalance.save_daily_nav_snapshot.assert_called_once() + + +def test_dry_run_ignores_daily_guard(patched_rebalance, monkeypatch): + """dry-run은 계획 확인 용도 — 당일 체결과 무관하게 항상 허용.""" + import main as main_mod + + monkeypatch.setattr( + "database.repositories.get_trade_history", + lambda **kw: _one_trade_today(), + ) + main_mod.run_rebalance(_args(dry_run=True)) + + patched_rebalance.should_rebalance.assert_called_once() + + +def test_guard_check_failure_falls_back_to_normal_path(patched_rebalance, monkeypatch): + """가드 판정 실패는 기존 동작 유지(방어선이지 게이트가 아님).""" + import main as main_mod + + def _boom(**kw): + raise RuntimeError("db glitch") + monkeypatch.setattr("database.repositories.get_trade_history", _boom) + main_mod.run_rebalance(_args(dry_run=False)) + + patched_rebalance.should_rebalance.assert_called_once() + + # --- P0-1 사이클 관측성 배선(run_rebalance 통합) --- def _summary(**over):