Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/PAPER_MONTH1_REVIEW_AND_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 평가 문서가
Expand Down
72 changes: 53 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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). 미지정 시 오늘",
Expand Down
83 changes: 81 additions & 2 deletions tests/test_rebalance_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading