fix: enforce snapshot-consistent precision checks for manual ledger

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-09 03:08:46 +00:00
committed by GitHub
co-authored by genichin
parent aa40c7fdff
commit b74284e626
4 changed files with 147 additions and 63 deletions
+72 -38
View File
@@ -7,21 +7,39 @@ import json
import logging import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any from typing import Any
import psycopg import psycopg
from psycopg import sql from psycopg import sql
from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError from app.domain.errors import (
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User DaemonInactiveError,
DaemonInUseError,
DaemonNotFoundError,
StrategyInactiveError,
StrategyNotFoundError,
)
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_NUMERIC_SCALE = 8
_LEDGER_MAX_INTEGER_DIGITS = 12 _LEDGER_MAX_INTEGER_DIGITS = 12
_LEDGER_QUANT = Decimal("0.00000001") _LEDGER_QUANT = Decimal("0.00000001")
_MAX_SAFE_DOUBLE_FOR_8DP = Decimal("45035996")
_SELECT_USER = """ _SELECT_USER = """
SELECT SELECT
@@ -1523,7 +1541,7 @@ class PostgresPortfolioRepository:
note: str = "", note: str = "",
) -> None: ) -> None:
self.ensure_schema() self.ensure_schema()
trade_time = datetime.now(timezone.utc) trade_time = datetime.now(UTC)
conn = self._connect() conn = self._connect()
with conn.transaction(), conn.cursor() as cur: with conn.transaction(), conn.cursor() as cur:
cur.execute( cur.execute(
@@ -1602,9 +1620,9 @@ class PostgresPortfolioRepository:
self, self,
portfolio_id: str, portfolio_id: str,
*, *,
date: "datetime | None" = None, date: datetime | None = None,
from_: "datetime | None" = None, from_: datetime | None = None,
to_: "datetime | None" = None, to_: datetime | None = None,
) -> list[LedgerEntry]: ) -> list[LedgerEntry]:
"""Return ledger entries for portfolio_id filtered by date range (REQ-039). """Return ledger entries for portfolio_id filtered by date range (REQ-039).
@@ -2051,9 +2069,18 @@ class PostgresPortfolioRepository:
return decimal_value return decimal_value
@staticmethod @staticmethod
def _ensure_safe_double_snapshot_precision(value: Decimal, *, field_name: str) -> None: def _to_snapshot_float_and_ledger_decimal(value: Decimal, *, field_name: str) -> tuple[float, Decimal]:
if value.copy_abs() > _MAX_SAFE_DOUBLE_FOR_8DP: if value.is_nan() or value.is_infinite():
raise ValueError(f"{field_name} exceeds double snapshot precision at 8 decimal places") raise ValueError(f"{field_name} must be a finite decimal")
try:
as_float = float(value)
except (OverflowError, ValueError):
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot") from None
expected_ledger = value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
actual_ledger = Decimal(str(as_float)).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
if actual_ledger != expected_ledger:
raise ValueError(f"{field_name} cannot be represented in DOUBLE PRECISION snapshot")
return as_float, actual_ledger
def record_manual_ledger( def record_manual_ledger(
self, self,
@@ -2063,8 +2090,8 @@ class PostgresPortfolioRepository:
amount: Decimal | None = None, amount: Decimal | None = None,
quantity: Decimal | None = None, quantity: Decimal | None = None,
price: Decimal | None = None, price: Decimal | None = None,
fee: Decimal = Decimal("0"), fee: Decimal = Decimal(0),
tax: Decimal = Decimal("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 = "",
@@ -2093,15 +2120,9 @@ class PostgresPortfolioRepository:
fee_f = self._coerce_ledger_decimal(fee or 0, field_name="fee", allow_zero=True) fee_f = self._coerce_ledger_decimal(fee or 0, field_name="fee", allow_zero=True)
tax_f = self._coerce_ledger_decimal(tax or 0, field_name="tax", allow_zero=True) tax_f = self._coerce_ledger_decimal(tax or 0, field_name="tax", allow_zero=True)
amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True) amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True)
self._ensure_safe_double_snapshot_precision(amount_f, field_name="amount") ledger_executed_at = executed_at or datetime.now(UTC)
self._ensure_safe_double_snapshot_precision(ledger_price, field_name="price")
self._ensure_safe_double_snapshot_precision(ledger_quantity, field_name="quantity")
self._ensure_safe_double_snapshot_precision(fee_f, field_name="fee")
self._ensure_safe_double_snapshot_precision(tax_f, field_name="tax")
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=UTC)
reverse_target: LedgerEntry | None = None
if normalized == "INVEST": if normalized == "INVEST":
buffer += amount_f buffer += amount_f
@@ -2118,12 +2139,7 @@ class PostgresPortfolioRepository:
cash -= amount_f cash -= amount_f
buffer += amount_f buffer += amount_f
ledger_price = amount_f ledger_price = amount_f
elif normalized == "WITHDRAW": elif normalized == "WITHDRAW" or normalized == "PROFIT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
ledger_price = amount_f
elif normalized == "PROFIT":
if buffer < amount_f: if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER") raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f buffer -= amount_f
@@ -2234,7 +2250,25 @@ class PostgresPortfolioRepository:
if buffer < 0: if buffer < 0:
raise ValueError("INSUFFICIENT_BUFFER") raise ValueError("INSUFFICIENT_BUFFER")
total_value = (cash + qty * asset_price + buffer).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP) snapshot_cash_float, snapshot_cash_ledger = self._to_snapshot_float_and_ledger_decimal(
cash,
field_name="cash_balance_after",
)
snapshot_qty_float, snapshot_qty_ledger = self._to_snapshot_float_and_ledger_decimal(
qty,
field_name="asset_quantity_after",
)
snapshot_price_float, _ = self._to_snapshot_float_and_ledger_decimal(
asset_price,
field_name="asset_price",
)
snapshot_buffer_float, snapshot_buffer_ledger = self._to_snapshot_float_and_ledger_decimal(
buffer,
field_name="profit_buffer_after",
)
total_value = (
snapshot_cash_ledger + snapshot_qty_ledger * Decimal(str(snapshot_price_float)) + snapshot_buffer_ledger
).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
cur.execute( cur.execute(
""" """
UPDATE portfolios UPDATE portfolios
@@ -2246,7 +2280,7 @@ class PostgresPortfolioRepository:
last_trade_at = NOW() last_trade_at = NOW()
WHERE portfolio_id = %s WHERE portfolio_id = %s
""", """,
(float(cash), float(qty), float(asset_price), float(buffer), portfolio_id), (snapshot_cash_float, snapshot_qty_float, snapshot_price_float, snapshot_buffer_float, portfolio_id),
) )
cur.execute( cur.execute(
""" """
@@ -2268,10 +2302,10 @@ class PostgresPortfolioRepository:
ledger_quantity, ledger_quantity,
fee_f, fee_f,
tax_f, tax_f,
cash, snapshot_cash_ledger,
qty, snapshot_qty_ledger,
total_value, total_value,
buffer, snapshot_buffer_ledger,
"manual", "manual",
note, note,
ledger_executed_at, ledger_executed_at,
@@ -2711,7 +2745,8 @@ class PostgresPortfolioRepository:
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}") raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix from app.core.security import generate_token, hash_token
from app.core.security import token_prefix as _token_prefix
self.ensure_schema() self.ensure_schema()
with self._connect().cursor() as cur: with self._connect().cursor() as cur:
@@ -3047,10 +3082,9 @@ def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgre
connect_timeout=config.connect_timeout_sec, connect_timeout=config.connect_timeout_sec,
application_name=config.application_name, application_name=config.application_name,
autocommit=True, autocommit=True,
) as conn: ) as conn, conn.cursor() as cur:
with conn.cursor() as cur: cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,)) return cur.fetchone() is not None
return cur.fetchone() is not None
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool: def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
+6 -7
View File
@@ -8,7 +8,6 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET/POST/PATCH /v1/tags (REQ-004) # GET/POST/PATCH /v1/tags (REQ-004)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -114,8 +113,8 @@ class LedgerTradeRequest(BaseModel):
side: Literal["buy", "sell"] side: Literal["buy", "sell"]
quantity: Decimal = Field(..., gt=0) quantity: Decimal = Field(..., gt=0)
price: Decimal = Field(..., gt=0) price: Decimal = Field(..., gt=0)
fee_amount: Decimal = Field(default=Decimal("0"), ge=0) fee_amount: Decimal = Field(default=Decimal(0), ge=0)
tax_amount: Decimal = Field(default=Decimal("0"), ge=0) tax_amount: Decimal = Field(default=Decimal(0), ge=0)
executed_at: datetime executed_at: datetime
@@ -160,8 +159,8 @@ class ManualLedgerRequest(BaseModel):
amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8) amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8) quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8) price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
fee: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8) fee: Decimal = Field(default=Decimal(0), ge=0, max_digits=20, decimal_places=8)
tax: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8) 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 = ""
@@ -170,7 +169,7 @@ class ManualLedgerRequest(BaseModel):
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
@model_validator(mode="after") @model_validator(mode="after")
def validate_action_fields(self) -> "ManualLedgerRequest": def validate_action_fields(self) -> ManualLedgerRequest:
amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}
trade_actions = {"BUY", "SELL"} trade_actions = {"BUY", "SELL"}
provided_fields = self.model_fields_set provided_fields = self.model_fields_set
@@ -418,7 +417,7 @@ class StrategyUpdateRequest(BaseModel):
return normalized return normalized
@model_validator(mode="after") @model_validator(mode="after")
def require_change(self) -> "StrategyUpdateRequest": def require_change(self) -> StrategyUpdateRequest:
if not self.model_fields_set: if not self.model_fields_set:
raise ValueError("at least one field is required") raise ValueError("at least one field is required")
return self return self
@@ -9,7 +9,7 @@ or production application database.
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime, timezone from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from uuid import uuid4 from uuid import uuid4
@@ -106,10 +106,9 @@ def _current_revision(settings: Settings) -> str:
user=settings.active_db_user, user=settings.active_db_user,
password=settings.active_db_password, password=settings.active_db_password,
sslmode=settings.active_db_sslmode, sslmode=settings.active_db_sslmode,
) as conn: ) as conn, conn.cursor() as cur:
with conn.cursor() as cur: cur.execute("SELECT version_num FROM alembic_version")
cur.execute("SELECT version_num FROM alembic_version") return str(cur.fetchone()[0])
return str(cur.fetchone()[0])
def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | None]]: def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | None]]:
@@ -120,18 +119,17 @@ def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | Non
user=settings.active_db_user, user=settings.active_db_user,
password=settings.active_db_password, password=settings.active_db_password,
sslmode=settings.active_db_sslmode, sslmode=settings.active_db_sslmode,
) as conn: ) as conn, conn.cursor() as cur:
with conn.cursor() as cur: cur.execute(
cur.execute( """
"""
SELECT column_name, data_type, is_nullable, column_default SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'portfolio_ledger' WHERE table_schema = 'public' AND table_name = 'portfolio_ledger'
AND column_name IN ('source', 'target_ledger_entry_id', AND column_name IN ('source', 'target_ledger_entry_id',
'confirmation_evidence', 'actor_user_id') 'confirmation_evidence', 'actor_user_id')
""" """
) )
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()} return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str: def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str:
@@ -169,7 +167,7 @@ def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source(
command.upgrade(get_alembic_config(isolated_settings), _PRE_BAT025) command.upgrade(get_alembic_config(isolated_settings), _PRE_BAT025)
repo = _repo(isolated_settings) repo = _repo(isolated_settings)
portfolio_id = _seed_portfolio(repo, "preserve") portfolio_id = _seed_portfolio(repo, "preserve")
recorded_at = datetime(2026, 7, 15, tzinfo=timezone.utc) recorded_at = datetime(2026, 7, 15, tzinfo=UTC)
with repo._connect().cursor() as cur: with repo._connect().cursor() as cur:
cur.execute( cur.execute(
""" """
@@ -249,7 +247,7 @@ def test_broker_and_manual_ledger_writes_regress_after_real_upgrade(
side="buy", side="buy",
quantity=2, quantity=2,
price=100, price=100,
executed_at=datetime.now(timezone.utc), executed_at=datetime.now(UTC),
) )
manual = repo.record_manual_ledger( manual = repo.record_manual_ledger(
portfolio_id, portfolio_id,
@@ -287,7 +285,7 @@ def test_dividend_precision_and_reversal_round_trip_on_real_postgres(
action="DIVIDEND", action="DIVIDEND",
amount=Decimal("0.00000001"), amount=Decimal("0.00000001"),
tax=Decimal("0.00000001"), tax=Decimal("0.00000001"),
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc), executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
note="precision-test", note="precision-test",
) )
@@ -352,7 +350,60 @@ def test_dividend_large_integer_with_8dp_is_rejected_to_protect_double_snapshot(
action="DIVIDEND", action="DIVIDEND",
amount=Decimal("1000000000.00000001"), amount=Decimal("1000000000.00000001"),
tax=Decimal("0.00000001"), tax=Decimal("0.00000001"),
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc), executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
note="too-large-for-double", note="too-large-for-double",
) )
repo.close() repo.close()
def test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence(
isolated_settings: Settings,
) -> None:
"""Reject writes that would diverge between NUMERIC ledger and DOUBLE snapshot."""
upgrade_to_head(isolated_settings)
repo = _repo(isolated_settings)
portfolio_id = _seed_portfolio(repo, "large-buffer-min-dividend")
with repo._connect().cursor() as cur:
cur.execute("UPDATE portfolios SET profit_buffer = %s WHERE portfolio_id = %s", (1_000_000_000.0, portfolio_id))
with pytest.raises(ValueError, match="DOUBLE PRECISION snapshot"):
repo.record_manual_ledger(
portfolio_id,
action="DIVIDEND",
amount=Decimal("0.00000001"),
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
note="should-reject",
)
with repo._connect().cursor() as cur:
cur.execute("SELECT COUNT(*) FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'dividend'", (portfolio_id,))
assert cur.fetchone()[0] == 0
repo.close()
def test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe(
isolated_settings: Settings,
) -> None:
"""No hidden per-field cap for values that do not cause snapshot divergence."""
upgrade_to_head(isolated_settings)
repo = _repo(isolated_settings)
portfolio_id = _seed_portfolio(repo, "large-invest-large-tax")
invest = repo.record_manual_ledger(
portfolio_id,
action="INVEST",
amount=Decimal("50000000.00000000"),
note="large-invest",
)
assert invest.price == 50000000.0
dividend = repo.record_manual_ledger(
portfolio_id,
action="DIVIDEND",
amount=Decimal("1.00000000"),
tax=Decimal("50000000.00000000"),
executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC),
note="large-tax-metadata",
)
assert dividend.tax_amount == 50000000.0
repo.close()
+3 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import patch
@@ -195,7 +195,7 @@ class _FakeRepo:
fee_amount=fee, fee_amount=fee,
tax_amount=tax, tax_amount=tax,
executed_at=executed_at, executed_at=executed_at,
recorded_at=datetime.now(timezone.utc), recorded_at=datetime.now(UTC),
cash_balance_after=self.portfolio.cash_balance, cash_balance_after=self.portfolio.cash_balance,
asset_quantity_after=self.portfolio.asset_quantity, asset_quantity_after=self.portfolio.asset_quantity,
profit_buffer_after=self.portfolio.profit_buffer, profit_buffer_after=self.portfolio.profit_buffer,
@@ -674,7 +674,7 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
repo.entries.append( repo.entries.append(
LedgerEntry( LedgerEntry(
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker", ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
recorded_at=datetime.now(timezone.utc), cash_balance_after=900, asset_quantity_after=12, recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12,
) )
) )
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()) first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())