diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 5d5fe9d..bcaedf8 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -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, diff --git a/app/domain/models.py b/app/domain/models.py index 35dc03c..a388e93 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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 - diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 613e177..a2a129a 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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], diff --git a/app/schemas/v1.py b/app/schemas/v1.py index a6c3a2f..daa7bfc 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index dc302d0..344b562 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -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