fix(ledger): preserve exact decimal amount in manual and unified responses
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
7fc3386f3a
commit
fde5b33f05
@@ -16,6 +16,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
@@ -687,7 +688,11 @@ def record_ledger_trade_v1(
|
||||
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None
|
||||
amount = (
|
||||
(entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)))
|
||||
if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"}
|
||||
else None
|
||||
)
|
||||
return ManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
@@ -786,6 +791,9 @@ def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None)
|
||||
|
||||
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
amount = None
|
||||
if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"}:
|
||||
amount = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
|
||||
return LedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
@@ -796,7 +804,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None,
|
||||
amount=amount,
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=entry.tax_amount,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
@@ -136,6 +137,7 @@ class LedgerEntry:
|
||||
portfolio_id: str
|
||||
quantity: float
|
||||
price: float
|
||||
price_decimal: Decimal | None = None
|
||||
ledger_entry_id: int = 0
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
@@ -195,4 +197,3 @@ class BrokerCredential:
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
daemon_id: str | None = None
|
||||
|
||||
|
||||
@@ -711,6 +711,7 @@ class PostgresPortfolioRepository:
|
||||
side=row[2],
|
||||
quantity=float(row[4]),
|
||||
price=float(row[3]),
|
||||
price_decimal=Decimal(str(row[3])) if row[3] is not None else None,
|
||||
fee_amount=float(row[5]),
|
||||
tax_amount=float(row[6]),
|
||||
executed_at=row[1],
|
||||
@@ -1864,6 +1865,7 @@ class PostgresPortfolioRepository:
|
||||
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
|
||||
if row is None:
|
||||
raise ValueError("원장 항목을 찾을 수 없습니다")
|
||||
raw_price = row[6]
|
||||
return LedgerEntry(
|
||||
ledger_entry_id=int(row[0]),
|
||||
portfolio_id=row[1],
|
||||
@@ -1871,7 +1873,8 @@ class PostgresPortfolioRepository:
|
||||
execution_id=row[3],
|
||||
side=row[4],
|
||||
quantity=float(row[5]),
|
||||
price=float(row[6]),
|
||||
price=float(raw_price),
|
||||
price_decimal=Decimal(str(raw_price)) if raw_price is not None else None,
|
||||
fee_amount=float(row[7]),
|
||||
tax_amount=float(row[8]),
|
||||
executed_at=row[9],
|
||||
|
||||
+2
-2
@@ -220,7 +220,7 @@ class ManualLedgerResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
portfolio_id: str
|
||||
action: str
|
||||
amount: float | None = None
|
||||
amount: Decimal | None = None
|
||||
quantity: float = 0.0
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
@@ -254,7 +254,7 @@ class LedgerHistoryItem(BaseModel):
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: float | None = None
|
||||
amount: Decimal | None = None
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: float | None = None
|
||||
|
||||
@@ -199,6 +199,7 @@ class _FakeRepo:
|
||||
side=action.lower(),
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)),
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
executed_at=executed_at,
|
||||
@@ -505,7 +506,7 @@ def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() -
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["action"] == "DIVIDEND"
|
||||
assert body["amount"] == 120.0
|
||||
assert body["amount"] == "120"
|
||||
assert body["tax"] == 10.0
|
||||
assert body["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert body["cash_balance_after"] == 1000.0
|
||||
@@ -593,11 +594,14 @@ def test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_
|
||||
)
|
||||
|
||||
invalid_payloads = [
|
||||
{"action": "DIVIDEND", "amount": 1},
|
||||
{"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": -1, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-13-40"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": -1, "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": "0.000000001", "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "tax": "1000000000000.00000000", "executed_at": "2026-09-01"},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "fee": 0},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "quantity": 1},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "price": 1},
|
||||
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "target_ledger_entry_id": 1},
|
||||
@@ -712,6 +716,14 @@ def test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_f
|
||||
headers=_auth(),
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert first.json()["amount"] == "90000000.00000001"
|
||||
assert second.json()["amount"] == "90000000.00000002"
|
||||
|
||||
history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||
assert history.status_code == 200
|
||||
amounts = [item["amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"]
|
||||
assert "90000000.00000001" in amounts
|
||||
assert "90000000.00000002" in amounts
|
||||
|
||||
|
||||
def test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching() -> None:
|
||||
@@ -891,7 +903,7 @@ def test_unified_ledger_includes_dividend_fields() -> None:
|
||||
item = resp.json()["items"][0]
|
||||
assert item["source"] == "manual"
|
||||
assert item["action"] == "DIVIDEND"
|
||||
assert item["amount"] == 33.0
|
||||
assert item["amount"] == "33"
|
||||
assert item["price"] == 33.0
|
||||
assert item["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert item["tax_amount"] == 3.0
|
||||
|
||||
Reference in New Issue
Block a user