Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""§T 用户自助场内基金模拟交易 controller(`docs/05` §19 T 段)。
|
||||
|
||||
端点与权限码(9 个端点 / 5 个权限码):
|
||||
|
||||
| § | 端点 | 权限码 | 摘要 |
|
||||
|---|---|---|---|
|
||||
| T001 | `GET /api/v1/users/me/account/dashboard` | `account:read:self` | 我的账户看板 |
|
||||
| T002 | `POST /api/v1/users/me/orders` | `trade:order:create` | 提交委托(首版市价立即成交) |
|
||||
| T003 | `GET /api/v1/users/me/orders` | `trade:order:read` | 委托列表 |
|
||||
| T004 | `GET /api/v1/users/me/orders/{order_no}` | `trade:order:read` | 委托详情 |
|
||||
| T005 | `POST /api/v1/users/me/orders/{order_no}/cancellations` | `trade:order:cancel` | 撤单 |
|
||||
| T006 | `GET /api/v1/users/me/holdings` | `holding:read:self` | 持仓列表 |
|
||||
| T007 | `GET /api/v1/users/me/transactions` | `trade:txn:read` | 成交记录列表 |
|
||||
| T008 | `GET /api/v1/users/me/transactions/{txn_no}` | `trade:txn:read` | 成交详情 |
|
||||
| T009 | `GET /api/v1/users/me/cash-ledger` | `account:read:self` | 资金明细 |
|
||||
|
||||
设计要点:
|
||||
- 全部走 `build_request_context`(与 memory / portfolio 一致),数据范围 `self`。
|
||||
- 不走限流依赖(`enforce_rate_limit`)——场内交易为低频,由底座网关层限流。
|
||||
- 信封用 `envelope` / `list_envelope`,与 §3.3 一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.schemas.trading import OrderCreateRequest
|
||||
from app.api.views.envelope import envelope, list_envelope
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.trade_service import TradeService
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users/me", tags=["trading"])
|
||||
|
||||
|
||||
def _service(session: AsyncSession, context: RequestContext) -> TradeService:
|
||||
return TradeService(session)
|
||||
|
||||
|
||||
# T001 账户看板
|
||||
@router.get("/account/dashboard")
|
||||
async def get_account_dashboard(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
data = await _service(session, context).get_account_dashboard(context)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
# T002 提交委托(市价立即成交)
|
||||
@router.post("/orders", status_code=status.HTTP_201_CREATED)
|
||||
async def submit_order(
|
||||
payload: OrderCreateRequest,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
data = await _service(session, context).submit_order(payload, context)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
# T003 委托列表
|
||||
@router.get("/orders")
|
||||
async def list_orders(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
items, next_cursor = await _service(session, context).list_orders(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
)
|
||||
return list_envelope(
|
||||
{"items": items, "next_cursor": next_cursor, "has_more": next_cursor is not None},
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
# T004 委托详情
|
||||
@router.get("/orders/{order_no}")
|
||||
async def get_order(
|
||||
order_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
data = await _service(session, context).get_order(order_no, context)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
# T005 撤单
|
||||
@router.post("/orders/{order_no}/cancellations", status_code=status.HTTP_200_OK)
|
||||
async def cancel_order(
|
||||
order_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
order = await _service(session, context).cancel_order(order_no, context)
|
||||
return envelope(order, context)
|
||||
|
||||
|
||||
# T006 持仓列表
|
||||
@router.get("/holdings")
|
||||
async def list_holdings(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
data = await _service(session, context).list_holdings(context)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
# T007 成交记录列表
|
||||
@router.get("/transactions")
|
||||
async def list_transactions(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
data = await _service(session, context).list_transactions(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
)
|
||||
return envelope(data, context)
|
||||
|
||||
|
||||
# T008 成交详情
|
||||
@router.get("/transactions/{txn_no}")
|
||||
async def get_transaction(
|
||||
txn_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
item = await _service(session, context).get_transaction(txn_no, context)
|
||||
return envelope(item, context)
|
||||
|
||||
|
||||
# T009 资金明细
|
||||
@router.get("/cash-ledger")
|
||||
async def list_cash_ledger(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
cursor_id = int(cursor) if cursor else None
|
||||
data = await _service(session, context).list_cash_ledger(
|
||||
context, limit=limit, cursor=cursor_id
|
||||
)
|
||||
return envelope(data, context)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""场内基金模拟交易 API Schemas(§T 用户自助端点)。
|
||||
|
||||
按 `docs/05` §T 端点清单设计:账户看板、委托、持仓、成交、资金明细。
|
||||
请求/响应模型只承载字段契约,**业务校验**(产品可用性、适当性、持仓比例、
|
||||
成交价快照)由 Service 层在调用行情/账户后处理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
OrderSide = Literal["buy", "sell"]
|
||||
PriceType = Literal["market"] # 首版只支持市价(基线 §6.2 明确)
|
||||
|
||||
|
||||
class AccountSummary(BaseModel):
|
||||
"""虚拟资金账户快照。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_no: str
|
||||
status: str
|
||||
currency: str
|
||||
initial_balance: Decimal
|
||||
cash_balance: Decimal
|
||||
available_cash: Decimal
|
||||
frozen_cash: Decimal
|
||||
|
||||
|
||||
class PortfolioSummary(BaseModel):
|
||||
"""组合汇总指标。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
total_asset: Decimal
|
||||
total_market_value: Decimal
|
||||
total_cost: Decimal
|
||||
total_profit_loss: Decimal
|
||||
total_profit_loss_ratio: Decimal
|
||||
today_profit_loss: Decimal
|
||||
today_profit_loss_ratio: Decimal
|
||||
|
||||
|
||||
class HoldingItem(BaseModel):
|
||||
"""单只基金持仓(前端可直接渲染)。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
product_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
total_quantity: Decimal
|
||||
available_quantity: Decimal
|
||||
frozen_quantity: Decimal
|
||||
average_cost: Decimal
|
||||
cost_amount: Decimal
|
||||
latest_price: Decimal
|
||||
market_value: Decimal
|
||||
profit_loss: Decimal
|
||||
profit_loss_ratio: Decimal
|
||||
today_profit_loss: Decimal
|
||||
|
||||
|
||||
class AccountDashboardResponse(BaseModel):
|
||||
"""T001 `GET /api/v1/users/me/account/dashboard` 响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account: AccountSummary
|
||||
summary: PortfolioSummary
|
||||
holdings: list[HoldingItem]
|
||||
as_of: datetime
|
||||
|
||||
|
||||
class OrderCreateRequest(BaseModel):
|
||||
"""T002 提交委托请求。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
product_code: str = Field(min_length=1, max_length=32)
|
||||
order_side: OrderSide
|
||||
quantity: Decimal = Field(gt=Decimal("0"))
|
||||
price_type: PriceType = "market"
|
||||
|
||||
|
||||
class OrderCreateResponse(BaseModel):
|
||||
"""T002 提交委托响应(市价立即成交,首版 status='已成交')。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
order_no: str
|
||||
status: str
|
||||
executed_quantity: Decimal
|
||||
executed_price: Decimal
|
||||
gross_amount: Decimal
|
||||
fee_amount: Decimal
|
||||
net_amount: Decimal
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
class OrderSummary(BaseModel):
|
||||
"""委托列表项(T003 / T004)。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
order_no: str
|
||||
product_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
order_side: OrderSide
|
||||
price_type: PriceType
|
||||
quantity: Decimal
|
||||
limit_price: Decimal | None
|
||||
quote_price: Decimal
|
||||
quote_at: datetime
|
||||
filled_quantity: Decimal
|
||||
average_executed_price: Decimal | None
|
||||
status: str
|
||||
submitted_at: datetime
|
||||
cancelled_at: datetime | None
|
||||
reject_reason: str | None
|
||||
|
||||
|
||||
class OrderCancelResponse(BaseModel):
|
||||
"""T005 撤单响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
order_no: str
|
||||
status: str
|
||||
cancelled_at: datetime
|
||||
|
||||
|
||||
class HoldingListResponse(BaseModel):
|
||||
"""T006 持仓列表响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
holdings: list[HoldingItem]
|
||||
|
||||
|
||||
class TransactionItem(BaseModel):
|
||||
"""成交记录列表项(T007 / T008)。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
transaction_no: str
|
||||
order_no: str
|
||||
product_id: int
|
||||
product_code: str
|
||||
product_name: str
|
||||
order_side: OrderSide
|
||||
executed_price: Decimal
|
||||
executed_quantity: Decimal
|
||||
gross_amount: Decimal
|
||||
fee_amount: Decimal
|
||||
net_amount: Decimal
|
||||
quote_at: datetime
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
"""T007 成交列表响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
transactions: list[TransactionItem]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class CashLedgerItem(BaseModel):
|
||||
"""资金明细项(T009)。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
ledger_no: str
|
||||
entry_type: str
|
||||
amount: Decimal
|
||||
balance_after: Decimal
|
||||
available_cash_after: Decimal
|
||||
frozen_cash_after: Decimal
|
||||
transaction_no: str | None
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
class CashLedgerResponse(BaseModel):
|
||||
"""T009 资金明细响应。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
entries: list[CashLedgerItem]
|
||||
next_cursor: str | None = None
|
||||
Reference in New Issue
Block a user