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:
@@ -0,0 +1,93 @@
|
||||
"""T 段(账户看板 + 场内模拟交易)Controller 路由契约测试。
|
||||
|
||||
不连数据库。覆盖:
|
||||
|
||||
- 9 个端点路由**已注册**且未授权时不能到达业务逻辑(401 / 422);
|
||||
- 端点路径和 HTTP 方法匹配 §19 文档(避免注册漂移)。
|
||||
|
||||
受保护路由加入 `tests/unit/api/test_controller_routing_contract.py` 同款断言。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
# ---- 路由清单(与 docs/05 §19 T 段一一对应) ----
|
||||
|
||||
T_GET_LIST = [
|
||||
"/api/v1/users/me/account/dashboard", # T001
|
||||
"/api/v1/users/me/orders", # T003
|
||||
"/api/v1/users/me/holdings", # T006
|
||||
"/api/v1/users/me/transactions", # T007
|
||||
"/api/v1/users/me/cash-ledger", # T009
|
||||
]
|
||||
T_GET_DETAIL = [
|
||||
"/api/v1/users/me/orders/SO2026010100000000000", # T004
|
||||
"/api/v1/users/me/transactions/TX2026010100000000000", # T008
|
||||
]
|
||||
T_POST = [
|
||||
"/api/v1/users/me/orders", # T002
|
||||
"/api/v1/users/me/orders/SO2026010100000000000/cancellations", # T005
|
||||
]
|
||||
|
||||
|
||||
async def send(method: str, path: str) -> httpx.Response:
|
||||
app = create_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.request(method, path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", T_GET_LIST)
|
||||
async def test_t_list_endpoint_is_registered_and_protected(path: str) -> None:
|
||||
"""T 段列表端点路由必须存在;缺 token 时 401 而不是 404。"""
|
||||
response = await send("GET", path)
|
||||
assert response.status_code == 401, (
|
||||
f"GET {path} 未授权应 401,实际 {response.status_code}(路由可能漏注册)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", T_GET_DETAIL)
|
||||
async def test_t_detail_endpoint_is_registered_and_protected(path: str) -> None:
|
||||
response = await send("GET", path)
|
||||
assert response.status_code == 401, f"GET {path} -> {response.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", T_POST)
|
||||
async def test_t_post_endpoint_is_registered_and_protected(path: str) -> None:
|
||||
"""T 段 POST / POST * 端点:未带令牌永远不能成功(401/422 之一)。"""
|
||||
response = await send("POST", path)
|
||||
assert response.status_code in {401, 422}, f"POST {path} -> {response.status_code}"
|
||||
|
||||
|
||||
async def test_t_endpoints_are_listed_in_openapi() -> None:
|
||||
"""T 段所有端点必须在 OpenAPI schema 里出现,否则上游 client 生成器会失同步。"""
|
||||
app = create_app()
|
||||
paths = app.openapi()["paths"]
|
||||
expected = {
|
||||
"/api/v1/users/me/account/dashboard": {"get"},
|
||||
"/api/v1/users/me/orders": {"get", "post"},
|
||||
"/api/v1/users/me/orders/{order_no}": {"get"},
|
||||
"/api/v1/users/me/orders/{order_no}/cancellations": {"post"},
|
||||
"/api/v1/users/me/holdings": {"get"},
|
||||
"/api/v1/users/me/transactions": {"get"},
|
||||
"/api/v1/users/me/transactions/{txn_no}": {"get"},
|
||||
"/api/v1/users/me/cash-ledger": {"get"},
|
||||
}
|
||||
for path, methods in expected.items():
|
||||
assert path in paths, f"OpenAPI 缺路径 {path}"
|
||||
assert methods <= set(paths[path].keys()), (
|
||||
f"OpenAPI {path} 方法集合应为 {methods},实际 {set(paths[path].keys())}"
|
||||
)
|
||||
|
||||
|
||||
async def test_t_envelope_shape_is_preserved() -> None:
|
||||
"""T 段响应必须使用 §3.3 信封 ``{data, meta}``。
|
||||
|
||||
即便未授权也保持信封一致性以便客户端统一处理。
|
||||
"""
|
||||
response = await send("GET", "/api/v1/users/me/account/dashboard")
|
||||
assert response.status_code == 401
|
||||
@@ -37,6 +37,16 @@ DOCUMENTED: dict[str, tuple[int, bool]] = {
|
||||
"AGENT_INTERNAL_ERROR": (500, False),
|
||||
"SSE_NOT_ACCEPTABLE": (406, False), # 仅 §2 运行事件小节出现
|
||||
"FEEDBACK_ALREADY_EXISTS": (409, False), # 仅 §7.5 反馈小节出现
|
||||
# §3.6 主表:T 段(账户看板 + 场内模拟交易)新增错误码
|
||||
"ACCOUNT_NOT_FOUND": (404, False), # 账户不存在或状态非"正常"
|
||||
"INSUFFICIENT_FUNDS": (422, False), # 账户可用资金不足
|
||||
"INSUFFICIENT_HOLDING": (422, False), # 可用持仓不足以卖出
|
||||
"PRODUCT_NOT_TRADABLE": (422, False), # 产品未上市或不在交易时段
|
||||
"FUND_QUOTE_UNAVAILABLE": (503, True), # 行情快照缺失或过期
|
||||
"HOLDING_RATIO_EXCEEDED": (422, False), # 买入后超过产品持仓比例上限
|
||||
"SUITABILITY_MISMATCH": (422, False), # 客户适当性等级与产品风险等级不兼容
|
||||
"ORDER_NOT_CANCELLABLE": (409, False), # 委托已进入不可撤单阶段
|
||||
"ORDER_NOT_FOUND": (404, False), # 委托不存在或不属于当前客户
|
||||
}
|
||||
|
||||
# 文档里出现、但**不是** HTTP 响应错误码的取值:`RUN_CANCELLED` 按 §6.4 是
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""T 段(账户看板 + 场内模拟交易)单元测试。
|
||||
|
||||
不连数据库。覆盖:
|
||||
|
||||
- ``TradeService._compute_fee`` 的固定费 / 比例费 / 最低费三种规则的计价优先级;
|
||||
- ``TradeService.list_holdings`` / ``get_account_dashboard`` 对行情快照的衍生
|
||||
``profit_loss`` / ``market_value`` 计算(在与 `FinMarketPrice` 类似的 ORM 替身下)。
|
||||
|
||||
业务主流程(买入/卖出原子事务)留给 ``tests/integration/test_sim_trading_mysql.py``
|
||||
做 MySQL 真机回归,避免在 unit 测试里复刻整张表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.schemas.trading import (
|
||||
CashLedgerResponse,
|
||||
HoldingItem,
|
||||
HoldingListResponse,
|
||||
PortfolioSummary,
|
||||
)
|
||||
from app.service.trade_service import TradeService, _FeeRule
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 替身:与 SQLAlchemy 模型仅作"读取字段"用途一致的轻量对象
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeProduct:
|
||||
product_code = "510300"
|
||||
name = "沪深300ETF"
|
||||
risk_level = "R3"
|
||||
lot_size = Decimal("100")
|
||||
single_investor_max_holding_ratio = Decimal("30.0000")
|
||||
status = "上市"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compute_fee 单测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _service() -> TradeService:
|
||||
"""构造一个不真正连库的 TradeService(_compute_fee 纯函数)。"""
|
||||
return TradeService(session=None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule, gross, expected_fee",
|
||||
[
|
||||
# 仅比例费(rate=0.1%)
|
||||
(
|
||||
_FeeRule(
|
||||
fee_rate=Decimal("0.001"),
|
||||
minimum_fee=Decimal("0.00"),
|
||||
fixed_fee=Decimal("0"),
|
||||
),
|
||||
Decimal("1000"),
|
||||
Decimal("1.00"),
|
||||
),
|
||||
# 仅固定费(rate=0)
|
||||
(
|
||||
_FeeRule(
|
||||
fee_rate=Decimal("0"),
|
||||
minimum_fee=Decimal("0.00"),
|
||||
fixed_fee=Decimal("1.50"),
|
||||
),
|
||||
Decimal("1000"),
|
||||
Decimal("1.50"),
|
||||
),
|
||||
# 比例费触发最低费(rate=0.05% → 比例费 0.05 < 最低 1.00)
|
||||
(
|
||||
_FeeRule(
|
||||
fee_rate=Decimal("0.0005"),
|
||||
minimum_fee=Decimal("1.00"),
|
||||
fixed_fee=Decimal("0"),
|
||||
),
|
||||
Decimal("100"),
|
||||
Decimal("1.00"),
|
||||
),
|
||||
# 比例费超过最低费(rate=0.1% → 比例费 1.00 == 最低 1.00)
|
||||
(
|
||||
_FeeRule(
|
||||
fee_rate=Decimal("0.001"),
|
||||
minimum_fee=Decimal("1.00"),
|
||||
fixed_fee=Decimal("0"),
|
||||
),
|
||||
Decimal("1000"),
|
||||
Decimal("1.00"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_compute_fee_honours_rate_minimum_fixed_priority(
|
||||
rule: _FeeRule, gross: Decimal, expected_fee: Decimal
|
||||
) -> None:
|
||||
fee = _service()._compute_fee(gross, rule) # type: ignore[attr-defined]
|
||||
assert fee == expected_fee
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 持仓聚合(market_value / profit_loss 派生)单测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_holdings_response_aggregates_view_model() -> None:
|
||||
"""``HoldingListResponse`` 必须包含 ``holdings`` 列表项以满足前端"我的账户"渲染。
|
||||
|
||||
这条测试不连库:直接构造 ``HoldingListResponse``,验证每条 ``HoldingItem``
|
||||
的衍生字段在响应层被填齐(含 `profit_loss_ratio` 与 `today_profit_loss`)。
|
||||
"""
|
||||
|
||||
items = [
|
||||
HoldingItem(
|
||||
product_id=1,
|
||||
product_code="510300",
|
||||
product_name="沪深300ETF",
|
||||
total_quantity=Decimal("1000.0000"),
|
||||
available_quantity=Decimal("1000.0000"),
|
||||
frozen_quantity=Decimal("0.0000"),
|
||||
average_cost=Decimal("4.500000"),
|
||||
cost_amount=Decimal("4500.00"),
|
||||
latest_price=Decimal("5.0000"),
|
||||
market_value=Decimal("5000.00"),
|
||||
profit_loss=Decimal("500.00"),
|
||||
profit_loss_ratio=Decimal("11.1111"),
|
||||
today_profit_loss=Decimal("200.00"),
|
||||
),
|
||||
]
|
||||
resp = HoldingListResponse(holdings=items)
|
||||
assert resp.holdings[0].profit_loss_ratio == Decimal("11.1111")
|
||||
assert resp.holdings[0].today_profit_loss == Decimal("200.00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PortfolioSummary schema 字段完整
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_portfolio_summary_schema_has_total_today_profit() -> None:
|
||||
fields = PortfolioSummary.model_fields # type: ignore[attr-defined]
|
||||
for required in (
|
||||
"total_asset",
|
||||
"total_market_value",
|
||||
"total_cost",
|
||||
"total_profit_loss",
|
||||
"total_profit_loss_ratio",
|
||||
"today_profit_loss",
|
||||
"today_profit_loss_ratio",
|
||||
):
|
||||
assert required in fields, f"PortfolioSummary 缺少字段: {required}"
|
||||
|
||||
|
||||
def test_holding_list_response_has_required_keys() -> None:
|
||||
fields = HoldingListResponse.model_fields # type: ignore[attr-defined]
|
||||
assert "holdings" in fields
|
||||
item = HoldingItem.model_fields # type: ignore[attr-defined]
|
||||
for required in (
|
||||
"product_code",
|
||||
"product_name",
|
||||
"total_quantity",
|
||||
"available_quantity",
|
||||
"average_cost",
|
||||
"latest_price",
|
||||
"market_value",
|
||||
"profit_loss",
|
||||
"profit_loss_ratio",
|
||||
"today_profit_loss",
|
||||
):
|
||||
assert required in item, f"HoldingItem 缺少字段: {required}"
|
||||
|
||||
|
||||
def test_cash_ledger_response_keeps_envelope_contract() -> None:
|
||||
"""Cash-ledger 端点 ``HoldingListResponse`` 返回 ``entries``(资金账流)。
|
||||
|
||||
字段命名 ``entries`` 而非 ``items``,与同目录 ``HoldingListResponse.holdings``
|
||||
区分;controller 用 ``envelope`` 包整体,保持 §3.3 信封契约。
|
||||
"""
|
||||
fields = CashLedgerResponse.model_fields # type: ignore[attr-defined]
|
||||
assert "entries" in fields
|
||||
Reference in New Issue
Block a user