From 4f3a0a39368562799ef0dd9b4426df7cfffbae9d Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Thu, 27 Aug 2026 10:43:45 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A3=BC=EA=B0=84=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EC=A0=81=EB=A6=BD=20=EC=9E=85=EA=B8=88?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=9D=B5=EC=9C=BC=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performance_lens는 flows 인자로 유입을 중화할 수 있는데 주간 리포트가 그걸 안 넘겼다. 어제 kr_pocket에 적립 10만원을 넣자 NAV가 284,499 → 385,460이 됐고, 그날이 통째로 +35% 수익으로 계산됐다. 연환산 변동성 109.2% → 29.5% 샤프 +2.28 → -1.18 하락일 41% → 59% 리포트가 오너에게 '샤프 +2.28'이라고 말하고 있었다. 실제로는 -1.18이다. 방향이 반대라 숫자 하나 틀린 것과는 다르다 — 위험 지표가 정반대 결론을 주고 있었다. 어제 첫 적립을 넣기 전까지는 흐름이 없어서 드러나지 않았다. 적립식 트랙은 이 경로가 상시라 한 번 새면 매주 샌다. 호출부가 잊을 수 있는 인자였던 게 원인이라, 유입 수집을 account_flows_by_day로 빼고 테스트로 고정했다. 함수가 flows를 지원하는 것과 호출부가 실제로 넘기는 것은 다른 문제다 — 전자만 테스트하고 있었다. 국면 분해(aligned_returns)도 같은 인자를 쓰므로 함께 넘긴다. --- main.py | 36 ++++++++++++++++++++-- tests/test_performance_lens.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) 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