Files
group_fqcd_jr/tests/unit/service/test_trade_service.py
T
lzf_0626 9dd2802d67 客户看板「今日盈亏」不再是硬编码 0:按行情/净值基准真实计算(含当日买卖)
- 口径:今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额(不含费用,与「持有盈亏」同口径)
- 昨日持仓数量由当日成交反推,不需要新表新字段
- 基准优先场内行情最近两个交易日收盘价(与同页 latest_price/market_value 同源、客户可核对),
  行情只有一天时回退基金净值(15911/159991-159995 的行情本身就来自净值序列)
- 只认 transaction_type ∈ {买入,卖出}:演示库里混进的风控场外申购/赎回(RISKDEMO-*)
  会把「昨日持仓数量」抬到 76 万份、今日盈亏从 -21.22 变成 -1612.72
- 接口字段与前端一行未改:HoldingItem.today_profit_loss / PortfolioSummary 两个字段数值变真
- 新增 tools/check_today_profit_loss.py:纯 SQL 独立复算并与接口逐只比对(实测 9001 = -132.70 / -0.2528%)
- 新增 6 条单测;pytest tests/unit tests/contract → 1466 passed, 0 failed
2026-09-15 00:03:23 +08:00

421 lines
15 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, 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")