209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""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.errors import FundQuoteUnavailableError
|
||
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]
|