feat: support manual dividend ledger entries
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
9d2c2aea5f
commit
ccde81b7a0
@@ -687,7 +687,7 @@ def record_ledger_trade_v1(
|
||||
|
||||
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
action = (entry.action or entry.side or "").upper()
|
||||
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None
|
||||
amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None
|
||||
return ManualLedgerResponse(
|
||||
ledger_entry_id=entry.ledger_entry_id,
|
||||
portfolio_id=entry.portfolio_id,
|
||||
@@ -697,6 +697,7 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
price=entry.price,
|
||||
fee=entry.fee_amount,
|
||||
tax=entry.tax_amount,
|
||||
executed_at=entry.executed_at,
|
||||
note=entry.note,
|
||||
recorded_at=entry.recorded_at,
|
||||
cash_balance_after=entry.cash_balance_after,
|
||||
@@ -710,6 +711,8 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||
|
||||
|
||||
def _manual_error_code(message: str) -> str:
|
||||
if "POTENTIAL_DUPLICATE_DIVIDEND" in message:
|
||||
return "POTENTIAL_DUPLICATE_DIVIDEND"
|
||||
if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message:
|
||||
return "LEDGER_ENTRY_NOT_FOUND"
|
||||
if "INSUFFICIENT_CASH" in message or "현금" in message:
|
||||
@@ -723,6 +726,16 @@ def _manual_error_code(message: str) -> str:
|
||||
return "MANUAL_LEDGER_CONFLICT"
|
||||
|
||||
|
||||
def _extract_duplicate_dividend_entry_id(message: str) -> int | None:
|
||||
prefix = "POTENTIAL_DUPLICATE_DIVIDEND:"
|
||||
if not message.startswith(prefix):
|
||||
return None
|
||||
try:
|
||||
return int(message.removeprefix(prefix))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
|
||||
payload = {
|
||||
"portfolio_id": portfolio_id,
|
||||
@@ -817,10 +830,21 @@ def record_manual_ledger_v1(
|
||||
price=float(body.price) if body.price is not None else None,
|
||||
fee=float(body.fee),
|
||||
tax=float(body.tax),
|
||||
executed_at=(
|
||||
datetime(
|
||||
body.executed_at.year,
|
||||
body.executed_at.month,
|
||||
body.executed_at.day,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
if body.executed_at is not None
|
||||
else None
|
||||
),
|
||||
target_ledger_entry_id=body.target_ledger_entry_id,
|
||||
note=body.note,
|
||||
actor_user_id=auth.user_id,
|
||||
confirmation_evidence=evidence,
|
||||
confirm_duplicate=body.confirm_duplicate,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -829,9 +853,18 @@ def record_manual_ledger_v1(
|
||||
code = _manual_error_code(message)
|
||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
detail: str | dict[str, object] = {"code": code, "message": message}
|
||||
elif code == "POTENTIAL_DUPLICATE_DIVIDEND":
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
detail = {
|
||||
"code": code,
|
||||
"message": "Potential duplicate dividend entry detected",
|
||||
"matched_ledger_entry_id": _extract_duplicate_dividend_entry_id(message),
|
||||
}
|
||||
else:
|
||||
status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc
|
||||
detail = {"code": code, "message": message}
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return _manual_entry_to_response(entry)
|
||||
|
||||
@@ -325,10 +325,12 @@ class PortfolioRepository(Protocol):
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
actor_user_id: str | None = None,
|
||||
confirmation_evidence: str | None = None,
|
||||
confirm_duplicate: bool = False,
|
||||
) -> LedgerEntry: ...
|
||||
|
||||
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ...
|
||||
@@ -362,4 +364,3 @@ class PortfolioRepository(Protocol):
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
||||
|
||||
|
||||
@@ -2017,14 +2017,16 @@ class PostgresPortfolioRepository:
|
||||
price: float | None = None,
|
||||
fee: float = 0.0,
|
||||
tax: float = 0.0,
|
||||
executed_at: datetime | None = None,
|
||||
target_ledger_entry_id: int | None = None,
|
||||
note: str = "",
|
||||
actor_user_id: str | None = None,
|
||||
confirmation_evidence: str | None = None,
|
||||
confirm_duplicate: bool = False,
|
||||
) -> LedgerEntry:
|
||||
self.ensure_schema()
|
||||
normalized = str(action).upper()
|
||||
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "REVERSAL"}:
|
||||
if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "DIVIDEND", "REVERSAL"}:
|
||||
raise ValueError("unsupported manual ledger action")
|
||||
conn = self._connect()
|
||||
with conn.transaction():
|
||||
@@ -2043,6 +2045,9 @@ class PostgresPortfolioRepository:
|
||||
fee_f = float(fee or 0.0)
|
||||
tax_f = float(tax or 0.0)
|
||||
amount_f = float(amount or 0.0)
|
||||
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)
|
||||
reverse_target: LedgerEntry | None = None
|
||||
|
||||
if normalized == "INVEST":
|
||||
@@ -2070,6 +2075,35 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
buffer -= amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "DIVIDEND":
|
||||
if not confirm_duplicate:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT existing.ledger_entry_id
|
||||
FROM portfolio_ledger AS existing
|
||||
WHERE existing.portfolio_id = %s
|
||||
AND existing.source = 'manual'
|
||||
AND existing.side = 'dividend'
|
||||
AND existing.price = %s::numeric(20,8)
|
||||
AND existing.executed_at = %s
|
||||
AND existing.note = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM portfolio_ledger AS reversal
|
||||
WHERE reversal.portfolio_id = existing.portfolio_id
|
||||
AND reversal.side = 'reversal'
|
||||
AND reversal.target_ledger_entry_id = existing.ledger_entry_id
|
||||
)
|
||||
ORDER BY existing.ledger_entry_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(portfolio_id, amount_f, ledger_executed_at, note),
|
||||
)
|
||||
duplicate_row = cur.fetchone()
|
||||
if duplicate_row is not None:
|
||||
raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{int(duplicate_row[0])}")
|
||||
buffer += amount_f
|
||||
ledger_price = amount_f
|
||||
elif normalized == "BUY":
|
||||
required = ledger_quantity * ledger_price + fee_f + tax_f
|
||||
if cash < required:
|
||||
@@ -2128,7 +2162,7 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
cash += amount_f
|
||||
buffer -= amount_f
|
||||
elif target_action == "INVEST":
|
||||
elif target_action in {"INVEST", "DIVIDEND"}:
|
||||
if buffer < amount_f:
|
||||
raise ValueError("INSUFFICIENT_BUFFER")
|
||||
buffer -= amount_f
|
||||
@@ -2165,7 +2199,7 @@ class PostgresPortfolioRepository:
|
||||
tax_amount, cash_balance_after, asset_quantity_after,
|
||||
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
|
||||
source, target_ledger_entry_id, confirmation_evidence, actor_user_id
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s, %s, %s)
|
||||
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
|
||||
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
|
||||
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
|
||||
@@ -2184,6 +2218,7 @@ class PostgresPortfolioRepository:
|
||||
float(buffer),
|
||||
"manual",
|
||||
note,
|
||||
ledger_executed_at,
|
||||
"manual",
|
||||
target_ledger_entry_id,
|
||||
confirmation_evidence,
|
||||
|
||||
+25
-2
@@ -148,6 +148,7 @@ ManualLedgerAction = Literal[
|
||||
"SELL",
|
||||
"WITHDRAW",
|
||||
"PROFIT",
|
||||
"DIVIDEND",
|
||||
"REVERSAL",
|
||||
]
|
||||
|
||||
@@ -161,9 +162,11 @@ class ManualLedgerRequest(BaseModel):
|
||||
price: Decimal | None = Field(default=None, gt=0)
|
||||
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
executed_at: date | None = None
|
||||
target_ledger_entry_id: int | None = None
|
||||
note: str = ""
|
||||
confirm_daemon_managed_manual_write: bool = False
|
||||
confirm_duplicate: bool = False
|
||||
confirmation_evidence: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -172,6 +175,9 @@ class ManualLedgerRequest(BaseModel):
|
||||
trade_actions = {"BUY", "SELL"}
|
||||
provided_fields = self.model_fields_set
|
||||
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
|
||||
if self.action in amount_actions:
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
@@ -180,17 +186,33 @@ class ManualLedgerRequest(BaseModel):
|
||||
or self.price is not None
|
||||
or self.target_ledger_entry_id is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_provided
|
||||
):
|
||||
raise ValueError("unexpected numeric/target field for amount action")
|
||||
elif self.action == "DIVIDEND":
|
||||
if self.amount is None:
|
||||
raise ValueError("amount is required for this action")
|
||||
if self.executed_at is None:
|
||||
raise ValueError("executed_at is required for DIVIDEND")
|
||||
if self.quantity is not None or self.price is not None or self.target_ledger_entry_id is not None or fee_provided:
|
||||
raise ValueError("unexpected numeric/target field for DIVIDEND")
|
||||
elif self.action in trade_actions:
|
||||
if self.quantity is None or self.price is None:
|
||||
raise ValueError("quantity and price are required for BUY/SELL")
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None:
|
||||
if self.amount is not None or self.target_ledger_entry_id is not None or executed_at_provided or confirm_duplicate_provided:
|
||||
raise ValueError("unexpected amount/target field for BUY/SELL")
|
||||
elif self.action == "REVERSAL":
|
||||
if self.target_ledger_entry_id is None:
|
||||
raise ValueError("target_ledger_entry_id is required for REVERSAL")
|
||||
if self.amount is not None or self.quantity is not None or self.price is not None or fee_or_tax_provided:
|
||||
if (
|
||||
self.amount is not None
|
||||
or self.quantity is not None
|
||||
or self.price is not None
|
||||
or fee_or_tax_provided
|
||||
or executed_at_provided
|
||||
or confirm_duplicate_provided
|
||||
):
|
||||
raise ValueError("unexpected numeric field for REVERSAL")
|
||||
return self
|
||||
|
||||
@@ -204,6 +226,7 @@ class ManualLedgerResponse(BaseModel):
|
||||
price: float = 0.0
|
||||
fee: float = 0.0
|
||||
tax: float = 0.0
|
||||
executed_at: datetime | None = None
|
||||
note: str = ""
|
||||
recorded_at: datetime | None = None
|
||||
cash_balance_after: float = 0.0
|
||||
|
||||
Reference in New Issue
Block a user