Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
633 lines
20 KiB
Python
633 lines
20 KiB
Python
"""Schemas for v1 (Consumer-Driven Contract) API endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET/POST/PATCH /v1/tags (REQ-004)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TagItem(BaseModel):
|
|
tag_id: str
|
|
name: str
|
|
is_admin_tag: bool
|
|
is_active: bool
|
|
created_at: datetime | None = None
|
|
|
|
|
|
class TagCreateRequest(BaseModel):
|
|
name: str = Field(..., min_length=1)
|
|
|
|
|
|
class TagListResponse(BaseModel):
|
|
tags: list[TagItem]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST/DELETE /v1/assets/{id}/tags, /v1/portfolios/{id}/tags (REQ-005)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TagApplyRequest(BaseModel):
|
|
tag_id: str = Field(..., min_length=1)
|
|
|
|
|
|
class TagApplyResponse(BaseModel):
|
|
tag_id: str
|
|
status: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/assets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TagRefItem(BaseModel):
|
|
tag_id: str
|
|
archived: bool = False
|
|
|
|
|
|
class AssetItem(BaseModel):
|
|
asset_id: str
|
|
symbol: str
|
|
status: str
|
|
cash: float
|
|
quantity: float
|
|
broker_type: str
|
|
strategy_id: str | None = None
|
|
strategy_type: str | None = None
|
|
strategy_params: dict[str, Any] = {}
|
|
updated_at: datetime | None = None
|
|
# Additional informational fields
|
|
name: str | None = None
|
|
price: float | None = None
|
|
profit_buffer: float | None = None
|
|
tags: list[str] | None = None
|
|
tag_ids: list[TagRefItem] | None = None
|
|
owner_user_id: str | None = None
|
|
created_at: datetime | None = None
|
|
last_trade_at: datetime | None = None
|
|
|
|
|
|
class AssetsResponse(BaseModel):
|
|
assets: list[AssetItem]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/user_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class BrokerItem(BaseModel):
|
|
broker: str
|
|
account_ref: str
|
|
credential_payload: dict[str, Any] | None = None
|
|
credential_version: int = 1
|
|
last_rotated_at: datetime | None = None
|
|
|
|
|
|
class UserInfoResponse(BaseModel):
|
|
runtime_id: str | None = None
|
|
daemon_id: str | None = None
|
|
trading_enabled: bool
|
|
disabled_reason: str | None = None
|
|
broker: BrokerItem | None = None
|
|
# Additional informational fields
|
|
user_id: str
|
|
username: str
|
|
email: str | None = None
|
|
permission: 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
|
|
portfolio_id: str
|
|
order_id: str | None
|
|
execution_id: str | None
|
|
side: str
|
|
quantity: float
|
|
price: float
|
|
fee_amount: float
|
|
tax_amount: float
|
|
executed_at: datetime
|
|
recorded_at: datetime | None = None
|
|
cash_balance_after: float = 0.0
|
|
asset_quantity_after: float = 0.0
|
|
status: Literal["new", "duplicate"] = "new"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST/GET /v1/portfolios/{id}/ledger/manual (REQ-056)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ManualLedgerAction = Literal[
|
|
"INVEST",
|
|
"BUFFER_OUT",
|
|
"BUFFER_IN",
|
|
"BUY",
|
|
"SELL",
|
|
"WITHDRAW",
|
|
"PROFIT",
|
|
"DIVIDEND",
|
|
"REVERSAL",
|
|
]
|
|
|
|
|
|
class ManualLedgerRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
action: ManualLedgerAction
|
|
amount: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
|
quantity: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
|
price: Decimal | None = Field(default=None, gt=0, max_digits=20, decimal_places=8)
|
|
fee: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8)
|
|
tax: Decimal = Field(default=Decimal("0"), ge=0, max_digits=20, decimal_places=8)
|
|
executed_at: date | None = None
|
|
target_ledger_entry_id: int | None = None
|
|
note: str = ""
|
|
confirm_daemon_managed_manual_write: bool = False
|
|
confirm_duplicate: bool = False
|
|
confirmation_evidence: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_action_fields(self) -> "ManualLedgerRequest":
|
|
amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}
|
|
trade_actions = {"BUY", "SELL"}
|
|
provided_fields = self.model_fields_set
|
|
fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields
|
|
fee_provided = "fee" in provided_fields
|
|
executed_at_provided = "executed_at" in provided_fields
|
|
confirm_duplicate_provided = "confirm_duplicate" in provided_fields
|
|
if self.action in amount_actions:
|
|
if self.amount is None:
|
|
raise ValueError("amount is required for this action")
|
|
if (
|
|
self.quantity is not None
|
|
or self.price is not None
|
|
or self.target_ledger_entry_id is not None
|
|
or fee_or_tax_provided
|
|
or executed_at_provided
|
|
or confirm_duplicate_provided
|
|
):
|
|
raise ValueError("unexpected numeric/target field for amount action")
|
|
elif self.action == "DIVIDEND":
|
|
if self.amount is None:
|
|
raise ValueError("amount is required for this action")
|
|
if self.executed_at is None:
|
|
raise ValueError("executed_at is required for DIVIDEND")
|
|
if self.quantity is not None or self.price is not None or self.target_ledger_entry_id is not None or fee_provided:
|
|
raise ValueError("unexpected numeric/target field for DIVIDEND")
|
|
elif self.action in trade_actions:
|
|
if self.quantity is None or self.price is None:
|
|
raise ValueError("quantity and price are required for BUY/SELL")
|
|
if self.amount is not None or self.target_ledger_entry_id is not None or executed_at_provided or confirm_duplicate_provided:
|
|
raise ValueError("unexpected amount/target field for BUY/SELL")
|
|
elif self.action == "REVERSAL":
|
|
if self.target_ledger_entry_id is None:
|
|
raise ValueError("target_ledger_entry_id is required for REVERSAL")
|
|
if (
|
|
self.amount is not None
|
|
or self.quantity is not None
|
|
or self.price is not None
|
|
or fee_or_tax_provided
|
|
or executed_at_provided
|
|
or confirm_duplicate_provided
|
|
):
|
|
raise ValueError("unexpected numeric field for REVERSAL")
|
|
return self
|
|
|
|
|
|
class ManualLedgerResponse(BaseModel):
|
|
ledger_entry_id: int
|
|
portfolio_id: str
|
|
action: str
|
|
amount: float | None = None
|
|
quantity: float = 0.0
|
|
price: float = 0.0
|
|
fee: float = 0.0
|
|
tax: float = 0.0
|
|
executed_at: datetime | None = None
|
|
note: str = ""
|
|
recorded_at: datetime | None = None
|
|
cash_balance_after: float = 0.0
|
|
asset_quantity_after: float = 0.0
|
|
profit_buffer_after: float = 0.0
|
|
total_value_after: float = 0.0
|
|
source: str = "manual"
|
|
target_ledger_entry_id: int | None = None
|
|
confirmation_evidence: str | None = None
|
|
|
|
|
|
class ManualLedgerHistoryResponse(BaseModel):
|
|
items: list[ManualLedgerResponse]
|
|
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
|
|
amount: 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):
|
|
portfolio_id: str
|
|
total_invested: float = Field(
|
|
default=0.0,
|
|
description="총투자원금: source와 무관한 유효 INVEST의 gross 합계",
|
|
)
|
|
total_withdrawn: float = Field(
|
|
default=0.0,
|
|
description="총회수원금: source와 무관한 유효 WITHDRAW 합계",
|
|
)
|
|
net_contributed_capital: float = Field(
|
|
default=0.0,
|
|
description="순투자원금: total_invested - total_withdrawn (PROFIT 제외)",
|
|
)
|
|
cumulative_profit_withdrawn: float = Field(
|
|
default=0.0,
|
|
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST/PATCH/DELETE/GET /v1/portfolios (REQ-025, REQ-026, REQ-027, REQ-028)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PortfolioCreateRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
asset_id: str = Field(..., min_length=1)
|
|
broker: str = Field(..., min_length=1)
|
|
name: str | None = None
|
|
cash_balance: float = Field(default=0.0, ge=0.0)
|
|
asset_quantity: float = Field(default=0.0, ge=0.0)
|
|
asset_price: float = Field(default=0.0, ge=0.0)
|
|
profit_buffer: float = Field(default=0.0, ge=0.0)
|
|
strategy_id: str | None = Field(default=None, min_length=1)
|
|
managed_by_daemon_id: str | None = None
|
|
user_id: str | None = None # admin-only: override owner
|
|
|
|
|
|
class PortfolioUpdateRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
broker: str | None = None
|
|
status: str | None = None
|
|
name: str | None = None
|
|
strategy_id: str | None = Field(default=None, min_length=1)
|
|
asset_price: float | None = Field(default=None, ge=0.0)
|
|
managed_by_daemon_id: str | None = None
|
|
|
|
|
|
class PortfolioItem(BaseModel):
|
|
portfolio_id: str
|
|
asset_id: str | None = None
|
|
symbol: str | None = None
|
|
display_name: str | None = None
|
|
owner_user_id: str | None = None
|
|
broker: str
|
|
status: str
|
|
name: str
|
|
cash_balance: float = 0.0
|
|
asset_quantity: float = 0.0
|
|
price: float = 0.0
|
|
profit_buffer: float = 0.0
|
|
net_contributed_capital: float = Field(
|
|
default=0.0,
|
|
description="순투자원금: source와 무관한 유효 INVEST 합계 - 유효 WITHDRAW 합계 (PROFIT 제외)",
|
|
)
|
|
cumulative_profit_withdrawn: float = Field(
|
|
default=0.0,
|
|
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
|
|
)
|
|
tag_ids: list[str] = Field(default_factory=list)
|
|
managed_by_daemon_id: str | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
# REQ-055: 전략·시장 메타데이터 additive 필드
|
|
active_strategy_id: str | None = None
|
|
strategy_name: str | None = None
|
|
strategy_nick: str | None = None
|
|
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
|
strategy_revision: int | None = Field(default=None, ge=1)
|
|
markets: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class PortfolioListResponse(BaseModel):
|
|
portfolios: list[PortfolioItem]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/strategies, CRUD /v1/admin/strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
StrategyStatus = Literal["active", "inactive"]
|
|
|
|
|
|
class StrategyResponse(BaseModel):
|
|
strategy_id: str
|
|
nick: str
|
|
name: str
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
status: StrategyStatus
|
|
revision: int = Field(ge=1)
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
class StrategyCreateRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str = Field(..., min_length=1)
|
|
nick: str | None = Field(default=None, min_length=1)
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
status: StrategyStatus = "active"
|
|
|
|
@field_validator("nick")
|
|
@classmethod
|
|
def normalize_nick(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
raise ValueError("nick must not be null")
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
raise ValueError("nick must not be blank")
|
|
return normalized
|
|
|
|
|
|
class StrategyUpdateRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str | None = Field(default=None, min_length=1)
|
|
nick: str | None = Field(default=None, min_length=1)
|
|
config: dict[str, Any] | None = None
|
|
status: StrategyStatus | None = None
|
|
|
|
@field_validator("nick")
|
|
@classmethod
|
|
def normalize_nick(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
raise ValueError("nick must not be null")
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
raise ValueError("nick must not be blank")
|
|
return normalized
|
|
|
|
@model_validator(mode="after")
|
|
def require_change(self) -> "StrategyUpdateRequest":
|
|
if not self.model_fields_set:
|
|
raise ValueError("at least one field is required")
|
|
return self
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/portfolios/{id}/tags, CRUD /v1/portfolios/{id}/comments (REQ-030, REQ-031)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PortfolioTagListResponse(BaseModel):
|
|
tags: list[TagItem]
|
|
total: int
|
|
|
|
|
|
class PortfolioCommentItem(BaseModel):
|
|
comment_id: str
|
|
portfolio_id: str
|
|
content: str
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
class PortfolioCommentRequest(BaseModel):
|
|
content: str = Field(..., min_length=1)
|
|
|
|
|
|
class PortfolioCommentUpdateRequest(BaseModel):
|
|
content: str = Field(..., min_length=1)
|
|
|
|
|
|
class PortfolioCommentListResponse(BaseModel):
|
|
comments: list[PortfolioCommentItem]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST/PATCH /v1/assets (admin catalog management — REQ-010)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AssetCatalogCreateRequest(BaseModel):
|
|
symbol: str = Field(..., min_length=1)
|
|
markets: list[str] = Field(default_factory=list)
|
|
display_name: str | None = None
|
|
status: str = "active"
|
|
|
|
|
|
class AssetCatalogUpdateRequest(BaseModel):
|
|
markets: list[str] | None = None
|
|
display_name: str | None = None
|
|
status: str | None = None
|
|
|
|
|
|
class AssetCatalogResponse(BaseModel):
|
|
asset_id: str
|
|
symbol: str
|
|
markets: list[str]
|
|
display_name: str | None = None
|
|
status: str
|
|
tag_ids: list[TagRefItem] = Field(default_factory=list)
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
class AssetCatalogListResponse(BaseModel):
|
|
items: list[AssetCatalogResponse]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/portfolios/{id}/trades (REQ-039)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TradeHistoryItem(BaseModel):
|
|
ledger_entry_id: int
|
|
portfolio_id: str
|
|
order_id: str | None = None
|
|
execution_id: str | None = None
|
|
side: str
|
|
quantity: float
|
|
price: float
|
|
fee_amount: float
|
|
tax_amount: float
|
|
executed_at: datetime
|
|
recorded_at: datetime | None = None
|
|
|
|
|
|
class TradeHistoryResponse(BaseModel):
|
|
items: list[TradeHistoryItem]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/portfolios/{id}/pnl/realized (REQ-040)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RealizedPnlItem(BaseModel):
|
|
symbol: str | None = None
|
|
executed_at: datetime | None = None
|
|
quantity: float
|
|
sell_price: float
|
|
cost_basis_per_unit: float
|
|
fee_amount: float
|
|
tax_amount: float
|
|
realized_pnl: float
|
|
order_id: str | None = None
|
|
execution_id: str | None = None
|
|
|
|
|
|
class RealizedPnlResponse(BaseModel):
|
|
total_realized_pnl: float
|
|
items: list[RealizedPnlItem]
|
|
cost_basis_from: datetime | None = None
|
|
data_completeness_warning: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/portfolios/{id}/holdings/cost-basis (REQ-043)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class HoldingsCostBasisResponse(BaseModel):
|
|
portfolio_id: str
|
|
symbol: str | None = None
|
|
current_quantity: float
|
|
cost_basis_per_unit: float | None = None
|
|
total_cost_basis: float | None = None
|
|
cost_basis_from: datetime | None = None
|
|
data_completeness_warning: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/daemons (REQ-045)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DaemonResponse(BaseModel):
|
|
id: str
|
|
daemon_slug: str
|
|
display_name: str | None = None
|
|
is_active: bool
|
|
|
|
|
|
class DaemonListResponse(BaseModel):
|
|
daemons: list[DaemonResponse]
|
|
total: int
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET/PUT/DELETE /v1/daemons/{daemon_id}/credential (REQ-052)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CredentialSetRequest(BaseModel):
|
|
"""Credential write request — app_key/app_secret/account_ref inputs only.
|
|
|
|
Fernet-encrypted server-side; secret never returned in any response.
|
|
"""
|
|
app_key: str = Field(..., min_length=1)
|
|
app_secret: str = Field(..., min_length=1)
|
|
account_ref: str = Field(..., min_length=1)
|
|
broker: str = Field(default="kis", min_length=1)
|
|
|
|
|
|
class CredentialMetaResponse(BaseModel):
|
|
"""Credential metadata response — secret fields intentionally absent (REQ-052, REQ-054)."""
|
|
daemon_id: str
|
|
broker: str
|
|
account_ref: str
|
|
has_credential: bool
|
|
credential_version: int
|
|
last_rotated_at: datetime | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST/PATCH /v1/admin/daemons (REQ-053)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DaemonCreateRequest(BaseModel):
|
|
daemon_slug: str = Field(..., min_length=1)
|
|
display_name: str | None = None
|
|
owner_user_id: str = Field(..., min_length=1)
|
|
|
|
|
|
class DaemonUpdateRequest(BaseModel):
|
|
display_name: str | None = None
|
|
owner_user_id: str | None = None
|
|
|
|
|
|
class DaemonAdminResponse(BaseModel):
|
|
"""Admin daemon response — no credential fields (REQ-054)."""
|
|
id: str
|
|
daemon_slug: str
|
|
display_name: str | None = None
|
|
owner_user_id: str
|
|
is_active: bool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /v1/admin/daemons (REQ-053 목록 조회)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DaemonDetail(BaseModel):
|
|
"""Single daemon item for admin list — matches trex-web DaemonDetail contract."""
|
|
id: str
|
|
daemon_slug: str
|
|
display_name: str | None = None
|
|
is_active: bool
|
|
owner_user_id: str
|
|
|
|
|
|
class DaemonDetailListResponse(BaseModel):
|
|
"""Response for GET /v1/admin/daemons — matches trex-web DaemonDetailListResponse."""
|
|
daemons: list[DaemonDetail]
|
|
total: int
|