diff --git a/database/repositories.py b/database/repositories.py index 89fa4d9b..576948a0 100644 --- a/database/repositories.py +++ b/database/repositories.py @@ -780,10 +780,13 @@ def record_cash_flow( """외부 현금 흐름(+입금/-출금)을 기록하고 id를 반환한다. 입금은 수익이 아니다 — TWR 계산이 이 기록으로 구간을 나눈다. amount=0은 무의미 - 하므로 ValueError. + 하므로 ValueError. NaN/Inf는 합산(현금·원금·TWR)을 통째로 오염시키므로 최종 + 방어선인 여기서도 차단한다(NaN은 truthy라 `if not amount`를 통과한다). """ - if not amount: - raise ValueError("amount는 0이 아니어야 합니다 (+입금 / -출금)") + import math + + if not amount or not math.isfinite(float(amount)): + raise ValueError("amount는 0이 아닌 유한한 숫자여야 합니다 (+입금 / -출금)") session = get_session() try: row = CashFlow( diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index d1f3097c..e2aac2c2 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -119,7 +119,8 @@ def get_baskets_json() -> dict: if snap is not None: total = float(snap.total_value or 0) cash = float(snap.cash or 0) - deployment_ratio = ((total - cash) / total) if total > 0 else None + # 음수 클램프 — 헬스(run_health_check)와 동일 규칙(현금>총액 이상치 방어) + deployment_ratio = (max(0.0, (total - cash) / total)) if total > 0 else None snapshot = { "date": str(snap.date)[:10], "total_value": total, diff --git a/tests/test_cash_flows.py b/tests/test_cash_flows.py index fdff0094..8bd47a84 100644 --- a/tests/test_cash_flows.py +++ b/tests/test_cash_flows.py @@ -48,6 +48,15 @@ def test_zero_amount_rejected(self): with pytest.raises(ValueError): record_cash_flow(0, account_key="basket_rebalance:test_cf_zero") + def test_nonfinite_amount_rejected_at_repo_layer(self): + # NaN은 truthy라 `if not amount`를 통과하고, 기록되면 모든 합산이 오염된다 + # — 최종 방어선(repo)에서 차단. + init_database() + for bad in (float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError): + record_cash_flow(bad, account_key="basket_rebalance:test_cf_nonfinite") + assert get_cash_flow_total(account_key="basket_rebalance:test_cf_nonfinite") == 0.0 + def test_between_boundaries(self): # after 배타 / until 포함 init_database() diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py index 4c120ed4..76ab8243 100644 --- a/tests/test_dashboard_baskets.py +++ b/tests/test_dashboard_baskets.py @@ -230,6 +230,63 @@ async def run(): asyncio.run(run()) + def test_deposit_rejects_nonfinite_json_literals(self): + # python json.loads는 Infinity/NaN 리터럴을 기본 허용 — float('inf')>0 은 True, + # nan<=0 은 False라 기존 양수 검사를 둘 다 통과해 무한대/NaN 입금이 기록되던 + # 실제 구멍. isfinite 검증으로 400이어야 한다. + import asyncio + from aiohttp.test_utils import TestClient, TestServer + from monitoring import web_dashboard as wd + from core.basket_rebalancer import rebalance_live_strategy_id + from database.repositories import get_cash_flow_total + + name = "kr_pocket_inf" + init_database() + + async def run(): + with patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + app = wd.create_app() + client = TestClient(TestServer(app)) + await client.start_server() + try: + for payload in ( + b'{"basket": "kr_pocket_inf", "amount": Infinity}', + b'{"basket": "kr_pocket_inf", "amount": NaN}', + b'{"basket": "kr_pocket_inf", "amount": -Infinity}', + ): + res = await client.post( + "/api/deposit", data=payload, + headers={"Content-Type": "application/json"}, + ) + assert res.status == 400, f"payload {payload!r} → {res.status}" + finally: + await client.close() + + asyncio.run(run()) + assert get_cash_flow_total( + account_key=rebalance_live_strategy_id(name) + ) == 0.0 # 아무것도 기록되지 않아야 한다 + + def test_deposit_trims_overlong_note(self): + # SQLite는 String(200)을 강제하지 않는다 — 서버측 절단 계약. + from tools.record_deposit import record_basket_deposit + from core.basket_rebalancer import rebalance_live_strategy_id + from database.repositories import get_recent_cash_flows + + name = "kr_pocket_note" + init_database() + with patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + out = record_basket_deposit(name, 10_000, note="가" * 500) + assert out["ok"] is True + flows = get_recent_cash_flows(rebalance_live_strategy_id(name)) + assert len(flows[0]["note"]) == 200 + @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치") def test_cash_flows_endpoint_lists_recent(): diff --git a/tools/record_deposit.py b/tools/record_deposit.py index 72c0a156..eb124b5e 100644 --- a/tools/record_deposit.py +++ b/tools/record_deposit.py @@ -35,12 +35,21 @@ def record_basket_deposit(basket_name: str, amount: float, note: str = "") -> di record_cash_flow, ) + import math + try: amount = float(amount) except (TypeError, ValueError): return {"ok": False, "error": "금액이 숫자가 아닙니다"} + # isfinite 필수: json.loads는 Infinity/NaN 리터럴을 기본 허용하고, NaN은 + # `<= 0` 비교가 False라 양수 검사를 그대로 통과한다 — 기록되면 현금·원금·TWR + # 합산이 전부 오염된다(웹 POST·CLI --amount inf 둘 다 실제로 뚫리던 구멍). + if not math.isfinite(amount): + return {"ok": False, "error": "금액이 유한한 숫자가 아닙니다"} if amount <= 0: return {"ok": False, "error": "입금액은 양수여야 합니다"} + # note는 컬럼 정의가 200자지만 SQLite는 길이를 강제하지 않는다 — 서버측 절단. + note = str(note or "")[:200] init_database() config = Config.get() @@ -102,8 +111,10 @@ def main() -> int: return 0 # --account-key 직접 지정 경로 (바스켓 문맥 없음 — 원금 표기 생략) - if args.amount <= 0: - logger.error("입금액은 양수여야 합니다: {}", args.amount) + import math + + if not math.isfinite(args.amount) or args.amount <= 0: + logger.error("입금액은 유한한 양수여야 합니다: {}", args.amount) return 1 from database.models import init_database