客户看板「今日盈亏」不再是硬编码 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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user