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 uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
import psycopg
from psycopg import sql
from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
from app.domain.errors import (
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__)
_LEDGER_NUMERIC_SCALE = 8
_LEDGER_MAX_INTEGER_DIGITS = 12
_LEDGER_QUANT = Decimal("0.00000001")
_MAX_SAFE_DOUBLE_FOR_8DP = Decimal("45035996")
_SELECT_USER = """
SELECT
@@ -1523,7 +1541,7 @@ class PostgresPortfolioRepository:
note: str = "",
) -> None:
self.ensure_schema()
trade_time = datetime.now(timezone.utc)
trade_time = datetime.now(UTC)
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
cur.execute(
@@ -1602,9 +1620,9 @@ class PostgresPortfolioRepository:
self,
portfolio_id: str,
*,
date: "datetime | None" = None,
from_: "datetime | None" = None,
to_: "datetime | None" = None,
date: datetime | None = None,
from_: datetime | None = None,
to_: datetime | None = None,
) -> list[LedgerEntry]:
"""Return ledger entries for portfolio_id filtered by date range (REQ-039).
@@ -2051,9 +2069,18 @@ class PostgresPortfolioRepository:
return decimal_value
@staticmethod
def _ensure_safe_double_snapshot_precision(value: Decimal, *, field_name: str) -> None:
if value.copy_abs() > _MAX_SAFE_DOUBLE_FOR_8DP:
raise ValueError(f"{field_name} exceeds double snapshot precision at 8 decimal places")
def _to_snapshot_float_and_ledger_decimal(value: Decimal, *, field_name: str) -> tuple[float, Decimal]:
if value.is_nan() or value.is_infinite():
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(
self,
@@ -2063,8 +2090,8 @@ class PostgresPortfolioRepository:
amount: Decimal | None = None,
quantity: Decimal | None = None,
price: Decimal | None = None,
fee: Decimal = Decimal("0"),
tax: Decimal = Decimal("0"),
fee: Decimal = Decimal(0),
tax: Decimal = Decimal(0),
executed_at: datetime | None = None,
target_ledger_entry_id: int | None = None,
note: str = "",
@@ -2093,15 +2120,9 @@ class PostgresPortfolioRepository:
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)
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")
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)
ledger_executed_at = executed_at or datetime.now(UTC)
if ledger_executed_at.tzinfo is None:
ledger_executed_at = ledger_executed_at.replace(tzinfo=timezone.utc)
reverse_target: LedgerEntry | None = None
ledger_executed_at = ledger_executed_at.replace(tzinfo=UTC)
if normalized == "INVEST":
buffer += amount_f
@@ -2118,12 +2139,7 @@ class PostgresPortfolioRepository:
cash -= amount_f
buffer += amount_f
ledger_price = amount_f
elif normalized == "WITHDRAW":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
ledger_price = amount_f
elif normalized == "PROFIT":
elif normalized == "WITHDRAW" or normalized == "PROFIT":
if buffer < amount_f:
raise ValueError("INSUFFICIENT_BUFFER")
buffer -= amount_f
@@ -2234,7 +2250,25 @@ class PostgresPortfolioRepository:
if buffer < 0:
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(
"""
UPDATE portfolios
@@ -2246,7 +2280,7 @@ class PostgresPortfolioRepository:
last_trade_at = NOW()
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(
"""
@@ -2268,10 +2302,10 @@ class PostgresPortfolioRepository:
ledger_quantity,
fee_f,
tax_f,
cash,
qty,
snapshot_cash_ledger,
snapshot_qty_ledger,
total_value,
buffer,
snapshot_buffer_ledger,
"manual",
note,
ledger_executed_at,
@@ -2711,7 +2745,8 @@ class PostgresPortfolioRepository:
raise ValueError(f"asset을 찾을 수 없습니다: {asset_id}")
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()
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,
application_name=config.application_name,
autocommit=True,
) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
return cur.fetchone() is not None
) as conn, conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
return cur.fetchone() is not None
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool: