fix: add unified ledger amount and enforce manual decimal precision

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-09 02:23:32 +00:00
committed by GitHub
co-authored by genichin
parent ccde81b7a0
commit a71c91f6ef
8 changed files with 173 additions and 55 deletions
+8 -6
View File
@@ -769,16 +769,18 @@ 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()
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,
source=entry.source or "broker", source=entry.source or "broker",
action=(entry.action or entry.side or "").upper(), action=action,
recorded_at=entry.recorded_at, recorded_at=entry.recorded_at,
executed_at=entry.executed_at, executed_at=entry.executed_at,
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,
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,
@@ -825,11 +827,11 @@ def record_manual_ledger_v1(
entry = repo.record_manual_ledger( entry = repo.record_manual_ledger(
portfolio_id, portfolio_id,
action=body.action, action=body.action,
amount=float(body.amount) if body.amount is not None else None, amount=body.amount,
quantity=float(body.quantity) if body.quantity is not None else None, quantity=body.quantity,
price=float(body.price) if body.price is not None else None, price=body.price,
fee=float(body.fee), fee=body.fee,
tax=float(body.tax), tax=body.tax,
executed_at=( executed_at=(
datetime( datetime(
body.executed_at.year, body.executed_at.year,
+6 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from decimal import Decimal
from typing import Protocol from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
@@ -320,11 +321,11 @@ class PortfolioRepository(Protocol):
portfolio_id: str, portfolio_id: str,
*, *,
action: str, action: str,
amount: float | None = None, amount: Decimal | None = None,
quantity: float | None = None, quantity: Decimal | None = None,
price: float | None = None, price: Decimal | None = None,
fee: float = 0.0, fee: Decimal = Decimal("0"),
tax: float = 0.0, tax: Decimal = Decimal("0"),
executed_at: datetime | None = None, executed_at: datetime | None = None,
target_ledger_entry_id: int | None = None, target_ledger_entry_id: int | None = None,
note: str = "", note: str = "",
+67 -34
View File
@@ -8,6 +8,7 @@ import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any from typing import Any
import psycopg import psycopg
@@ -17,6 +18,9 @@ from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFo
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LEDGER_NUMERIC_SCALE = 8
_LEDGER_MAX_INTEGER_DIGITS = 12
_LEDGER_QUANT = Decimal("0.00000001")
_SELECT_USER = """ _SELECT_USER = """
SELECT SELECT
@@ -2007,16 +2011,44 @@ class PostgresPortfolioRepository:
def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float: def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float:
return float(cash) + float(quantity) * float(price) + float(buffer) return float(cash) + float(quantity) * float(price) + float(buffer)
@staticmethod
def _validate_ledger_decimal(value: Decimal, *, field_name: str, allow_zero: bool) -> Decimal:
if value.is_nan() or value.is_infinite():
raise ValueError(f"{field_name} must be a finite decimal")
if allow_zero:
if value < 0:
raise ValueError(f"{field_name} must be greater than or equal to 0")
elif value <= 0:
raise ValueError(f"{field_name} must be greater than 0")
scale = max(-value.as_tuple().exponent, 0)
if scale > _LEDGER_NUMERIC_SCALE:
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
digits = len(value.copy_abs().as_tuple().digits)
integer_digits = max(digits - scale, 0)
if value == 0:
integer_digits = 0
if integer_digits > _LEDGER_MAX_INTEGER_DIGITS:
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
return value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
@classmethod
def _coerce_ledger_decimal(cls, value: Any, *, field_name: str, allow_zero: bool) -> Decimal:
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
raise ValueError(f"{field_name} must be a decimal") from None
return cls._validate_ledger_decimal(decimal_value, field_name=field_name, allow_zero=allow_zero)
def record_manual_ledger( def record_manual_ledger(
self, self,
portfolio_id: str, portfolio_id: str,
*, *,
action: str, action: str,
amount: float | None = None, amount: Decimal | None = None,
quantity: float | None = None, quantity: Decimal | None = None,
price: float | None = None, price: Decimal | None = None,
fee: float = 0.0, fee: Decimal = Decimal("0"),
tax: float = 0.0, tax: Decimal = Decimal("0"),
executed_at: datetime | None = None, executed_at: datetime | None = None,
target_ledger_entry_id: int | None = None, target_ledger_entry_id: int | None = None,
note: str = "", note: str = "",
@@ -2036,15 +2068,15 @@ class PostgresPortfolioRepository:
if row is None: if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다") raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row) portfolio = self._row_to_portfolio(row)
cash = float(portfolio.cash_balance) cash = self._coerce_ledger_decimal(portfolio.cash_balance, field_name="cash_balance", allow_zero=True)
qty = float(portfolio.asset_quantity) qty = self._coerce_ledger_decimal(portfolio.asset_quantity, field_name="asset_quantity", allow_zero=True)
buffer = float(portfolio.profit_buffer) buffer = self._coerce_ledger_decimal(portfolio.profit_buffer, field_name="profit_buffer", allow_zero=True)
asset_price = float(portfolio.price) asset_price = self._coerce_ledger_decimal(portfolio.price, field_name="asset_price", allow_zero=True)
ledger_price = float(price or 0.0) ledger_price = self._coerce_ledger_decimal(price or 0, field_name="price", allow_zero=True)
ledger_quantity = float(quantity or 0.0) ledger_quantity = self._coerce_ledger_decimal(quantity or 0, field_name="quantity", allow_zero=True)
fee_f = float(fee or 0.0) fee_f = self._coerce_ledger_decimal(fee or 0, field_name="fee", allow_zero=True)
tax_f = float(tax or 0.0) tax_f = self._coerce_ledger_decimal(tax or 0, field_name="tax", allow_zero=True)
amount_f = float(amount or 0.0) amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True)
ledger_executed_at = executed_at or datetime.now(timezone.utc) ledger_executed_at = executed_at or datetime.now(timezone.utc)
if ledger_executed_at.tzinfo is None: if ledger_executed_at.tzinfo is None:
ledger_executed_at = ledger_executed_at.replace(tzinfo=timezone.utc) ledger_executed_at = ledger_executed_at.replace(tzinfo=timezone.utc)
@@ -2105,7 +2137,7 @@ class PostgresPortfolioRepository:
buffer += amount_f buffer += amount_f
ledger_price = amount_f ledger_price = amount_f
elif normalized == "BUY": elif normalized == "BUY":
required = ledger_quantity * ledger_price + fee_f + tax_f required = (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if cash < required: if cash < required:
raise ValueError("INSUFFICIENT_CASH") raise ValueError("INSUFFICIENT_CASH")
cash -= required cash -= required
@@ -2114,7 +2146,7 @@ class PostgresPortfolioRepository:
elif normalized == "SELL": elif normalized == "SELL":
if qty < ledger_quantity: if qty < ledger_quantity:
raise ValueError("INSUFFICIENT_QUANTITY") raise ValueError("INSUFFICIENT_QUANTITY")
cash += ledger_quantity * ledger_price - fee_f - tax_f cash += (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
qty -= ledger_quantity qty -= ledger_quantity
asset_price = ledger_price asset_price = ledger_price
elif normalized == "REVERSAL": elif normalized == "REVERSAL":
@@ -2139,18 +2171,19 @@ class PostgresPortfolioRepository:
if cur.fetchone() is not None: if cur.fetchone() is not None:
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED") raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
target_action = (reverse_target.action or "").upper() target_action = (reverse_target.action or "").upper()
amount_f = float(reverse_target.price) amount_f = self._coerce_ledger_decimal(reverse_target.price, field_name="amount", allow_zero=True)
ledger_price = float(reverse_target.price) ledger_price = self._coerce_ledger_decimal(reverse_target.price, field_name="price", allow_zero=True)
ledger_quantity = float(reverse_target.quantity) ledger_quantity = self._coerce_ledger_decimal(reverse_target.quantity, field_name="quantity", allow_zero=True)
fee_f = float(reverse_target.fee_amount) fee_f = self._coerce_ledger_decimal(reverse_target.fee_amount, field_name="fee", allow_zero=True)
tax_f = float(reverse_target.tax_amount) tax_f = self._coerce_ledger_decimal(reverse_target.tax_amount, field_name="tax", allow_zero=True)
if target_action == "BUY": if target_action == "BUY":
cash += ledger_quantity * ledger_price + fee_f + tax_f cash += (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
qty -= ledger_quantity qty -= ledger_quantity
elif target_action == "SELL": elif target_action == "SELL":
if cash < ledger_quantity * ledger_price - fee_f - tax_f: required_cash = (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if cash < required_cash:
raise ValueError("INSUFFICIENT_CASH") raise ValueError("INSUFFICIENT_CASH")
cash -= ledger_quantity * ledger_price - fee_f - tax_f cash -= required_cash
qty += ledger_quantity qty += ledger_quantity
elif target_action == "BUFFER_OUT": elif target_action == "BUFFER_OUT":
if cash < amount_f: if cash < amount_f:
@@ -2178,7 +2211,7 @@ class PostgresPortfolioRepository:
if buffer < 0: if buffer < 0:
raise ValueError("INSUFFICIENT_BUFFER") raise ValueError("INSUFFICIENT_BUFFER")
total_value = self._manual_total_value(cash, qty, asset_price, buffer) total_value = (cash + qty * asset_price + buffer).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
cur.execute( cur.execute(
""" """
UPDATE portfolios UPDATE portfolios
@@ -2190,7 +2223,7 @@ class PostgresPortfolioRepository:
last_trade_at = NOW() last_trade_at = NOW()
WHERE portfolio_id = %s WHERE portfolio_id = %s
""", """,
(cash, qty, asset_price, buffer, portfolio_id), (float(cash), float(qty), float(asset_price), float(buffer), portfolio_id),
) )
cur.execute( cur.execute(
""" """
@@ -2208,14 +2241,14 @@ class PostgresPortfolioRepository:
( (
portfolio_id, portfolio_id,
normalized.lower(), normalized.lower(),
float(ledger_price), ledger_price,
float(ledger_quantity), ledger_quantity,
float(fee_f), fee_f,
float(tax_f), tax_f,
float(cash), cash,
float(qty), qty,
float(total_value), total_value,
float(buffer), buffer,
"manual", "manual",
note, note,
ledger_executed_at, ledger_executed_at,
+7 -6
View File
@@ -157,11 +157,11 @@ class ManualLedgerRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
action: ManualLedgerAction action: ManualLedgerAction
amount: Decimal | None = Field(default=None, gt=0) amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
quantity: Decimal | None = Field(default=None, gt=0) quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
price: Decimal | None = Field(default=None, gt=0) price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
fee: Decimal = Field(default=Decimal("0"), ge=0) fee: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8)
tax: Decimal = Field(default=Decimal("0"), ge=0) tax: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8)
executed_at: date | None = None executed_at: date | None = None
target_ledger_entry_id: int | None = None target_ledger_entry_id: int | None = None
note: str = "" note: str = ""
@@ -177,7 +177,7 @@ class ManualLedgerRequest(BaseModel):
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
fee_provided = "fee" in provided_fields fee_provided = "fee" in provided_fields
executed_at_provided = "executed_at" in provided_fields executed_at_provided = "executed_at" in provided_fields
confirm_duplicate_provided = "confirm_duplicate" in provided_fields and self.confirm_duplicate confirm_duplicate_provided = "confirm_duplicate" in provided_fields
if self.action in amount_actions: if self.action in amount_actions:
if self.amount is None: if self.amount is None:
raise ValueError("amount is required for this action") raise ValueError("amount is required for this action")
@@ -255,6 +255,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
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
+3
View File
@@ -341,6 +341,7 @@
- BUFFER_IN: cash_balance -> profit_buffer. - BUFFER_IN: cash_balance -> profit_buffer.
- BUY: cash_balance -> asset_quantity. - BUY: cash_balance -> asset_quantity.
- SELL: asset_quantity -> cash_balance. - SELL: asset_quantity -> cash_balance.
- DIVIDEND: external net cash dividend -> profit_buffer only; `tax_amount` is reference metadata and does not affect snapshot math.
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`. - REVERSAL: append-only inverse effect of `target_ledger_entry_id`.
### Return Aggregate Semantics ### Return Aggregate Semantics
@@ -356,6 +357,8 @@
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates. - Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates.
- Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state. - Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state.
- DIVIDEND duplicate candidate (`portfolio_id`, `executed_at` UTC date, decimal `amount`, `note`) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` unless request confirms with `confirm_duplicate=true`.
- DIVIDEND uses date-only input stored as `executed_at` UTC midnight (`00:00:00Z`).
- `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer. - `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer.
## Shared Consistency Rules ## Shared Consistency Rules
+5 -4
View File
@@ -280,11 +280,12 @@
3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`. 3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`.
4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker. 4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker.
5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot. 5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot.
6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code. 6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. DIVIDEND requires `amount` and date-only `executed_at`, stores `executed_at` at UTC midnight, and increases only `profit_buffer` (`tax` metadata only). Insufficient state returns HTTP 409 with structured code.
7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target. 7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target.
8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`. 8. Duplicate DIVIDEND candidate (same portfolio/date/amount/note and original not reversed) returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with matched entry id; `confirm_duplicate=true` re-submit records it.
9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`). 9. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`.
10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately. 10. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`).
11. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately.
## Flow: `v1 API — POST /auth/service-tokens` ## Flow: `v1 API — POST /auth/service-tokens`
@@ -10,6 +10,7 @@ from __future__ import annotations
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from uuid import uuid4 from uuid import uuid4
@@ -271,3 +272,43 @@ def test_broker_and_manual_ledger_writes_regress_after_real_upgrade(
) )
assert cur.fetchone() == ("manual", "bat025-user", "synthetic-local-fixture") assert cur.fetchone() == ("manual", "bat025-user", "synthetic-local-fixture")
repo.close() repo.close()
def test_dividend_precision_and_reversal_round_trip_on_real_postgres(
isolated_settings: Settings,
) -> None:
"""DIVIDEND min unit persists and REVERSAL removes the same amount exactly."""
upgrade_to_head(isolated_settings)
repo = _repo(isolated_settings)
portfolio_id = _seed_portfolio(repo, "dividend-precision")
dividend = repo.record_manual_ledger(
portfolio_id,
action="DIVIDEND",
amount=Decimal("0.00000001"),
tax=Decimal("0.00000001"),
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc),
note="precision-test",
)
assert dividend.price == 0.00000001
assert dividend.tax_amount == 0.00000001
assert dividend.profit_buffer_after == 0.00000001
with repo._connect().cursor() as cur:
cur.execute(
"SELECT price::text, tax_amount::text, profit_buffer_after::text FROM portfolio_ledger WHERE ledger_entry_id = %s",
(dividend.ledger_entry_id,),
)
assert cur.fetchone() == ("0.00000001", "0.00000001", "0.00000001")
reversal = repo.record_manual_ledger(
portfolio_id,
action="REVERSAL",
target_ledger_entry_id=dividend.ledger_entry_id,
)
assert reversal.profit_buffer_after == 0.0
with repo._connect().cursor() as cur:
cur.execute("SELECT profit_buffer::text FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
assert cur.fetchone()[0] == "0"
repo.close()
+36
View File
@@ -500,6 +500,30 @@ def test_dividend_requires_executed_at_and_rejects_fee_field() -> None:
assert invalid_fee.status_code == 422 assert invalid_fee.status_code == 422
def test_dividend_rejects_sub_minimum_numeric_precision() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
too_precise = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": "0.000000004", "executed_at": "2026-09-01"},
headers=_auth(),
)
assert too_precise.status_code == 422
def test_dividend_rejects_amount_exceeding_numeric_20_8() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
too_large = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
json={"action": "DIVIDEND", "amount": "1000000000000.00000000", "executed_at": "2026-09-01"},
headers=_auth(),
)
assert too_large.status_code == 422
def test_dividend_duplicate_requires_confirmation() -> None: def test_dividend_duplicate_requires_confirmation() -> None:
repo = _FakeRepo(_portfolio()) repo = _FakeRepo(_portfolio())
client = _make_client(repo) client = _make_client(repo)
@@ -571,12 +595,24 @@ 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["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
assert item["note"] == "Q3" assert item["note"] == "Q3"
assert item["profit_buffer_after"] == created.json()["profit_buffer_after"] assert item["profit_buffer_after"] == created.json()["profit_buffer_after"]
def test_unified_ledger_openapi_contains_nullable_amount() -> None:
repo = _FakeRepo(_portfolio())
client = _make_client(repo)
response = client.get("/openapi.json")
assert response.status_code == 200
schemas = response.json()["components"]["schemas"]
assert "amount" in schemas["LedgerHistoryItem"]["properties"]
def test_reversal_rejects_fee_tax_and_missing_target() -> None: def test_reversal_rejects_fee_tax_and_missing_target() -> None:
repo = _FakeRepo(_portfolio()) repo = _FakeRepo(_portfolio())
client = _make_client(repo) client = _make_client(repo)