Files
group_fqcd_jr/tests/unit/service/test_trade_service.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

421 lines
15 KiB
Python
Raw Permalink 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, date, 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 ZERO, TradeService, _FeeRule, _TodayTradeFlow
# ---------------------------------------------------------------------------
# 替身:与 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
# ---------------------------------------------------------------------------
# 今日盈亏(2026-09-14:由硬编码 0 改为真实计算)
# ---------------------------------------------------------------------------
def _flow(buy_shares: str, sell_shares: str, buy_gross: str, sell_gross: str) -> _TodayTradeFlow:
return _TodayTradeFlow(
buy_shares=Decimal(buy_shares),
sell_shares=Decimal(sell_shares),
buy_gross=Decimal(buy_gross),
sell_gross=Decimal(sell_gross),
)
def test_today_profit_loss_without_trades_is_quantity_times_price_move() -> None:
"""无当日成交:今日盈亏 = 持仓数量 ×(今收 − 昨收),分母 = 昨日持仓市值。"""
today_pl, base = TradeService._compute_today_profit_loss(
quantity=Decimal("1000.0000"),
today_value=Decimal("5.000000"),
prev_value=Decimal("4.800000"),
flow=_flow("0", "0", "0", "0"),
)
assert today_pl == Decimal("200.00")
assert base == Decimal("4800.00")
def test_today_profit_loss_counts_only_intraday_move_for_shares_bought_today() -> None:
"""当日买入的份额不享受昨日→今日的涨幅,只算「买入价 vs 今收」。"""
# 10001 的 510300:当日买入 300 份、成交额 1365.75,收盘 4.552(买入均价 4.5525)
today_pl, base = TradeService._compute_today_profit_loss(
quantity=Decimal("300.0000"),
today_value=Decimal("4.552000"),
prev_value=Decimal("4.579000"),
flow=_flow("300", "0", "1365.75", "0"),
)
assert today_pl == Decimal("-0.15")
# 分母 = 今日市值 − 今日盈亏 = 当日买入成本,避免"当日建仓 → 除以 0"
assert base == Decimal("1365.75")
def test_today_profit_loss_keeps_prev_close_gain_on_shares_sold_today() -> None:
"""当日卖出的份额仍要算「昨收 → 卖出价」的当日已实现盈亏。"""
today_pl, base = TradeService._compute_today_profit_loss(
quantity=Decimal("600.0000"),
today_value=Decimal("4.552000"),
prev_value=Decimal("4.579000"),
flow=_flow("0", "400", "0", "1960.00"),
)
# 600×4.552 − 1000×4.579 + 1960 = 112.20
assert today_pl == Decimal("112.20")
assert base == Decimal("2619.00")
def test_today_profit_loss_handles_same_day_round_trip() -> None:
"""当日先买后卖:买的部分只算成交价差,净额按成交量归零。"""
today_pl, base = TradeService._compute_today_profit_loss(
quantity=Decimal("1000.0000"),
today_value=Decimal("5.100000"),
prev_value=Decimal("5.000000"),
flow=_flow("500", "500", "2500.00", "2550.00"),
)
# 1000×5.1 − 1000×5.0 − 2500 + 2550 = 150.00
assert today_pl == Decimal("150.00")
assert base == Decimal("4950.00")
def test_today_profit_loss_is_zero_when_implied_open_quantity_is_negative() -> None:
"""当日买入份额 > 持仓+卖出(持仓表与成交对不上)→ 基准不可信,记 0 不硬算。"""
today_pl, base = TradeService._compute_today_profit_loss(
quantity=Decimal("1000.0000"),
today_value=Decimal("5.000000"),
prev_value=Decimal("4.800000"),
flow=_flow("3000", "0", "15000.00", "0"),
)
assert today_pl == ZERO
assert base == Decimal("5000.00")
class _FakeResult:
def __init__(self, rows: list[object]) -> None:
self._rows = rows
def scalars(self) -> _FakeResult:
return self
def all(self) -> list[object]:
return self._rows
class _QueuedSession:
"""按调用顺序吐出预置结果,用来驱动「行情 → 净值 → 成交」的分支。"""
def __init__(self, *results: list[object]) -> None:
self._results = list(results)
self.calls = 0
async def execute(self, _stmt: object) -> _FakeResult:
result = self._results[self.calls]
self.calls += 1
return _FakeResult(result)
def _price(day: str, close: str) -> SimpleNamespace:
return SimpleNamespace(trade_date=date.fromisoformat(day), close_price=Decimal(close))
def _nav(day: str, value: str) -> SimpleNamespace:
return SimpleNamespace(nav_date=date.fromisoformat(day), nav=Decimal(value))
def _txn(side: str, shares: str, gross: str, txn_type: str | None = None) -> SimpleNamespace:
return SimpleNamespace(
order_side=side,
transaction_type=txn_type or ("买入" if side == "buy" else "卖出"),
shares=Decimal(shares),
gross_amount=Decimal(gross),
)
@pytest.mark.asyncio
async def test_today_profit_loss_prefers_market_price_basis() -> None:
"""行情有两日 → 用行情(与持仓页 `latest_price` / `market_value` 同源、可核对)。"""
session = _QueuedSession(
[_price("2026-09-14", "4.552000"), _price("2026-09-11", "4.579000")],
[_txn("buy", "300", "1365.75")],
)
service = TradeService(session=session) # type: ignore[arg-type]
today_pl, base = await service._holding_today_profit_loss(
customer_id=10001,
product=SimpleNamespace(id=7, product_code="510300"), # type: ignore[arg-type]
quantity=Decimal("300.0000"),
market_value=Decimal("1365.60"),
)
assert today_pl == Decimal("-0.15")
assert base == Decimal("1365.75")
assert session.calls == 2, "行情够两日就不该再查净值"
@pytest.mark.asyncio
async def test_today_profit_loss_falls_back_to_nav_when_price_has_single_day() -> None:
"""行情只有一天(演示数据 15911 / 159991-159995)→ 回退净值基准。"""
session = _QueuedSession(
[_price("2026-09-14", "1.000000")],
[_nav("2026-09-14", "1.000000"), _nav("2026-09-11", "1.002122")],
[], # 当日无成交
)
service = TradeService(session=session) # type: ignore[arg-type]
today_pl, base = await service._holding_today_profit_loss(
customer_id=10001,
product=SimpleNamespace(id=8, product_code="15911"), # type: ignore[arg-type]
quantity=Decimal("1000.0000"),
market_value=Decimal("1000.00"),
)
assert today_pl == Decimal("-2.12") # 1000 × (1.000000 − 1.002122)
assert base == Decimal("1002.12")
@pytest.mark.asyncio
async def test_today_profit_loss_is_zero_without_previous_day_basis() -> None:
"""行情/净值都不足两日 → 记 0(不拿别的日期硬凑),分母退回今日市值。"""
session = _QueuedSession([_price("2026-09-14", "4.552000")], [_nav("2026-09-14", "4.552000")])
service = TradeService(session=session) # type: ignore[arg-type]
today_pl, base = await service._holding_today_profit_loss(
customer_id=10002,
product=SimpleNamespace(id=9, product_code="510300"), # type: ignore[arg-type]
quantity=Decimal("300.0000"),
market_value=Decimal("1365.60"),
)
assert today_pl == ZERO
assert base == Decimal("1365.60")