feat(portfolios): add unified ledger history API

This commit is contained in:
2026-08-20 12:01:28 +09:00
parent fe22be0b66
commit ac742b1150
10 changed files with 307 additions and 25 deletions
+1
View File
@@ -3,6 +3,7 @@ PORTFOLIOHUB_ALLOWED_ORIGINS=http://localhost:3000
PORTFOLIOHUB_READ_TOKENS=read-token-123 PORTFOLIOHUB_READ_TOKENS=read-token-123
PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456 PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456
PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789 PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789
PORTFOLIOHUB_LEDGER_CURSOR_SECRET=replace-with-a-long-random-secret
PORTFOLIOHUB_ENVIRONMENT=development PORTFOLIOHUB_ENVIRONMENT=development
# Enable DB-backed repository (uses TIMESCALEDB_* below) # Enable DB-backed repository (uses TIMESCALEDB_* below)
+106 -2
View File
@@ -8,6 +8,11 @@ REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cos
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import logging import logging
import uuid import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
@@ -15,11 +20,15 @@ from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.core.config import get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Portfolio, PortfolioComment from app.domain.models import Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import ( from app.schemas.v1 import (
HoldingsCostBasisResponse,
LedgerHistoryItem,
LedgerHistoryResponse,
LedgerTradeRequest, LedgerTradeRequest,
LedgerTradeResponse, LedgerTradeResponse,
ManualLedgerHistoryResponse, ManualLedgerHistoryResponse,
@@ -35,7 +44,6 @@ from app.schemas.v1 import (
PortfolioReturnSummaryResponse, PortfolioReturnSummaryResponse,
PortfolioTagListResponse, PortfolioTagListResponse,
PortfolioUpdateRequest, PortfolioUpdateRequest,
HoldingsCostBasisResponse,
RealizedPnlItem, RealizedPnlItem,
RealizedPnlResponse, RealizedPnlResponse,
TagApplyRequest, TagApplyRequest,
@@ -44,7 +52,10 @@ from app.schemas.v1 import (
TradeHistoryItem, TradeHistoryItem,
TradeHistoryResponse, TradeHistoryResponse,
) )
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError from app.services.strategy_set_service import (
StrategyInactiveError,
StrategyNotFoundError,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/portfolios", tags=["v1"]) router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
@@ -710,6 +721,62 @@ def _manual_error_code(message: str) -> str:
return "MANUAL_LEDGER_CONFLICT" return "MANUAL_LEDGER_CONFLICT"
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
payload = {
"portfolio_id": portfolio_id,
"source": source,
"recorded_at": recorded_at.isoformat(),
"ledger_entry_id": ledger_entry_id,
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
signature = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + signature).decode().rstrip("=")
def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None) -> tuple[datetime, int]:
try:
encoded = cursor.encode()
raw_and_signature = base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
if base64.urlsafe_b64encode(raw_and_signature).rstrip(b"=") != encoded:
raise ValueError
raw, signature = raw_and_signature[:-32], raw_and_signature[-32:]
expected = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
payload = json.loads(raw)
recorded_at = datetime.fromisoformat(payload["recorded_at"])
ledger_entry_id = int(payload["ledger_entry_id"])
if not hmac.compare_digest(signature, expected) or payload["portfolio_id"] != portfolio_id or payload["source"] != source:
raise ValueError
if recorded_at.tzinfo is None:
raise ValueError
return recorded_at, ledger_entry_id
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError, binascii.Error):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid ledger cursor") from None
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
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(),
recorded_at=entry.recorded_at,
executed_at=entry.executed_at,
order_id=entry.order_id,
execution_id=entry.execution_id,
quantity=entry.quantity,
price=entry.price,
fee_amount=entry.fee_amount,
tax_amount=entry.tax_amount,
note=entry.note,
cash_balance_after=entry.cash_balance_after,
asset_quantity_after=entry.asset_quantity_after,
profit_buffer_after=entry.profit_buffer_after,
total_value_after=entry.total_value_after,
target_ledger_entry_id=entry.target_ledger_entry_id,
confirmation_evidence=entry.confirmation_evidence,
)
@router.post( @router.post(
"/{portfolio_id}/ledger/manual", "/{portfolio_id}/ledger/manual",
response_model=ManualLedgerResponse, response_model=ManualLedgerResponse,
@@ -788,6 +855,43 @@ def list_manual_ledger_v1(
return ManualLedgerHistoryResponse(items=items, total=len(items)) return ManualLedgerHistoryResponse(items=items, total=len(items))
@router.get("/{portfolio_id}/ledger", response_model=LedgerHistoryResponse)
def list_ledger_v1(
portfolio_id: str,
source: str | None = Query(default=None, min_length=1),
cursor: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
auth: AuthContext = Depends(require_read),
) -> LedgerHistoryResponse:
if auth.token_type in {"service", "client"} or auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ledger requires user/session token")
before_recorded_at = before_ledger_entry_id = None
if cursor is not None:
before_recorded_at, before_ledger_entry_id = _decode_ledger_cursor(cursor, portfolio_id=portfolio_id, source=source)
repo = _get_repo()
try:
_get_owner_portfolio_or_404(auth, portfolio_id, repo)
entries = repo.list_ledger_entries(
portfolio_id,
source=source,
before_recorded_at=before_recorded_at,
before_ledger_entry_id=before_ledger_entry_id,
limit=limit + 1,
)
finally:
_close_repo(repo)
page, extra = entries[:limit], entries[limit:]
next_cursor = None
if extra and page:
last = page[-1]
if last.recorded_at is None:
raise RuntimeError("ledger entry is missing recorded_at")
next_cursor = _encode_ledger_cursor(
portfolio_id=portfolio_id, source=source, recorded_at=last.recorded_at, ledger_entry_id=last.ledger_entry_id
)
return LedgerHistoryResponse(items=[_ledger_entry_to_history_item(entry) for entry in page], next_cursor=next_cursor)
@router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse) @router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse)
def get_portfolio_return_summary_v1( def get_portfolio_return_summary_v1(
portfolio_id: str, portfolio_id: str,
+1
View File
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS") read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS") trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS") admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
ledger_cursor_secret: str = Field(default="development-ledger-cursor-secret", validation_alias="PORTFOLIOHUB_LEDGER_CURSOR_SECRET")
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED") db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS") session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS")
client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS") client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS")
@@ -0,0 +1,31 @@
"""portfolio_ledger: keyset pagination index for unified ledger reads
Revision ID: 20260820_0030
Revises: 20260814_0029
Create Date: 2026-08-20 10:50:00
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import text
revision = "20260820_0030"
down_revision = "20260814_0029"
branch_labels = None
depends_on = None
_INDEX = "idx_portfolio_ledger_portfolio_recorded_entry_desc"
def upgrade() -> None:
op.get_bind().execute(
text(
f"CREATE INDEX IF NOT EXISTS {_INDEX} "
"ON portfolio_ledger (portfolio_id, recorded_at DESC, ledger_entry_id DESC)"
)
)
def downgrade() -> None:
op.get_bind().execute(text(f"DROP INDEX IF EXISTS {_INDEX}"))
+10
View File
@@ -333,6 +333,16 @@ class PortfolioRepository(Protocol):
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]: ...
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]: ...
def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ... def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ...
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: ... def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: ...
+35
View File
@@ -2210,6 +2210,41 @@ class PostgresPortfolioRepository:
) )
return [self._row_to_ledger_entry(row) for row in cur.fetchall()] return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.ensure_schema()
clauses = ["portfolio_id = %s"]
params: list[Any] = [portfolio_id]
if source is not None:
clauses.append("source = %s")
params.append(source)
if before_recorded_at is not None and before_ledger_entry_id is not None:
clauses.append("(recorded_at, ledger_entry_id) < (%s, %s)")
params.extend([before_recorded_at, before_ledger_entry_id])
params.append(int(limit))
with self._connect().cursor() as cur:
cur.execute(
"""
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
FROM portfolio_ledger
WHERE """ + " AND ".join(clauses) + """
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
params,
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids: if not portfolio_ids:
return {} return {}
+29
View File
@@ -220,6 +220,35 @@ class ManualLedgerHistoryResponse(BaseModel):
total: int total: int
class LedgerHistoryItem(BaseModel):
"""Source-neutral, owner-visible ledger fact for GET /ledger."""
ledger_entry_id: int
portfolio_id: str
source: str
action: str
recorded_at: datetime
executed_at: datetime | None = None
order_id: str | None = None
execution_id: str | None = None
quantity: float | None = None
price: float | None = None
fee_amount: float | None = None
tax_amount: float | None = None
note: str | None = None
cash_balance_after: float | None = None
asset_quantity_after: float | None = None
profit_buffer_after: float | None = None
total_value_after: float | None = None
target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None
class LedgerHistoryResponse(BaseModel):
items: list[LedgerHistoryItem]
next_cursor: str | None = None
class PortfolioReturnSummaryResponse(BaseModel): class PortfolioReturnSummaryResponse(BaseModel):
portfolio_id: str portfolio_id: str
total_invested: float = Field( total_invested: float = Field(
+3 -3
View File
@@ -50,7 +50,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert 'RELEASE_VERSION=v0.1.3' not in verify assert 'RELEASE_VERSION=v0.1.3' not in verify
def test_release_version_and_migration_contract_are_v013_head_0029(): def test_release_version_and_migration_contract_are_v013_head_0030():
project = (REPO / "pyproject.toml").read_text() project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text() config = (REPO / "app/core/config.py").read_text()
migrations = sorted( migrations = sorted(
@@ -60,8 +60,8 @@ def test_release_version_and_migration_contract_are_v013_head_0029():
) )
assert 'version = "0.1.3"' in project assert 'version = "0.1.3"' in project
assert 'app_version: str = "0.1.3"' in config assert 'app_version: str = "0.1.3"' in config
assert len(migrations) == 29 assert len(migrations) == 30
assert migrations[-1].startswith("20260814_0029") assert migrations[-1].startswith("20260820_0030")
def test_dockerignore_is_deny_by_default_required_inputs_only(): def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**" lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
@@ -3,41 +3,32 @@
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import sys
# REQ-016 AC-1: 후보 소스에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음 def _source_symbol_matches(symbol: str) -> list[str]:
def test_no_issue_client_token_symbol_in_codebase() -> None:
result = subprocess.run( result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "issue_client_token"], ["grep", "-r", "--exclude-dir=temp", "--exclude-dir=.git", "--exclude-dir=.venv", "--include=*.py", "-l", symbol],
capture_output=True, capture_output=True,
check=False,
text=True, text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent), cwd=str(__import__("pathlib").Path(__file__).parent.parent),
) )
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")] return [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
def test_no_issue_client_token_symbol_in_codebase() -> None:
matches = _source_symbol_matches("issue_client_token")
assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}" assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
def test_no_create_client_token_request_symbol_in_codebase() -> None: def test_no_create_client_token_request_symbol_in_codebase() -> None:
result = subprocess.run( matches = _source_symbol_matches("CreateClientTokenRequest")
["grep", "-r", "--include=*.py", "-l", "CreateClientTokenRequest"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}" assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
def test_no_issued_client_token_response_symbol_in_codebase() -> None: def test_no_issued_client_token_response_symbol_in_codebase() -> None:
result = subprocess.run( matches = _source_symbol_matches("IssuedClientTokenResponse")
["grep", "-r", "--include=*.py", "-l", "IssuedClientTokenResponse"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}" assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
+80
View File
@@ -158,6 +158,27 @@ class _FakeRepo:
self.get(portfolio_id) self.get(portfolio_id)
return list(reversed(self.entries))[:limit] return list(reversed(self.entries))[:limit]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.get(portfolio_id)
entries = sorted(self.entries, key=lambda entry: (entry.recorded_at, entry.ledger_entry_id), reverse=True)
if source is not None:
entries = [entry for entry in entries if entry.source == source]
if before_recorded_at is not None and before_ledger_entry_id is not None:
entries = [
entry
for entry in entries
if (entry.recorded_at, entry.ledger_entry_id) < (before_recorded_at, before_ledger_entry_id)
]
return entries[:limit]
def get_return_summary(self, portfolio_id: str) -> dict[str, float]: def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.get(portfolio_id) self.get(portfolio_id)
reversed_ids = { reversed_ids = {
@@ -399,3 +420,62 @@ def test_reversal_rejects_fee_tax_and_missing_target() -> None:
) )
assert missing_target.status_code == 404 assert missing_target.status_code == 404
assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND" assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND"
def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
repo = _FakeRepo()
client = _make_client(repo)
manual = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
)
assert manual.status_code == 201
repo.entries.append(
LedgerEntry(
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
recorded_at=datetime.now(timezone.utc), cash_balance_after=900, asset_quantity_after=12,
)
)
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
assert first.status_code == 200
first_item = first.json()["items"][0]
assert first_item["source"] == "broker"
assert first_item["action"] == "BUY"
assert "side" not in first_item
assert first.json()["next_cursor"]
second = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={first.json()['next_cursor']}", headers=_auth()
)
assert second.status_code == 200
assert second.json()["items"][0]["ledger_entry_id"] == manual.json()["ledger_entry_id"]
assert second.json()["next_cursor"] is None
def test_unified_ledger_rejects_tampered_or_different_source_cursor() -> None:
repo = _FakeRepo()
client = _make_client(repo)
for amount in (100, 200):
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": amount}, headers=_auth()
).status_code == 201
cursor = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()).json()["next_cursor"]
assert client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={cursor[:-1]}x", headers=_auth()).status_code == 422
assert client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&source=manual&cursor={cursor}", headers=_auth()
).status_code == 422
def test_unified_ledger_source_filter_unknown_value_is_empty_and_owner_scoped() -> None:
repo = _FakeRepo()
client = _make_client(repo)
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
).status_code == 201
filtered = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=unknown-producer", headers=_auth())
assert filtered.status_code == 200
assert filtered.json() == {"items": [], "next_cursor": None}
foreign_client = _make_client(_FakeRepo(_portfolio(owner=_OTHER_USER_ID)))
assert foreign_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403
service_client = _make_client(_FakeRepo(principal=_token_principal(token_type="service")))
assert service_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403