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