"""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")