feat(portfolios): add unified ledger history API
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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}"))
|
||||||
@@ -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]]: ...
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user