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
9 changes: 6 additions & 3 deletions database/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion monitoring/web_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions tests/test_cash_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
57 changes: 57 additions & 0 deletions tests/test_dashboard_baskets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
15 changes: 13 additions & 2 deletions tools/record_deposit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading