diff --git a/main.py b/main.py index c7afdf07..39a0eef1 100644 --- a/main.py +++ b/main.py @@ -2061,6 +2061,30 @@ def _design_fraction(cfg: dict) -> float: return {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}.get(health["verdict"], 1) +def account_flows_by_day(account_key: str, mode: str = "paper") -> dict: + """계정의 외부 현금흐름을 {날짜: 그날 순유입}으로 모은다. + + 성과 지표(변동성·샤프·국면 분해)는 이 값을 분모에서 중화해야 한다. 안 넘기면 + 적립 하루가 통째로 수익률로 잡힌다 — 2026-08-26 kr_pocket에 10만원을 넣자 NAV가 + 284,499 → 385,460이 되면서 그날이 +35% 수익으로 계산돼 연환산 변동성 109%, + 샤프 +2.28이라는 허구가 나왔다(실제로는 29.5%, -1.18). 적립식 트랙은 이 경로가 + 상시라 한 번 새면 계속 샌다. + + get_cash_flows는 (occurred_at, amount) 튜플 목록을 준다 — 객체가 아니다. + 조회 실패 시 빈 dict(중화 없음)로 폴백하지 않고 예외를 올린다: 조용히 중화를 + 건너뛰면 지표가 틀린 채로 보고된다. + """ + from database.repositories import get_cash_flows + + out: dict = {} + for occurred_at, amount in get_cash_flows(account_key=account_key, mode=mode): + if occurred_at is None: + continue + day = occurred_at.date() if hasattr(occurred_at, "date") else occurred_at + out[day] = out.get(day, 0.0) + float(amount or 0) + return out + + def _fetch_benchmark_closes(start, end) -> dict: """벤치마크(KS11) 종가를 {date: close}로. 실패하면 빈 dict. @@ -2197,7 +2221,14 @@ def _d(v): # 그 열은 2026-08-10 이전 전 구간이 0.0이라(값을 안 넘기던 버그) # 그대로 쓰면 변동성이 0으로 깔려 없는 안정성을 주장하게 된다. nav_points = [(s.date, s.total_value) for s in snaps] - daily = daily_returns_from_nav(nav_points) + # 입금은 수익이 아니다 — 날짜별 유입을 넘겨 분모에서 중화한다. + # 안 넘기면 적립 하루가 통째로 수익률로 잡힌다: 2026-08-26 kr_pocket에 + # 10만원을 넣자 NAV가 284,499 → 385,460이 되면서 그날이 +35% 수익으로 + # 계산돼 연환산 변동성 109%, 샤프 +2.28이라는 허구가 나왔다. + # 적립식 트랙은 이 경로가 상시라 한 번 새면 계속 샌다. + flows = account_flows_by_day(key, mode="paper") + + daily = daily_returns_from_nav(nav_points, flows=flows) risk = risk_metrics([r for _, r in daily]) if len(nav_points) >= 2: closes = _fetch_benchmark_closes( @@ -2205,7 +2236,8 @@ def _d(v): ) if closes: pairs = [ - (m, b) for _d, m, b in aligned_returns(nav_points, closes) + (m, b) + for _d, m, b in aligned_returns(nav_points, closes, flows=flows) ] if pairs: regime = split_by_regime(pairs) diff --git a/tests/test_performance_lens.py b/tests/test_performance_lens.py index f8a3e63e..39a907fe 100644 --- a/tests/test_performance_lens.py +++ b/tests/test_performance_lens.py @@ -156,3 +156,59 @@ def test_zero_variance_series_has_no_sharpe(): m = risk_metrics([0.0, 0.0, 0.0]) assert m["vol_annual_pct"] == pytest.approx(0.0) assert m["sharpe_annual"] is None + + +class TestDepositNeutralisationWiring: + """입금 중화는 함수만 지원해선 안 되고 호출부가 실제로 넘겨야 한다. + + 2026-08-27 실측 버그: performance_lens는 flows 인자를 지원하는데 주간 리포트가 + 안 넘겨서, 전날 kr_pocket 적립 10만원이 그날 +35% 수익으로 잡혔다. + NAV 284,499 → 385,460. 그 결과 연환산 변동성 109%, 샤프 +2.28이 보고됐다 + (유입 중화 후 실제는 29.5%, -1.18). 적립식 트랙은 이 경로가 상시다. + """ + + def test_flow_helper_groups_by_day(self, monkeypatch): + import main + + monkeypatch.setattr( + "database.repositories.get_cash_flows", + lambda account_key, mode="paper": [ + (datetime(2026, 8, 26, 17, 19), 100000.0), + (datetime(2026, 8, 26, 18, 0), 50000.0), + (datetime(2026, 9, 1, 9, 0), 100000.0), + ], + ) + flows = main.account_flows_by_day("acct") + assert flows == {date(2026, 8, 26): 150000.0, date(2026, 9, 1): 100000.0} + + def test_no_flows_is_empty(self, monkeypatch): + import main + + monkeypatch.setattr( + "database.repositories.get_cash_flows", + lambda account_key, mode="paper": [], + ) + assert main.account_flows_by_day("acct") == {} + + def test_deposit_day_is_not_a_return(self): + """실측 수치로 고정 — 중화하면 0%, 안 하면 +35%.""" + nav = [(date(2026, 8, 25), 280718.0), (date(2026, 8, 26), 385460.0)] + flows = {date(2026, 8, 26): 100000.0} + + with_flow = daily_returns_from_nav(nav, flows=flows)[0][1] + without = daily_returns_from_nav(nav)[0][1] + + assert without > 30, "중화 없이는 입금이 큰 수익으로 잡힌다(버그 재현)" + assert abs(with_flow) < 2.0, f"중화 후에도 {with_flow:.1f}% — 입금이 수익에 남았다" + + def test_volatility_is_not_inflated_by_a_deposit(self): + """입금 하루가 변동성을 통째로 왜곡하지 않는지.""" + base = [(date(2026, 8, 1 + i), 280000.0 + i * 500) for i in range(10)] + nav = base + [(date(2026, 8, 11), 380000.0)] # 마지막 날 10만원 입금 + flows = {date(2026, 8, 11): 100000.0} + + inflated = risk_metrics([r for _, r in daily_returns_from_nav(nav)]) + correct = risk_metrics([r for _, r in daily_returns_from_nav(nav, flows=flows)]) + + assert inflated["vol_annual_pct"] > correct["vol_annual_pct"] * 3 + assert correct["vol_annual_pct"] < 30