feat(V1-API): implement TREX-Basic consumer API contract with encryption and persistence

- Add V1 API endpoints: assets, ledger, user-info
- Implement Fernet-based credential payload encryption (req-010)
- Extend health response schema with runtime info (req-004)
- Add service token runtime ID issuance (req-005)
- Implement database schema migration for V1 API (req-009)
- Add comprehensive test coverage for V1 endpoints
- Update SRS with 10 new requirements (REQ-004 through REQ-011)
- Create batch planning, design, and verification for BAT-001
- Integrate review-discovery StagePilot skill
- Update documentation indices and baseline specs
This commit is contained in:
2026-05-02 09:06:15 +00:00
parent 24096a1e58
commit ce63c75c9c
37 changed files with 2851 additions and 21 deletions
+211 -8
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """
expires_at,
last_used_at,
revoked_at,
created_at
created_at,
runtime_id
FROM auth_tokens
"""
@@ -58,7 +59,8 @@ _SELECT_PORTFOLIO = """
owner_user_id,
created_at,
updated_at,
last_trade_at
last_trade_at,
market
FROM portfolios
"""
@@ -300,6 +302,7 @@ class PostgresPortfolioRepository:
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
)
@staticmethod
@@ -332,6 +335,7 @@ class PostgresPortfolioRepository:
created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None,
market=row[15] if len(row) > 15 else None,
)
@staticmethod
@@ -532,6 +536,7 @@ class PostgresPortfolioRepository:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -542,14 +547,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at
expires_at, last_used_at, revoked_at, created_at, runtime_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -612,7 +617,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
@@ -629,6 +634,7 @@ class PostgresPortfolioRepository:
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
@@ -1296,6 +1302,203 @@ class PostgresPortfolioRepository:
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
# ------------------------------------------------------------------
# Broker credentials
# ------------------------------------------------------------------
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
if row is None:
raise ValueError("자격증명을 찾을 수 없습니다")
return BrokerCredential(
user_id=row[0],
broker=row[1],
account_ref=row[2],
encrypted_payload=row[3],
credential_version=int(row[4]),
last_rotated_at=row[5] if len(row) > 5 else None,
)
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s AND broker = %s
""",
(user_id, broker),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_broker_credential(row)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
VALUES (%s, %s, %s, %s, 1, NOW())
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
# ------------------------------------------------------------------
# Ledger trades
# ------------------------------------------------------------------
@staticmethod
def _row_to_ledger_trade(row: Any) -> LedgerTrade:
if row is None:
raise ValueError("원장 거래를 찾을 수 없습니다")
return LedgerTrade(
ledger_entry_id=int(row[0]),
asset_id=row[1],
order_id=row[2],
execution_id=row[3],
side=row[4],
quantity=float(row[5]),
price=float(row[6]),
fee_amount=float(row[7]),
tax_amount=float(row[8]),
executed_at=row[9],
recorded_at=row[10] if len(row) > 10 else None,
)
def insert_ledger_trade(
self,
asset_id: str,
order_id: str,
execution_id: str,
side: str,
quantity: float,
price: float,
executed_at: datetime,
*,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple[LedgerTrade, bool]:
"""Insert a ledger trade, returning (trade, created).
Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id)
triplet already exists (idempotent).
"""
from decimal import Decimal
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO ledger_trades
(asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
""",
(
asset_id,
order_id,
execution_id,
side,
Decimal(str(quantity)),
Decimal(str(price)),
Decimal(str(fee_amount)),
Decimal(str(tax_amount)),
executed_at,
),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), True
except psycopg.errors.UniqueViolation:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s AND order_id = %s AND execution_id = %s
""",
(asset_id, order_id, execution_id),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), False
def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s
ORDER BY executed_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(asset_id, limit),
)
rows = cur.fetchall()
return [self._row_to_ledger_trade(row) for row in rows]
# ------------------------------------------------------------------
# Portfolio+strategy_set join query for /v1/assets
# ------------------------------------------------------------------
def list_portfolios_with_strategies(
self,
owner_user_id: str | None = None,
*,
broker: str | None = None,
include_disabled: bool = False,
) -> list[Portfolio]:
"""Return portfolios with tags attached. Optionally filter by owner or broker."""
conditions: list[str] = []
params: list[object] = []
if not include_disabled:
conditions.append("p.status != 'disabled'")
if owner_user_id is not None:
conditions.append("p.owner_user_id = %s")
params.append(owner_user_id)
if broker is not None:
conditions.append("p.broker = %s")
params.append(broker)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with self._connect().cursor() as cur:
cur.execute(
f"""
SELECT
p.portfolio_id, p.broker, p.status, p.name, p.symbol,
p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer,
p.active_strategy_id, p.avabile_strategies, p.owner_user_id,
p.created_at, p.updated_at, p.last_trade_at, p.market
FROM portfolios p
{where}
ORDER BY p.updated_at DESC, p.portfolio_id ASC
""",
params,
)
rows = cur.fetchall()
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(