fix: add unified ledger amount and enforce manual decimal precision
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
ccde81b7a0
commit
a71c91f6ef
@@ -769,16 +769,18 @@ def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None)
|
||||
|
||||
|
||||
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
return LedgerHistoryItem(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
source=entry.source or "broker",
|
||||
action=(entry.action or entry.side or "").upper(),
|
||||
action=action,
|
||||
recorded_at=entry.recorded_at,
|
||||
executed_at=entry.executed_at,
|
||||
order_id=entry.order_id,
|
||||
execution_id=entry.execution_id,
|
||||
quantity=entry.quantity,
|
||||
amount=entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None,
|
||||
price=entry.price,
|
||||
fee_amount=entry.fee_amount,
|
||||
tax_amount=entry.tax_amount,
|
||||
@@ -825,11 +827,11 @@ def record_manual_ledger_v1(
|
||||
entry = repo.record_manual_ledger(
|
||||
portfolio_id,
|
||||
action=body.action,
|
||||
amount=float(body.amount) if body.amount is not None else None,
|
||||
quantity=float(body.quantity) if body.quantity is not None else None,
|
||||
price=float(body.price) if body.price is not None else None,
|
||||
fee=float(body.fee),
|
||||
tax=float(body.tax),
|
||||
amount=body.amount,
|
||||
quantity=body.quantity,
|
||||
price=body.price,
|
||||
fee=body.fee,
|
||||
tax=body.tax,
|
||||
executed_at=(
|
||||
datetime(
|
||||
body.executed_at.year,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
|
||||
@@ -320,11 +321,11 @@ class PortfolioRepository(Protocol):
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: str,
|
||||
amount: float | None = None,
|
||||
quantity: float | None = None,
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
amount: Decimal | None = None,
|
||||
quantity: Decimal | None = None,
|
||||
price: Decimal | None = None,
|
||||
fee: Decimal = Decimal("0"),
|
||||
tax: Decimal = Decimal("0"),
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
@@ -17,6 +18,9 @@ from app.domain.errors import DaemonInactiveError, DaemonInUseError, DaemonNotFo
|
||||
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")
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -2007,16 +2011,44 @@ class PostgresPortfolioRepository:
|
||||
def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float:
|
||||
return float(cash) + float(quantity) * float(price) + float(buffer)
|
||||
|
||||
@staticmethod
|
||||
def _validate_ledger_decimal(value: Decimal, *, field_name: str, allow_zero: bool) -> Decimal:
|
||||
if value.is_nan() or value.is_infinite():
|
||||
raise ValueError(f"{field_name} must be a finite decimal")
|
||||
if allow_zero:
|
||||
if value < 0:
|
||||
raise ValueError(f"{field_name} must be greater than or equal to 0")
|
||||
elif value <= 0:
|
||||
raise ValueError(f"{field_name} must be greater than 0")
|
||||
scale = max(-value.as_tuple().exponent, 0)
|
||||
if scale > _LEDGER_NUMERIC_SCALE:
|
||||
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
|
||||
digits = len(value.copy_abs().as_tuple().digits)
|
||||
integer_digits = max(digits - scale, 0)
|
||||
if value == 0:
|
||||
integer_digits = 0
|
||||
if integer_digits > _LEDGER_MAX_INTEGER_DIGITS:
|
||||
raise ValueError(f"{field_name} exceeds NUMERIC(20,8) precision")
|
||||
return value.quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
@classmethod
|
||||
def _coerce_ledger_decimal(cls, value: Any, *, field_name: str, allow_zero: bool) -> Decimal:
|
||||
try:
|
||||
decimal_value = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
raise ValueError(f"{field_name} must be a decimal") from None
|
||||
return cls._validate_ledger_decimal(decimal_value, field_name=field_name, allow_zero=allow_zero)
|
||||
|
||||
def record_manual_ledger(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
action: str,
|
||||
amount: float | None = None,
|
||||
quantity: float | None = None,
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
amount: Decimal | None = None,
|
||||
quantity: Decimal | None = None,
|
||||
price: Decimal | None = None,
|
||||
fee: Decimal = Decimal("0"),
|
||||
tax: Decimal = Decimal("0"),
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
@@ -2036,15 +2068,15 @@ class PostgresPortfolioRepository:
|
||||
if row is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
cash = float(portfolio.cash_balance)
|
||||
qty = float(portfolio.asset_quantity)
|
||||
buffer = float(portfolio.profit_buffer)
|
||||
asset_price = float(portfolio.price)
|
||||
ledger_price = float(price or 0.0)
|
||||
ledger_quantity = float(quantity or 0.0)
|
||||
fee_f = float(fee or 0.0)
|
||||
tax_f = float(tax or 0.0)
|
||||
amount_f = float(amount or 0.0)
|
||||
cash = self._coerce_ledger_decimal(portfolio.cash_balance, field_name="cash_balance", allow_zero=True)
|
||||
qty = self._coerce_ledger_decimal(portfolio.asset_quantity, field_name="asset_quantity", allow_zero=True)
|
||||
buffer = self._coerce_ledger_decimal(portfolio.profit_buffer, field_name="profit_buffer", allow_zero=True)
|
||||
asset_price = self._coerce_ledger_decimal(portfolio.price, field_name="asset_price", allow_zero=True)
|
||||
ledger_price = self._coerce_ledger_decimal(price or 0, field_name="price", allow_zero=True)
|
||||
ledger_quantity = self._coerce_ledger_decimal(quantity or 0, field_name="quantity", 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)
|
||||
amount_f = self._coerce_ledger_decimal(amount or 0, field_name="amount", allow_zero=True)
|
||||
ledger_executed_at = executed_at or datetime.now(timezone.utc)
|
||||
if ledger_executed_at.tzinfo is None:
|
||||
ledger_executed_at = ledger_executed_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2105,7 +2137,7 @@ class PostgresPortfolioRepository:
|
||||
buffer += amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "BUY":
|
||||
required = ledger_quantity * ledger_price + fee_f + tax_f
|
||||
required = (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
if cash < required:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
cash -= required
|
||||
@@ -2114,7 +2146,7 @@ class PostgresPortfolioRepository:
|
||||
elif normalized == "SELL":
|
||||
if qty < ledger_quantity:
|
||||
raise ValueError("INSUFFICIENT_QUANTITY")
|
||||
cash += ledger_quantity * ledger_price - fee_f - tax_f
|
||||
cash += (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
qty -= ledger_quantity
|
||||
asset_price = ledger_price
|
||||
elif normalized == "REVERSAL":
|
||||
@@ -2139,18 +2171,19 @@ class PostgresPortfolioRepository:
|
||||
if cur.fetchone() is not None:
|
||||
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
|
||||
target_action = (reverse_target.action or "").upper()
|
||||
amount_f = float(reverse_target.price)
|
||||
ledger_price = float(reverse_target.price)
|
||||
ledger_quantity = float(reverse_target.quantity)
|
||||
fee_f = float(reverse_target.fee_amount)
|
||||
tax_f = float(reverse_target.tax_amount)
|
||||
amount_f = self._coerce_ledger_decimal(reverse_target.price, field_name="amount", allow_zero=True)
|
||||
ledger_price = self._coerce_ledger_decimal(reverse_target.price, field_name="price", allow_zero=True)
|
||||
ledger_quantity = self._coerce_ledger_decimal(reverse_target.quantity, field_name="quantity", allow_zero=True)
|
||||
fee_f = self._coerce_ledger_decimal(reverse_target.fee_amount, field_name="fee", allow_zero=True)
|
||||
tax_f = self._coerce_ledger_decimal(reverse_target.tax_amount, field_name="tax", allow_zero=True)
|
||||
if target_action == "BUY":
|
||||
cash += ledger_quantity * ledger_price + fee_f + tax_f
|
||||
cash += (ledger_quantity * ledger_price + fee_f + tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
qty -= ledger_quantity
|
||||
elif target_action == "SELL":
|
||||
if cash < ledger_quantity * ledger_price - fee_f - tax_f:
|
||||
required_cash = (ledger_quantity * ledger_price - fee_f - tax_f).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
if cash < required_cash:
|
||||
raise ValueError("INSUFFICIENT_CASH")
|
||||
cash -= ledger_quantity * ledger_price - fee_f - tax_f
|
||||
cash -= required_cash
|
||||
qty += ledger_quantity
|
||||
elif target_action == "BUFFER_OUT":
|
||||
if cash < amount_f:
|
||||
@@ -2178,7 +2211,7 @@ class PostgresPortfolioRepository:
|
||||
if buffer < 0:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
|
||||
total_value = self._manual_total_value(cash, qty, asset_price, buffer)
|
||||
total_value = (cash + qty * asset_price + buffer).quantize(_LEDGER_QUANT, rounding=ROUND_HALF_UP)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
@@ -2190,7 +2223,7 @@ class PostgresPortfolioRepository:
|
||||
last_trade_at = NOW()
|
||||
WHERE portfolio_id = %s
|
||||
""",
|
||||
(cash, qty, asset_price, buffer, portfolio_id),
|
||||
(float(cash), float(qty), float(asset_price), float(buffer), portfolio_id),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -2208,14 +2241,14 @@ class PostgresPortfolioRepository:
|
||||
(
|
||||
portfolio_id,
|
||||
normalized.lower(),
|
||||
float(ledger_price),
|
||||
float(ledger_quantity),
|
||||
float(fee_f),
|
||||
float(tax_f),
|
||||
float(cash),
|
||||
float(qty),
|
||||
float(total_value),
|
||||
float(buffer),
|
||||
ledger_price,
|
||||
ledger_quantity,
|
||||
fee_f,
|
||||
tax_f,
|
||||
cash,
|
||||
qty,
|
||||
total_value,
|
||||
buffer,
|
||||
"manual",
|
||||
note,
|
||||
ledger_executed_at,
|
||||
|
||||
+7
-6
@@ -157,11 +157,11 @@ class ManualLedgerRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: ManualLedgerAction
|
||||
amount: Decimal | None = Field(default=None, gt=0)
|
||||
quantity: Decimal | None = Field(default=None, gt=0)
|
||||
price: Decimal | None = Field(default=None, gt=0)
|
||||
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
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)
|
||||
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)
|
||||
tax: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8)
|
||||
executed_at: date | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
note: str = ""
|
||||
@@ -177,7 +177,7 @@ class ManualLedgerRequest(BaseModel):
|
||||
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
|
||||
fee_provided = "fee" in provided_fields
|
||||
executed_at_provided = "executed_at" in provided_fields
|
||||
confirm_duplicate_provided = "confirm_duplicate" in provided_fields and self.confirm_duplicate
|
||||
confirm_duplicate_provided = "confirm_duplicate" in provided_fields
|
||||
if self.action in amount_actions:
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
@@ -255,6 +255,7 @@ class LedgerHistoryItem(BaseModel):
|
||||
order_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
quantity: float | None = None
|
||||
amount: float | None = None
|
||||
price: float | None = None
|
||||
fee_amount: float | None = None
|
||||
tax_amount: float | None = None
|
||||
|
||||
Reference in New Issue
Block a user