客户看板「今日盈亏」不再是硬编码 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
This commit is contained in:
2026-09-15 00:03:23 +08:00
parent 48b43cfad5
commit 9dd2802d67
7 changed files with 813 additions and 53 deletions
+188 -2
View File
@@ -12,7 +12,7 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -27,7 +27,7 @@ from app.api.schemas.trading import (
)
from app.core.contracts import RequestContext
from app.core.errors import FundQuoteUnavailableError, SuitabilityMismatchError
from app.service.trade_service import TradeService, _FeeRule
from app.service.trade_service import ZERO, TradeService, _FeeRule, _TodayTradeFlow
# ---------------------------------------------------------------------------
# 替身:与 SQLAlchemy 模型仅作"读取字段"用途一致的轻量对象
@@ -232,3 +232,189 @@ async def test_trade_suitability_uses_request_context_and_denies_mismatch() -> N
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")