"""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", "REVERSAL", ] class ManualLedgerRequest(BaseModel): model_config = ConfigDict(extra="forbid") action: ManualLedgerAction amount: Decimal | None = Field(default=None, gt=0) quantity: Decimal | None = Field(default=None, gt=0) price: Decimal | None = Field(default=None, gt=0) fee: Decimal = Field(default=Decimal("0"), ge=0) tax: Decimal = Field(default=Decimal("0"), ge=0) target_ledger_entry_id: int | None = None note: str = "" confirm_daemon_managed_manual_write: 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 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 ): raise ValueError("unexpected numeric/target field for amount action") 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: 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: 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 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 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 = Field(..., 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