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 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:
|
||||
|
||||
Reference in New Issue
Block a user