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