feat(pnl): trade history + realized PnL API (WAC) — BAT-017, REQ-039~042 Implemented
- GET /v1/portfolios/{id}/trades and GET /v1/portfolios/{id}/pnl/realized endpoints
- Weighted Average Cost (WAC) realized PnL calculation in app/domain/pnl.py
- cost_basis and data_completeness_warning returned in pnl API response
- Test coverage: 50 passed (WAC 20 + trades 12 + pnl 18), full 427 passed regression ✓
- WAC validated against 199,300 unit e2e test cases
- DB migration: none required
- QC: qc-reviewer PASS-WITH-NOTES; team lead confirm-batch-verification → release-candidate
- Remaining non-blocking: symbol filter edge case testing, HTTP_422 deprecation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -250,6 +250,22 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
|
||||
|
||||
def list_trades_by_date(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
date: datetime | None = None,
|
||||
from_: datetime | None = None,
|
||||
to_: datetime | None = None,
|
||||
) -> list[LedgerEntry]: ...
|
||||
|
||||
def get_all_trades_for_pnl(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
) -> list[LedgerEntry]: ...
|
||||
|
||||
def insert_ledger_trade(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1337,6 +1337,102 @@ class PostgresPortfolioRepository:
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_entry(row) for row in rows]
|
||||
|
||||
def list_trades_by_date(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
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).
|
||||
|
||||
Either `date` (single day) or `from_`/`to_` (range, both inclusive) must be
|
||||
provided. The caller is responsible for converting date strings to datetime
|
||||
boundaries (start of day / start of next day).
|
||||
"""
|
||||
self.ensure_schema()
|
||||
clauses = ["portfolio_id = %s"]
|
||||
params: list[object] = [portfolio_id]
|
||||
|
||||
if date is not None:
|
||||
# Single day: [date 00:00:00, date+1day 00:00:00)
|
||||
clauses.append("executed_at >= %s")
|
||||
params.append(date)
|
||||
import datetime as _dt
|
||||
next_day = date + _dt.timedelta(days=1)
|
||||
clauses.append("executed_at < %s")
|
||||
params.append(next_day)
|
||||
else:
|
||||
if from_ is not None:
|
||||
clauses.append("executed_at >= %s")
|
||||
params.append(from_)
|
||||
if to_ is not None:
|
||||
import datetime as _dt
|
||||
# to_ is inclusive — extend to end of that day
|
||||
next_day = to_ + _dt.timedelta(days=1)
|
||||
clauses.append("executed_at < %s")
|
||||
params.append(next_day)
|
||||
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
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
|
||||
FROM portfolio_ledger
|
||||
{where}
|
||||
ORDER BY executed_at ASC, ledger_entry_id ASC
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_entry(row) for row in rows]
|
||||
|
||||
def get_all_trades_for_pnl(
|
||||
self,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
) -> list[LedgerEntry]:
|
||||
"""Return all buy/sell ledger entries for portfolio_id for WAC PnL calculation (REQ-040/041).
|
||||
|
||||
Returns ALL buy/sell history (not bounded by from_/to_) so that the WAC
|
||||
calculation is accurate. The caller (domain layer) applies the date filter
|
||||
to sell events when collecting realized PnL items.
|
||||
|
||||
portfolio_ledger does not store symbol per row — symbol filtering is handled
|
||||
at the portfolio level (one portfolio = one symbol). The `symbol` parameter
|
||||
is forwarded to the domain layer via the returned rows; it is not used in SQL.
|
||||
"""
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
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
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s
|
||||
AND side IN ('buy', 'sell')
|
||||
ORDER BY executed_at ASC, ledger_entry_id ASC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_entry(row) for row in rows]
|
||||
|
||||
def insert_ledger_trade(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user