Preserve ledger venue provenance (#39)

* feat: preserve ledger venue provenance

* fix: satisfy ledger test lint ratchet

* fix: advance image migration inventory
This commit is contained in:
2026-09-19 19:30:32 +09:00
committed by GitHub
parent 567d662913
commit 996127f215
23 changed files with 768 additions and 35 deletions
+6
View File
@@ -9,6 +9,10 @@ from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
class LedgerTradeConflictError(ValueError):
"""A duplicate broker-ledger key disagrees with its persisted payload."""
class PortfolioRepository(Protocol):
def ping(self) -> None: ...
@@ -324,6 +328,8 @@ class PortfolioRepository(Protocol):
executed_at: datetime,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
execution_venue: str | None = None,
order_venue: str | None = None,
) -> tuple[LedgerEntry, bool]: ...
def record_manual_ledger(
+116 -10
View File
@@ -40,6 +40,7 @@ from app.domain.models import (
TokenPrincipal,
User,
)
from app.repositories.portfolio_repository import LedgerTradeConflictError
logger = logging.getLogger(__name__)
_LEDGER_NUMERIC_SCALE = 8
@@ -382,7 +383,11 @@ class PostgresPortfolioRepository:
profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0,
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
note TEXT NOT NULL DEFAULT '',
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
execution_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_execution_venue
CHECK (execution_venue IN ('KRX', 'NXT')),
order_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_order_venue
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'))
)
"""
)
@@ -395,6 +400,33 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_venue TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_venue TEXT")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_execution_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_execution_venue
CHECK (execution_venue IS NULL OR execution_venue IN ('KRX', 'NXT'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_order_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_order_venue
CHECK (order_venue IS NULL OR order_venue IN ('KRX', 'NXT', 'SOR'));
END IF;
END $$;
"""
)
cur.execute(
"""
DO $$
@@ -2018,6 +2050,8 @@ class PostgresPortfolioRepository:
executed_at,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
execution_venue: str | None = None,
order_venue: str | None = None,
) -> tuple[LedgerEntry, bool]:
self.ensure_schema()
side_normalized = str(side).lower()
@@ -2078,16 +2112,70 @@ class PostgresPortfolioRepository:
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT 1
FOR UPDATE
""",
(portfolio_id, order_id, execution_id),
)
existing = cur.fetchone()
if existing is not None:
economic_payload_matches = (
existing[4] == side_normalized
and abs(float(existing[5]) - qty_float) < 1e-9
and abs(float(existing[6]) - price_float) < 1e-9
and abs(float(existing[7]) - fee_float) < 1e-9
and abs(float(existing[8]) - tax_float) < 1e-9
)
if not economic_payload_matches:
raise LedgerTradeConflictError("duplicate economic payload conflict")
existing_execution_venue = existing[21]
existing_order_venue = existing[22]
if (
existing_execution_venue is not None
and execution_venue is not None
and existing_execution_venue != execution_venue
) or (
existing_order_venue is not None
and order_venue is not None
and existing_order_venue != order_venue
):
raise LedgerTradeConflictError("duplicate venue payload conflict")
enriched_execution_venue = existing_execution_venue or execution_venue
enriched_order_venue = existing_order_venue or order_venue
if (
enriched_execution_venue != existing_execution_venue
or enriched_order_venue != existing_order_venue
):
cur.execute(
"""
UPDATE portfolio_ledger
SET execution_venue = %s, order_venue = %s
WHERE ledger_entry_id = %s AND recorded_at = %s
RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
""",
(
enriched_execution_venue,
enriched_order_venue,
existing[0],
existing[10],
),
)
existing = cur.fetchone()
# Duplicate: do not insert a new row, do not mutate the portfolio.
# OQ-4 (b): return the CURRENT authoritative portfolio state so the
# caller always syncs to the latest balances, not the first-call snapshot.
@@ -2102,12 +2190,13 @@ class PostgresPortfolioRepository:
portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id,
side, price, quantity, fee_amount, tax_amount,
cash_balance_after, asset_quantity_after, total_value_after,
profit_buffer_after, strategy_set_id, note, executed_at
profit_buffer_after, strategy_set_id, note, executed_at,
execution_venue, order_venue
) VALUES (
%s, NOW(), DEFAULT, %s, %s,
%s, %s, %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,
@@ -2116,7 +2205,8 @@ class PostgresPortfolioRepository:
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
""",
(
portfolio_id,
@@ -2134,6 +2224,8 @@ class PostgresPortfolioRepository:
strategy_set_id,
"",
executed_at,
execution_venue,
order_venue,
),
)
row = cur.fetchone()
@@ -2182,6 +2274,16 @@ class PostgresPortfolioRepository:
target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None,
confirmation_evidence=row[19] if len(row) > 19 else None,
actor_user_id=row[20] if len(row) > 20 else None,
execution_venue=(
row[21]
if len(row) > 21 and row[21] in {None, "KRX", "NXT"}
else None
),
order_venue=(
row[22]
if len(row) > 22 and row[22] in {None, "KRX", "NXT", "SOR"}
else None
),
)
def reset(self, portfolio_id: str) -> Portfolio:
@@ -2492,7 +2594,8 @@ class PostgresPortfolioRepository:
SELECT 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,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
FROM portfolio_ledger
WHERE portfolio_id = %s AND ledger_entry_id = %s
""",
@@ -2591,7 +2694,8 @@ class PostgresPortfolioRepository:
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,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
""",
(
portfolio_id,
@@ -2623,7 +2727,8 @@ class PostgresPortfolioRepository:
SELECT 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,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
FROM portfolio_ledger
WHERE portfolio_id = %s AND source = 'manual'
ORDER BY recorded_at DESC, ledger_entry_id DESC
@@ -2661,6 +2766,7 @@ class PostgresPortfolioRepository:
ledger.asset_quantity_after, ledger.total_value_after, ledger.profit_buffer_after,
ledger.strategy_set_id, ledger.note, ledger.source, ledger.target_ledger_entry_id,
ledger.confirmation_evidence, ledger.actor_user_id,
ledger.execution_venue, ledger.order_venue,
ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
@@ -2676,8 +2782,8 @@ class PostgresPortfolioRepository:
result = []
for row in cur.fetchall():
entry = self._row_to_ledger_entry(row)
if row[21] is not None:
entry.corporate_action = {"corporate_action_id": str(row[21]), "action_type": row[22], "effective_date": row[23], "quantity_before": Decimal(str(row[24])), "quantity_after": Decimal(str(row[25])), "average_price_before": Decimal(str(row[26])), "average_price_after": Decimal(str(row[27])), "total_cost_basis": Decimal(str(row[28])), "cash_balance": Decimal(str(row[29])), "profit_buffer": Decimal(str(row[30])), "evidence_ref": row[31], "actor_user_id": row[32]}
if row[23] is not None:
entry.corporate_action = {"corporate_action_id": str(row[23]), "action_type": row[24], "effective_date": row[25], "quantity_before": Decimal(str(row[26])), "quantity_after": Decimal(str(row[27])), "average_price_before": Decimal(str(row[28])), "average_price_after": Decimal(str(row[29])), "total_cost_basis": Decimal(str(row[30])), "cash_balance": Decimal(str(row[31])), "profit_buffer": Decimal(str(row[32])), "evidence_ref": row[33], "actor_user_id": row[34]}
result.append(entry)
return result