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
36 changes: 34 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -2197,15 +2221,23 @@ 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(
nav_points[0][0], nav_points[-1][0],
)
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)
Expand Down
56 changes: 56 additions & 0 deletions tests/test_performance_lens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading