183 lines
6.1 KiB
Python
183 lines
6.1 KiB
Python
"""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
|