feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)

新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
This commit is contained in:
张胜宇
2026-09-12 15:50:37 +08:00
committed by ZSY
parent 615032ab00
commit ebc3fe4cbe
11 changed files with 1848 additions and 0 deletions
+151
View File
@@ -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)
+196
View File
@@ -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