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:
+25
-3
@@ -1,10 +1,32 @@
|
||||
"""Health and readiness endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
|
||||
@router.get("/health")
|
||||
def healthcheck() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
checked_at: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
def healthcheck() -> HealthResponse:
|
||||
settings = get_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service="portfolio-hub",
|
||||
version=settings.app_version,
|
||||
checked_at=datetime.now(_KST).isoformat(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""GET /v1/assets — service-token authenticated asset list endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.repositories.portfolio_repository import PortfolioRepository
|
||||
from app.schemas.v1 import AssetItem, AssetsResponse
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
|
||||
if auth.token_source != "db" or auth.token_type != "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Service token required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
@router.get("/assets", response_model=AssetsResponse)
|
||||
def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsResponse:
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
portfolios = repo.list_portfolios_with_strategies(
|
||||
owner_user_id=None,
|
||||
include_disabled=False,
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
items = [
|
||||
AssetItem(
|
||||
asset_id=p.portfolio_id,
|
||||
broker=p.broker,
|
||||
market=p.market,
|
||||
symbol=p.symbol,
|
||||
name=p.name,
|
||||
status=p.status,
|
||||
cash_balance=p.cash_balance,
|
||||
asset_quantity=p.asset_quantity,
|
||||
price=p.price,
|
||||
profit_buffer=p.profit_buffer,
|
||||
active_strategy_id=p.active_strategy_id,
|
||||
available_strategies=p.avabile_strategies,
|
||||
tags=p.tags,
|
||||
owner_user_id=p.owner_user_id,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
last_trade_at=p.last_trade_at,
|
||||
)
|
||||
for p in portfolios
|
||||
]
|
||||
return AssetsResponse(assets=items, total=len(items))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""POST /v1/assets/{asset_id}/ledger/trades — idempotent ledger trade recording."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import LedgerTradeRequest, LedgerTradeResponse
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
|
||||
if auth.token_source != "db" or auth.token_type != "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Service token required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets/{asset_id}/ledger/trades",
|
||||
response_model=LedgerTradeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def record_ledger_trade(
|
||||
asset_id: str,
|
||||
body: LedgerTradeRequest,
|
||||
auth: AuthContext = Depends(_require_service_token),
|
||||
) -> LedgerTradeResponse:
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
trade, created = repo.insert_ledger_trade(
|
||||
asset_id=asset_id,
|
||||
order_id=body.order_id,
|
||||
execution_id=body.execution_id,
|
||||
side=body.side,
|
||||
quantity=float(body.quantity),
|
||||
price=float(body.price),
|
||||
executed_at=body.executed_at,
|
||||
fee_amount=float(body.fee_amount),
|
||||
tax_amount=float(body.tax_amount),
|
||||
)
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
return LedgerTradeResponse(
|
||||
ledger_entry_id=trade.ledger_entry_id,
|
||||
asset_id=trade.asset_id,
|
||||
order_id=trade.order_id,
|
||||
execution_id=trade.execution_id,
|
||||
side=trade.side,
|
||||
quantity=trade.quantity,
|
||||
price=trade.price,
|
||||
fee_amount=trade.fee_amount,
|
||||
tax_amount=trade.tax_amount,
|
||||
executed_at=trade.executed_at,
|
||||
recorded_at=trade.recorded_at,
|
||||
created=created,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""GET /v1/user_info — service-token authenticated user info endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import UserInfoResponse
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
|
||||
if auth.token_source != "db" or auth.token_type != "service":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Service token required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
@router.get("/user_info", response_model=UserInfoResponse)
|
||||
def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserInfoResponse:
|
||||
if not auth.runtime_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="runtime_id not assigned to this token",
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
repo = get_portfolio_repository_from_settings(settings)
|
||||
try:
|
||||
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
disabled_reason: str | None = None
|
||||
if not user.is_active:
|
||||
disabled_reason = "account_inactive"
|
||||
|
||||
return UserInfoResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
permission=user.permission,
|
||||
is_active=user.is_active,
|
||||
disabled_reason=disabled_reason,
|
||||
runtime_id=auth.runtime_id,
|
||||
)
|
||||
@@ -78,6 +78,7 @@ def issue_service_token(
|
||||
name=name,
|
||||
expires_at=issued.auth_token.expires_at,
|
||||
revoked=issued.auth_token.revoked_at is not None,
|
||||
runtime_id=issued.auth_token.runtime_id,
|
||||
token=issued.token,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
@@ -28,6 +28,7 @@ class AuthContext:
|
||||
token_id: str | None = None
|
||||
token_type: str | None = None
|
||||
token_source: str = "env"
|
||||
runtime_id: str | None = None
|
||||
|
||||
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
|
||||
repository = get_portfolio_repository_from_settings(settings)
|
||||
@@ -66,6 +67,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR
|
||||
token_id=principal.token_id,
|
||||
token_type=principal.token_type,
|
||||
token_source="db",
|
||||
runtime_id=principal.runtime_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+17
-1
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field, computed_field
|
||||
from pydantic import Field, computed_field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Load .env file from project root
|
||||
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
|
||||
loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST")
|
||||
loki_port: int = Field(default=3100, validation_alias="LOKI_PORT")
|
||||
loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED")
|
||||
credential_encryption_key: str = Field(default="", validation_alias="CREDENTIAL_ENCRYPTION_KEY")
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="PORTFOLIOHUB_",
|
||||
@@ -58,6 +59,21 @@ class Settings(BaseSettings):
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_credential_encryption_key(self) -> "Settings":
|
||||
# Only validate at runtime when the key is actually used.
|
||||
# This avoids blocking test environments that don't exercise encryption.
|
||||
return self
|
||||
|
||||
def require_credential_encryption_key(self) -> str:
|
||||
"""Return the key or raise RuntimeError if it is not configured."""
|
||||
if not self.credential_encryption_key.strip():
|
||||
raise RuntimeError(
|
||||
"CREDENTIAL_ENCRYPTION_KEY is not configured. "
|
||||
"Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
return self.credential_encryption_key
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def allowed_origins(self) -> list[str]:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Fernet symmetric encryption utilities for credential payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken # noqa: F401 — re-exported for callers
|
||||
|
||||
|
||||
def make_fernet(key: str) -> Fernet:
|
||||
"""Return a Fernet instance from a base64-encoded key string."""
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
|
||||
|
||||
def encrypt_payload(key: str, plaintext: str) -> str:
|
||||
"""Encrypt *plaintext* and return the base64 ciphertext string."""
|
||||
f = make_fernet(key)
|
||||
return f.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_payload(key: str, ciphertext: str) -> str:
|
||||
"""Decrypt *ciphertext* and return the plaintext string.
|
||||
|
||||
Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the
|
||||
token is malformed.
|
||||
"""
|
||||
f = make_fernet(key)
|
||||
return f.decrypt(ciphertext.encode()).decode()
|
||||
@@ -41,6 +41,7 @@ class AuthToken:
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
runtime_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -49,6 +50,7 @@ class TokenPrincipal:
|
||||
user_id: str
|
||||
permission: str
|
||||
token_type: str
|
||||
runtime_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,6 +60,7 @@ class Portfolio:
|
||||
status: str
|
||||
name: str
|
||||
symbol: str
|
||||
market: str | None = None
|
||||
tags: list[str] | None = None
|
||||
owner_user_id: str | None = None
|
||||
cash_balance: float = 0.0
|
||||
@@ -107,3 +110,28 @@ class PortfolioTag:
|
||||
@runtime_checkable
|
||||
class LiveAssetSource(Protocol):
|
||||
def get_asset_quantity(self, symbol: str) -> float: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerCredential:
|
||||
user_id: str
|
||||
broker: str
|
||||
account_ref: str
|
||||
encrypted_payload: str
|
||||
credential_version: int = 1
|
||||
last_rotated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerTrade:
|
||||
ledger_entry_id: int
|
||||
asset_id: str
|
||||
order_id: str
|
||||
execution_id: str
|
||||
side: str
|
||||
quantity: float
|
||||
price: float
|
||||
fee_amount: float
|
||||
tax_amount: float
|
||||
executed_at: datetime
|
||||
recorded_at: datetime | None = None
|
||||
|
||||
@@ -11,6 +11,9 @@ from app.api.auth_routes import router as auth_router
|
||||
from app.api.portfolios import router as portfolio_router
|
||||
from app.api.strategy_sets import router as strategy_sets_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.v1_assets import router as v1_assets_router
|
||||
from app.api.v1_user_info import router as v1_user_info_router
|
||||
from app.api.v1_ledger import router as v1_ledger_router
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.core.security import hash_password
|
||||
@@ -73,6 +76,9 @@ def create_app(
|
||||
app.include_router(portfolio_router)
|
||||
app.include_router(strategy_sets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(v1_assets_router)
|
||||
app.include_router(v1_user_info_router)
|
||||
app.include_router(v1_ledger_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -75,6 +75,7 @@ class ServiceTokenIssueCliResponse(BaseModel):
|
||||
name: str
|
||||
expires_at: datetime | None = None
|
||||
revoked: bool
|
||||
runtime_id: str | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Schemas for v1 (Consumer-Driven Contract) API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/assets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AssetItem(BaseModel):
|
||||
asset_id: str
|
||||
broker: str
|
||||
market: str | None = None
|
||||
symbol: str
|
||||
name: str
|
||||
status: str
|
||||
cash_balance: float
|
||||
asset_quantity: float
|
||||
price: float
|
||||
profit_buffer: float
|
||||
active_strategy_id: str | None = None
|
||||
available_strategies: list[str] | None = None
|
||||
tags: list[str] | None = None
|
||||
owner_user_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
last_trade_at: datetime | None = None
|
||||
|
||||
|
||||
class AssetsResponse(BaseModel):
|
||||
assets: list[AssetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UserInfoResponse(BaseModel):
|
||||
user_id: str
|
||||
username: str
|
||||
email: str | None = None
|
||||
permission: str
|
||||
is_active: bool
|
||||
disabled_reason: str | None = None
|
||||
runtime_id: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/assets/{asset_id}/ledger/trades
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LedgerTradeRequest(BaseModel):
|
||||
order_id: str = Field(..., min_length=1)
|
||||
execution_id: str = Field(..., min_length=1)
|
||||
side: Literal["buy", "sell"]
|
||||
quantity: Decimal = Field(..., gt=0)
|
||||
price: Decimal = Field(..., gt=0)
|
||||
fee_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
class LedgerTradeResponse(BaseModel):
|
||||
ledger_entry_id: int
|
||||
asset_id: str
|
||||
order_id: str
|
||||
execution_id: str
|
||||
side: str
|
||||
quantity: float
|
||||
price: float
|
||||
fee_amount: float
|
||||
tax_amount: float
|
||||
executed_at: datetime
|
||||
recorded_at: datetime | None = None
|
||||
created: bool
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -131,6 +132,7 @@ class AuthService:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
raw_token = generate_token()
|
||||
runtime_id = str(uuid.uuid4())
|
||||
try:
|
||||
auth_token = self._repository.create_auth_token(
|
||||
user.id,
|
||||
@@ -140,6 +142,7 @@ class AuthService:
|
||||
permission=permission,
|
||||
token_type="service",
|
||||
expires_at=expires_at,
|
||||
runtime_id=runtime_id,
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
|
||||
|
||||
Reference in New Issue
Block a user