Merge remote-tracking branch 'origin/qyqy_develop' into RM2_develop

This commit is contained in:
zhangshy
2026-09-12 16:12:02 +08:00
18 changed files with 3863 additions and 16 deletions
+10
View File
@@ -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 是
+182
View File
@@ -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