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
@@ -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,8 +3082,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -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,8 +106,7 @@ 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])
|
||||||
|
|
||||||
@@ -120,8 +119,7 @@ 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
|
||||||
@@ -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,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())
|
||||||
|
|||||||
Reference in New Issue
Block a user