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:
copilot-swe-agent[bot]
2026-09-09 12:41:36 +00:00
committed by GitHub
co-authored by genichin
parent 7fc3386f3a
commit fde5b33f05
5 changed files with 32 additions and 8 deletions
+10 -2
View File
@@ -16,6 +16,7 @@ import json
import logging import logging
import uuid import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status 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: def _manual_entry_to_response(entry) -> ManualLedgerResponse:
action = (entry.action or entry.side or "").upper() 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( return ManualLedgerResponse(
ledger_entry_id=entry.ledger_entry_id, ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_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: def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
action = (entry.action or entry.side or "").upper() 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( return LedgerHistoryItem(
ledger_entry_id=entry.ledger_entry_id, ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_id, portfolio_id=entry.portfolio_id,
@@ -796,7 +804,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
order_id=entry.order_id, order_id=entry.order_id,
execution_id=entry.execution_id, execution_id=entry.execution_id,
quantity=entry.quantity, quantity=entry.quantity,
amount=entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None, amount=amount,
price=entry.price, price=entry.price,
fee_amount=entry.fee_amount, fee_amount=entry.fee_amount,
tax_amount=entry.tax_amount, tax_amount=entry.tax_amount,
+2 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from decimal import Decimal
from enum import Enum from enum import Enum
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
@@ -136,6 +137,7 @@ class LedgerEntry:
portfolio_id: str portfolio_id: str
quantity: float quantity: float
price: float price: float
price_decimal: Decimal | None = None
ledger_entry_id: int = 0 ledger_entry_id: int = 0
order_id: str | None = None order_id: str | None = None
execution_id: str | None = None execution_id: str | None = None
@@ -195,4 +197,3 @@ class BrokerCredential:
credential_version: int = 1 credential_version: int = 1
last_rotated_at: datetime | None = None last_rotated_at: datetime | None = None
daemon_id: str | None = None daemon_id: str | None = None
+4 -1
View File
@@ -711,6 +711,7 @@ class PostgresPortfolioRepository:
side=row[2], side=row[2],
quantity=float(row[4]), quantity=float(row[4]),
price=float(row[3]), price=float(row[3]),
price_decimal=Decimal(str(row[3])) if row[3] is not None else None,
fee_amount=float(row[5]), fee_amount=float(row[5]),
tax_amount=float(row[6]), tax_amount=float(row[6]),
executed_at=row[1], executed_at=row[1],
@@ -1864,6 +1865,7 @@ class PostgresPortfolioRepository:
def _row_to_ledger_entry(row: Any) -> LedgerEntry: def _row_to_ledger_entry(row: Any) -> LedgerEntry:
if row is None: if row is None:
raise ValueError("원장 항목을 찾을 수 없습니다") raise ValueError("원장 항목을 찾을 수 없습니다")
raw_price = row[6]
return LedgerEntry( return LedgerEntry(
ledger_entry_id=int(row[0]), ledger_entry_id=int(row[0]),
portfolio_id=row[1], portfolio_id=row[1],
@@ -1871,7 +1873,8 @@ class PostgresPortfolioRepository:
execution_id=row[3], execution_id=row[3],
side=row[4], side=row[4],
quantity=float(row[5]), 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]), fee_amount=float(row[7]),
tax_amount=float(row[8]), tax_amount=float(row[8]),
executed_at=row[9], executed_at=row[9],
+2 -2
View File
@@ -220,7 +220,7 @@ class ManualLedgerResponse(BaseModel):
ledger_entry_id: int ledger_entry_id: int
portfolio_id: str portfolio_id: str
action: str action: str
amount: float | None = None amount: Decimal | None = None
quantity: float = 0.0 quantity: float = 0.0
price: float = 0.0 price: float = 0.0
fee: float = 0.0 fee: float = 0.0
@@ -254,7 +254,7 @@ class LedgerHistoryItem(BaseModel):
order_id: str | None = None order_id: str | None = None
execution_id: str | None = None execution_id: str | None = None
quantity: float | None = None quantity: float | None = None
amount: float | None = None amount: Decimal | None = None
price: float | None = None price: float | None = None
fee_amount: float | None = None fee_amount: float | None = None
tax_amount: float | None = None tax_amount: float | None = None
+14 -2
View File
@@ -199,6 +199,7 @@ class _FakeRepo:
side=action.lower(), side=action.lower(),
quantity=quantity, quantity=quantity,
price=price, price=price,
price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)),
fee_amount=fee, fee_amount=fee,
tax_amount=tax, tax_amount=tax,
executed_at=executed_at, 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 assert resp.status_code == 201
body = resp.json() body = resp.json()
assert body["action"] == "DIVIDEND" assert body["action"] == "DIVIDEND"
assert body["amount"] == 120.0 assert body["amount"] == "120"
assert body["tax"] == 10.0 assert body["tax"] == 10.0
assert body["executed_at"] == "2026-09-01T00:00:00Z" assert body["executed_at"] == "2026-09-01T00:00:00Z"
assert body["cash_balance_after"] == 1000.0 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 = [ invalid_payloads = [
{"action": "DIVIDEND", "amount": 1},
{"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"}, {"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-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": -1, "executed_at": "2026-09-01"},
{"action": "DIVIDEND", "amount": 1, "tax": "0.000000001", "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, "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", "quantity": 1},
{"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "price": 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}, {"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(), headers=_auth(),
) )
assert second.status_code == 201 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: 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] item = resp.json()["items"][0]
assert item["source"] == "manual" assert item["source"] == "manual"
assert item["action"] == "DIVIDEND" assert item["action"] == "DIVIDEND"
assert item["amount"] == 33.0 assert item["amount"] == "33"
assert item["price"] == 33.0 assert item["price"] == 33.0
assert item["executed_at"] == "2026-09-01T00:00:00Z" assert item["executed_at"] == "2026-09-01T00:00:00Z"
assert item["tax_amount"] == 3.0 assert item["tax_amount"] == 3.0