Files
group_fqcd_jr/tests/unit/service/test_trade_service.py
T

235 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 datetime import UTC, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.api.schemas.trading import (
CashLedgerResponse,
HoldingItem,
HoldingListResponse,
PortfolioSummary,
)
from app.core.contracts import RequestContext
from app.core.errors import FundQuoteUnavailableError, SuitabilityMismatchError
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
@pytest.mark.asyncio
async def test_quote_freshness_only_blocks_trade_path() -> None:
"""只读资产可展示最近快照,但真实下单路径仍拒绝过期行情。"""
stale_quote = SimpleNamespace(
close_price=Decimal("4.5000"),
source_updated_at=(datetime.now(UTC).replace(tzinfo=None) - timedelta(minutes=16)),
source="eastmoney_demo_seed",
total_fund_shares=Decimal("10000000000"),
)
session = SimpleNamespace(
execute=AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: stale_quote))
)
service = TradeService(session=session) # type: ignore[arg-type]
product = SimpleNamespace(id=1, product_code="510300")
snapshot = await service._fetch_quote(product, enforce_freshness=False) # type: ignore[arg-type]
assert snapshot.price == Decimal("4.5000")
with pytest.raises(FundQuoteUnavailableError, match="行情已过期"):
await service._fetch_quote(product, enforce_freshness=True) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_trade_suitability_uses_request_context_and_denies_mismatch() -> None:
evaluator = SimpleNamespace(
evaluate=AsyncMock(
return_value=SimpleNamespace(allowed=False, reason_code="RISK_LEVEL_MISMATCH")
)
)
service = TradeService(session=None, suitability_evaluator=evaluator) # type: ignore[arg-type]
product = SimpleNamespace(
product_code="510500",
risk_level="R4",
risk_disclosure_required=1,
second_confirmation_required=0,
)
context = RequestContext(
user_id="9001", trace_id="trade-test", roles=("customer",), customer_ids=("9001",)
)
with pytest.raises(SuitabilityMismatchError, match="RISK_LEVEL_MISMATCH"):
await service._check_suitability(9001, product, context) # type: ignore[arg-type]
evaluator.evaluate.assert_awaited_once()
assert evaluator.evaluate.await_args.kwargs["context"] is context