fix: enforce snapshot-consistent precision checks for manual ledger
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
aa40c7fdff
commit
b74284e626
@@ -9,7 +9,7 @@ or production application database.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import uuid4
|
||||
@@ -106,10 +106,9 @@ def _current_revision(settings: Settings) -> str:
|
||||
user=settings.active_db_user,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT version_num FROM alembic_version")
|
||||
return str(cur.fetchone()[0])
|
||||
) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT version_num FROM alembic_version")
|
||||
return str(cur.fetchone()[0])
|
||||
|
||||
|
||||
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,
|
||||
password=settings.active_db_password,
|
||||
sslmode=settings.active_db_sslmode,
|
||||
) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'portfolio_ledger'
|
||||
AND column_name IN ('source', 'target_ledger_entry_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:
|
||||
@@ -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)
|
||||
repo = _repo(isolated_settings)
|
||||
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:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -249,7 +247,7 @@ def test_broker_and_manual_ledger_writes_regress_after_real_upgrade(
|
||||
side="buy",
|
||||
quantity=2,
|
||||
price=100,
|
||||
executed_at=datetime.now(timezone.utc),
|
||||
executed_at=datetime.now(UTC),
|
||||
)
|
||||
manual = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
@@ -287,7 +285,7 @@ def test_dividend_precision_and_reversal_round_trip_on_real_postgres(
|
||||
action="DIVIDEND",
|
||||
amount=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",
|
||||
)
|
||||
|
||||
@@ -352,7 +350,60 @@ def test_dividend_large_integer_with_8dp_is_rejected_to_protect_double_snapshot(
|
||||
action="DIVIDEND",
|
||||
amount=Decimal("1000000000.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",
|
||||
)
|
||||
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,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -195,7 +195,7 @@ class _FakeRepo:
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
executed_at=executed_at,
|
||||
recorded_at=datetime.now(timezone.utc),
|
||||
recorded_at=datetime.now(UTC),
|
||||
cash_balance_after=self.portfolio.cash_balance,
|
||||
asset_quantity_after=self.portfolio.asset_quantity,
|
||||
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(
|
||||
LedgerEntry(
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user