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:
|
def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
||||||
action = (entry.action or entry.side or "").upper()
|
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(
|
return ManualLedgerResponse(
|
||||||
ledger_entry_id=entry.ledger_entry_id,
|
ledger_entry_id=entry.ledger_entry_id,
|
||||||
portfolio_id=entry.portfolio_id,
|
portfolio_id=entry.portfolio_id,
|
||||||
@@ -697,6 +697,7 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
|
|||||||
price=entry.price,
|
price=entry.price,
|
||||||
fee=entry.fee_amount,
|
fee=entry.fee_amount,
|
||||||
tax=entry.tax_amount,
|
tax=entry.tax_amount,
|
||||||
|
executed_at=entry.executed_at,
|
||||||
note=entry.note,
|
note=entry.note,
|
||||||
recorded_at=entry.recorded_at,
|
recorded_at=entry.recorded_at,
|
||||||
cash_balance_after=entry.cash_balance_after,
|
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:
|
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:
|
if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message:
|
||||||
return "LEDGER_ENTRY_NOT_FOUND"
|
return "LEDGER_ENTRY_NOT_FOUND"
|
||||||
if "INSUFFICIENT_CASH" in message or "현금" in message:
|
if "INSUFFICIENT_CASH" in message or "현금" in message:
|
||||||
@@ -723,6 +726,16 @@ def _manual_error_code(message: str) -> str:
|
|||||||
return "MANUAL_LEDGER_CONFLICT"
|
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:
|
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"portfolio_id": portfolio_id,
|
"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,
|
price=float(body.price) if body.price is not None else None,
|
||||||
fee=float(body.fee),
|
fee=float(body.fee),
|
||||||
tax=float(body.tax),
|
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,
|
target_ledger_entry_id=body.target_ledger_entry_id,
|
||||||
note=body.note,
|
note=body.note,
|
||||||
actor_user_id=auth.user_id,
|
actor_user_id=auth.user_id,
|
||||||
confirmation_evidence=evidence,
|
confirmation_evidence=evidence,
|
||||||
|
confirm_duplicate=body.confirm_duplicate,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -829,9 +853,18 @@ def record_manual_ledger_v1(
|
|||||||
code = _manual_error_code(message)
|
code = _manual_error_code(message)
|
||||||
if code == "LEDGER_ENTRY_NOT_FOUND":
|
if code == "LEDGER_ENTRY_NOT_FOUND":
|
||||||
status_code = status.HTTP_404_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:
|
else:
|
||||||
status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY
|
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:
|
finally:
|
||||||
_close_repo(repo)
|
_close_repo(repo)
|
||||||
return _manual_entry_to_response(entry)
|
return _manual_entry_to_response(entry)
|
||||||
|
|||||||
@@ -325,10 +325,12 @@ class PortfolioRepository(Protocol):
|
|||||||
price: float | None = None,
|
price: float | None = None,
|
||||||
fee: float = 0.0,
|
fee: float = 0.0,
|
||||||
tax: float = 0.0,
|
tax: float = 0.0,
|
||||||
|
executed_at: datetime | None = None,
|
||||||
target_ledger_entry_id: int | None = None,
|
target_ledger_entry_id: int | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
confirmation_evidence: str | None = None,
|
confirmation_evidence: str | None = None,
|
||||||
|
confirm_duplicate: bool = False,
|
||||||
) -> LedgerEntry: ...
|
) -> LedgerEntry: ...
|
||||||
|
|
||||||
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[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 update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
|
||||||
|
|
||||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
||||||
|
|
||||||
|
|||||||
@@ -2017,14 +2017,16 @@ class PostgresPortfolioRepository:
|
|||||||
price: float | None = None,
|
price: float | None = None,
|
||||||
fee: float = 0.0,
|
fee: float = 0.0,
|
||||||
tax: float = 0.0,
|
tax: float = 0.0,
|
||||||
|
executed_at: datetime | None = None,
|
||||||
target_ledger_entry_id: int | None = None,
|
target_ledger_entry_id: int | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
confirmation_evidence: str | None = None,
|
confirmation_evidence: str | None = None,
|
||||||
|
confirm_duplicate: bool = False,
|
||||||
) -> LedgerEntry:
|
) -> LedgerEntry:
|
||||||
self.ensure_schema()
|
self.ensure_schema()
|
||||||
normalized = str(action).upper()
|
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")
|
raise ValueError("unsupported manual ledger action")
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
with conn.transaction():
|
with conn.transaction():
|
||||||
@@ -2043,6 +2045,9 @@ class PostgresPortfolioRepository:
|
|||||||
fee_f = float(fee or 0.0)
|
fee_f = float(fee or 0.0)
|
||||||
tax_f = float(tax or 0.0)
|
tax_f = float(tax or 0.0)
|
||||||
amount_f = float(amount 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
|
reverse_target: LedgerEntry | None = None
|
||||||
|
|
||||||
if normalized == "INVEST":
|
if normalized == "INVEST":
|
||||||
@@ -2070,6 +2075,35 @@ class PostgresPortfolioRepository:
|
|||||||
raise ValueError("INSUFFICIENT_BUFFER")
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
buffer -= amount_f
|
buffer -= amount_f
|
||||||
ledger_price = 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":
|
elif normalized == "BUY":
|
||||||
required = ledger_quantity * ledger_price + fee_f + tax_f
|
required = ledger_quantity * ledger_price + fee_f + tax_f
|
||||||
if cash < required:
|
if cash < required:
|
||||||
@@ -2128,7 +2162,7 @@ class PostgresPortfolioRepository:
|
|||||||
raise ValueError("INSUFFICIENT_BUFFER")
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
cash += amount_f
|
cash += amount_f
|
||||||
buffer -= amount_f
|
buffer -= amount_f
|
||||||
elif target_action == "INVEST":
|
elif target_action in {"INVEST", "DIVIDEND"}:
|
||||||
if buffer < amount_f:
|
if buffer < amount_f:
|
||||||
raise ValueError("INSUFFICIENT_BUFFER")
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
buffer -= amount_f
|
buffer -= amount_f
|
||||||
@@ -2165,7 +2199,7 @@ class PostgresPortfolioRepository:
|
|||||||
tax_amount, cash_balance_after, asset_quantity_after,
|
tax_amount, cash_balance_after, asset_quantity_after,
|
||||||
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
|
total_value_after, profit_buffer_after, strategy_set_id, note, executed_at,
|
||||||
source, target_ledger_entry_id, confirmation_evidence, actor_user_id
|
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,
|
RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
|
||||||
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
|
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
|
||||||
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
|
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
|
||||||
@@ -2184,6 +2218,7 @@ class PostgresPortfolioRepository:
|
|||||||
float(buffer),
|
float(buffer),
|
||||||
"manual",
|
"manual",
|
||||||
note,
|
note,
|
||||||
|
ledger_executed_at,
|
||||||
"manual",
|
"manual",
|
||||||
target_ledger_entry_id,
|
target_ledger_entry_id,
|
||||||
confirmation_evidence,
|
confirmation_evidence,
|
||||||
|
|||||||
+25
-2
@@ -148,6 +148,7 @@ ManualLedgerAction = Literal[
|
|||||||
"SELL",
|
"SELL",
|
||||||
"WITHDRAW",
|
"WITHDRAW",
|
||||||
"PROFIT",
|
"PROFIT",
|
||||||
|
"DIVIDEND",
|
||||||
"REVERSAL",
|
"REVERSAL",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -161,9 +162,11 @@ class ManualLedgerRequest(BaseModel):
|
|||||||
price: Decimal | None = Field(default=None, gt=0)
|
price: Decimal | None = Field(default=None, gt=0)
|
||||||
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
fee: Decimal = Field(default=Decimal("0"), ge=0)
|
||||||
tax: 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
|
target_ledger_entry_id: int | None = None
|
||||||
note: str = ""
|
note: str = ""
|
||||||
confirm_daemon_managed_manual_write: bool = False
|
confirm_daemon_managed_manual_write: bool = False
|
||||||
|
confirm_duplicate: bool = False
|
||||||
confirmation_evidence: str | None = None
|
confirmation_evidence: str | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -172,6 +175,9 @@ class ManualLedgerRequest(BaseModel):
|
|||||||
trade_actions = {"BUY", "SELL"}
|
trade_actions = {"BUY", "SELL"}
|
||||||
provided_fields = self.model_fields_set
|
provided_fields = self.model_fields_set
|
||||||
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
|
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.action in amount_actions:
|
||||||
if self.amount is None:
|
if self.amount is None:
|
||||||
raise ValueError("amount is required for this action")
|
raise ValueError("amount is required for this action")
|
||||||
@@ -180,17 +186,33 @@ class ManualLedgerRequest(BaseModel):
|
|||||||
or self.price is not None
|
or self.price is not None
|
||||||
or self.target_ledger_entry_id is not None
|
or self.target_ledger_entry_id is not None
|
||||||
or fee_or_tax_provided
|
or fee_or_tax_provided
|
||||||
|
or executed_at_provided
|
||||||
|
or confirm_duplicate_provided
|
||||||
):
|
):
|
||||||
raise ValueError("unexpected numeric/target field for amount action")
|
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:
|
elif self.action in trade_actions:
|
||||||
if self.quantity is None or self.price is None:
|
if self.quantity is None or self.price is None:
|
||||||
raise ValueError("quantity and price are required for BUY/SELL")
|
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")
|
raise ValueError("unexpected amount/target field for BUY/SELL")
|
||||||
elif self.action == "REVERSAL":
|
elif self.action == "REVERSAL":
|
||||||
if self.target_ledger_entry_id is None:
|
if self.target_ledger_entry_id is None:
|
||||||
raise ValueError("target_ledger_entry_id is required for REVERSAL")
|
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")
|
raise ValueError("unexpected numeric field for REVERSAL")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -204,6 +226,7 @@ class ManualLedgerResponse(BaseModel):
|
|||||||
price: float = 0.0
|
price: float = 0.0
|
||||||
fee: float = 0.0
|
fee: float = 0.0
|
||||||
tax: float = 0.0
|
tax: float = 0.0
|
||||||
|
executed_at: datetime | None = None
|
||||||
note: str = ""
|
note: str = ""
|
||||||
recorded_at: datetime | None = None
|
recorded_at: datetime | None = None
|
||||||
cash_balance_after: float = 0.0
|
cash_balance_after: float = 0.0
|
||||||
|
|||||||
@@ -418,12 +418,14 @@
|
|||||||
|
|
||||||
- Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed.
|
- Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed.
|
||||||
- Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only.
|
- Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only.
|
||||||
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|REVERSAL`.
|
- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|DIVIDEND|REVERSAL`.
|
||||||
- `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`.
|
- `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`.
|
||||||
|
- `DIVIDEND` requires `amount` and date-only `executed_at` (`YYYY-MM-DD`); `executed_at` is stored as UTC midnight (`00:00:00Z`). Optional non-negative `tax` is metadata only.
|
||||||
- `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0.
|
- `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0.
|
||||||
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported.
|
- Duplicate candidate `DIVIDEND` (same portfolio, same date, same decimal `amount`, same `note`, and original not already reversed): first POST returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with `matched_ledger_entry_id`; retry with `confirm_duplicate=true` records the entry.
|
||||||
|
- `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported. Reversing `DIVIDEND` reduces `profit_buffer` by the original net amount under existing reversal conflict/insufficient-balance policy.
|
||||||
- `QUANTITY_ADJUSTMENT` is not part of this API.
|
- `QUANTITY_ADJUSTMENT` is not part of this API.
|
||||||
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `executed_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`.
|
||||||
- Validation/conflict policy:
|
- Validation/conflict policy:
|
||||||
- disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`.
|
- disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`.
|
||||||
- daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`.
|
- daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`.
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class _FakeRepo:
|
|||||||
price = float(kwargs.get("price") or 0.0)
|
price = float(kwargs.get("price") or 0.0)
|
||||||
fee = float(kwargs.get("fee") or 0.0)
|
fee = float(kwargs.get("fee") or 0.0)
|
||||||
tax = float(kwargs.get("tax") or 0.0)
|
tax = float(kwargs.get("tax") or 0.0)
|
||||||
|
executed_at = kwargs.get("executed_at")
|
||||||
|
confirm_duplicate = bool(kwargs.get("confirm_duplicate") or False)
|
||||||
|
note = kwargs.get("note") or ""
|
||||||
target = kwargs.get("target_ledger_entry_id")
|
target = kwargs.get("target_ledger_entry_id")
|
||||||
if action == "BUY":
|
if action == "BUY":
|
||||||
required = quantity * price + fee + tax
|
required = quantity * price + fee + tax
|
||||||
@@ -122,12 +125,64 @@ class _FakeRepo:
|
|||||||
raise ValueError("INSUFFICIENT_BUFFER")
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
self.portfolio.profit_buffer -= amount
|
self.portfolio.profit_buffer -= amount
|
||||||
price = amount
|
price = amount
|
||||||
|
elif action == "DIVIDEND":
|
||||||
|
reversed_ids = {
|
||||||
|
e.target_ledger_entry_id
|
||||||
|
for e in self.entries
|
||||||
|
if e.action == "REVERSAL" and e.target_ledger_entry_id is not None
|
||||||
|
}
|
||||||
|
duplicate = next(
|
||||||
|
(
|
||||||
|
e
|
||||||
|
for e in self.entries
|
||||||
|
if e.action == "DIVIDEND"
|
||||||
|
and e.ledger_entry_id not in reversed_ids
|
||||||
|
and e.executed_at == executed_at
|
||||||
|
and abs(float(e.price) - amount) < 1e-9
|
||||||
|
and e.note == note
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate is not None and not confirm_duplicate:
|
||||||
|
raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{duplicate.ledger_entry_id}")
|
||||||
|
self.portfolio.profit_buffer += amount
|
||||||
|
price = amount
|
||||||
elif action == "REVERSAL":
|
elif action == "REVERSAL":
|
||||||
target_entry = next((e for e in self.entries if e.ledger_entry_id == target), None)
|
target_entry = next((e for e in self.entries if e.ledger_entry_id == target), None)
|
||||||
if target_entry is None:
|
if target_entry is None:
|
||||||
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
|
raise ValueError("LEDGER_ENTRY_NOT_FOUND")
|
||||||
if any(e.target_ledger_entry_id == target and e.action == "REVERSAL" for e in self.entries):
|
if any(e.target_ledger_entry_id == target and e.action == "REVERSAL" for e in self.entries):
|
||||||
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
|
raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED")
|
||||||
|
target_action = target_entry.action or ""
|
||||||
|
target_amount = float(target_entry.price)
|
||||||
|
target_quantity = float(target_entry.quantity)
|
||||||
|
target_fee = float(target_entry.fee_amount)
|
||||||
|
target_tax = float(target_entry.tax_amount)
|
||||||
|
if target_action == "BUY":
|
||||||
|
self.portfolio.cash_balance += target_quantity * target_amount + target_fee + target_tax
|
||||||
|
self.portfolio.asset_quantity -= target_quantity
|
||||||
|
elif target_action == "SELL":
|
||||||
|
required_cash = target_quantity * target_amount - target_fee - target_tax
|
||||||
|
if self.portfolio.cash_balance < required_cash:
|
||||||
|
raise ValueError("INSUFFICIENT_CASH")
|
||||||
|
self.portfolio.cash_balance -= required_cash
|
||||||
|
self.portfolio.asset_quantity += target_quantity
|
||||||
|
elif target_action == "BUFFER_OUT":
|
||||||
|
if self.portfolio.cash_balance < target_amount:
|
||||||
|
raise ValueError("INSUFFICIENT_CASH")
|
||||||
|
self.portfolio.cash_balance -= target_amount
|
||||||
|
self.portfolio.profit_buffer += target_amount
|
||||||
|
elif target_action == "BUFFER_IN":
|
||||||
|
if self.portfolio.profit_buffer < target_amount:
|
||||||
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
|
self.portfolio.cash_balance += target_amount
|
||||||
|
self.portfolio.profit_buffer -= target_amount
|
||||||
|
elif target_action in {"INVEST", "DIVIDEND"}:
|
||||||
|
if self.portfolio.profit_buffer < target_amount:
|
||||||
|
raise ValueError("INSUFFICIENT_BUFFER")
|
||||||
|
self.portfolio.profit_buffer -= target_amount
|
||||||
|
elif target_action in {"WITHDRAW", "PROFIT"}:
|
||||||
|
self.portfolio.profit_buffer += target_amount
|
||||||
price = 0.0
|
price = 0.0
|
||||||
total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer
|
total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer
|
||||||
entry = LedgerEntry(
|
entry = LedgerEntry(
|
||||||
@@ -139,12 +194,13 @@ class _FakeRepo:
|
|||||||
price=price,
|
price=price,
|
||||||
fee_amount=fee,
|
fee_amount=fee,
|
||||||
tax_amount=tax,
|
tax_amount=tax,
|
||||||
|
executed_at=executed_at,
|
||||||
recorded_at=datetime.now(timezone.utc),
|
recorded_at=datetime.now(timezone.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,
|
||||||
total_value_after=total,
|
total_value_after=total,
|
||||||
note=kwargs.get("note") or "",
|
note=note,
|
||||||
source="manual",
|
source="manual",
|
||||||
target_ledger_entry_id=target,
|
target_ledger_entry_id=target,
|
||||||
confirmation_evidence=kwargs.get("confirmation_evidence"),
|
confirmation_evidence=kwargs.get("confirmation_evidence"),
|
||||||
@@ -402,6 +458,125 @@ def test_amount_actions_reject_fee_and_tax_fields() -> None:
|
|||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() -> None:
|
||||||
|
repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500))
|
||||||
|
client = _make_client(repo)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 120, "tax": 10, "executed_at": "2026-09-01", "note": "Q3"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["action"] == "DIVIDEND"
|
||||||
|
assert body["amount"] == 120.0
|
||||||
|
assert body["tax"] == 10.0
|
||||||
|
assert body["executed_at"] == "2026-09-01T00:00:00Z"
|
||||||
|
assert body["cash_balance_after"] == 1000.0
|
||||||
|
assert body["asset_quantity_after"] == 10.0
|
||||||
|
assert body["profit_buffer_after"] == 620.0
|
||||||
|
assert body["total_value_after"] == 1720.0
|
||||||
|
assert body["source"] == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dividend_requires_executed_at_and_rejects_fee_field() -> None:
|
||||||
|
repo = _FakeRepo(_portfolio())
|
||||||
|
client = _make_client(repo)
|
||||||
|
|
||||||
|
missing_date = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 50},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert missing_date.status_code == 422
|
||||||
|
|
||||||
|
invalid_fee = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "fee": 0},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert invalid_fee.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_dividend_duplicate_requires_confirmation() -> None:
|
||||||
|
repo = _FakeRepo(_portfolio())
|
||||||
|
client = _make_client(repo)
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
duplicate = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert duplicate.status_code == 409
|
||||||
|
assert duplicate.json()["detail"]["code"] == "POTENTIAL_DUPLICATE_DIVIDEND"
|
||||||
|
assert duplicate.json()["detail"]["matched_ledger_entry_id"] == first.json()["ledger_entry_id"]
|
||||||
|
|
||||||
|
confirmed = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={
|
||||||
|
"action": "DIVIDEND",
|
||||||
|
"amount": 50,
|
||||||
|
"executed_at": "2026-09-01",
|
||||||
|
"note": "Q3",
|
||||||
|
"confirm_duplicate": True,
|
||||||
|
},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert confirmed.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_dividend_reversal_decreases_profit_buffer() -> None:
|
||||||
|
repo = _FakeRepo(_portfolio(buffer=200))
|
||||||
|
client = _make_client(repo)
|
||||||
|
|
||||||
|
dividend = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert dividend.status_code == 201
|
||||||
|
assert dividend.json()["profit_buffer_after"] == 280.0
|
||||||
|
|
||||||
|
reversal = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert reversal.status_code == 201
|
||||||
|
assert reversal.json()["profit_buffer_after"] == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_ledger_includes_dividend_fields() -> None:
|
||||||
|
repo = _FakeRepo(_portfolio())
|
||||||
|
client = _make_client(repo)
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual",
|
||||||
|
json={"action": "DIVIDEND", "amount": 33, "tax": 3, "executed_at": "2026-09-01", "note": "Q3"},
|
||||||
|
headers=_auth(),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth())
|
||||||
|
assert resp.status_code == 200
|
||||||
|
item = resp.json()["items"][0]
|
||||||
|
assert item["source"] == "manual"
|
||||||
|
assert item["action"] == "DIVIDEND"
|
||||||
|
assert item["executed_at"] == "2026-09-01T00:00:00Z"
|
||||||
|
assert item["tax_amount"] == 3.0
|
||||||
|
assert item["note"] == "Q3"
|
||||||
|
assert item["profit_buffer_after"] == created.json()["profit_buffer_after"]
|
||||||
|
|
||||||
|
|
||||||
def test_reversal_rejects_fee_tax_and_missing_target() -> None:
|
def test_reversal_rejects_fee_tax_and_missing_target() -> None:
|
||||||
repo = _FakeRepo(_portfolio())
|
repo = _FakeRepo(_portfolio())
|
||||||
client = _make_client(repo)
|
client = _make_client(repo)
|
||||||
|
|||||||
Reference in New Issue
Block a user