diff --git a/app/service/trade_service.py b/app/service/trade_service.py index f6f7938..bb4299a 100644 --- a/app/service/trade_service.py +++ b/app/service/trade_service.py @@ -14,6 +14,19 @@ - **生成列只读**:`fin_transaction.transaction_type/nav/shares/amount/fee/confirmed_at` 与 `fin_holding.shares/current_value` 是生成列,**本模块写入时不传这些字段**,仅由 MySQL 触发器/表达式生成。 +- **「今日盈亏」口径**(2026-09-14 由硬编码 `0` 改为真实计算,见 + `docs/演示用/今日盈亏实现说明-2026-09-14.md`): + + ``` + 今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用) + 昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 (由当日成交反推) + ``` + + 基准(`昨日每份价值` / `今日每份价值`)**优先取场内行情** + (`fin_market_price` 最近两个交易日收盘价,与持仓页 `latest_price` / `market_value` 同源、 + 可人工核对);行情只有一天时**回退基金净值**(`fin_nav_history` 最近两个净值日)—— + 演示数据里 `15911` / `159991-159995` 的行情本身就来自净值序列,只有一天。 + 两者都不足两日 → 该持仓记 `0`(当前数据下不可达)。 - **底座 ORM 只读约定**:`app/model/fund.py` 的注释明确"不提供任何写辅助方法"。 本服务**直接使用 session.add() 写入**——SQLAlchemy 标准 ORM 写入语义不违反该约定 (约定针对的是"业务便捷方法",session.add 是数据库访问基本操作)。 @@ -22,11 +35,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal from uuid import uuid4 -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.trading import ( @@ -61,6 +74,7 @@ from app.model.fund import ( FundFeeRule, FundHolding, FundMarketPrice, + FundNavHistory, FundProduct, FundSimAccount, FundSimOrder, @@ -93,6 +107,42 @@ class _FeeRule: fixed_fee: Decimal +@dataclass(frozen=True) +class _TodayBaseline: + """「今日盈亏」的基准:基准日与上一交易日的每份价值。 + + ``basis`` 只作排查用(`market_price` = 场内收盘价、`nav` = 基金净值), + 响应里不暴露该字段。 + """ + + ref_date: date + today_value: Decimal + prev_value: Decimal + basis: str + + +@dataclass(frozen=True) +class _TodayTradeFlow: + """基准日当天的成交汇总(份额与成交金额,**不含费用**)。""" + + buy_shares: Decimal + sell_shares: Decimal + buy_gross: Decimal + sell_gross: Decimal + + +@dataclass(frozen=True) +class _HoldingView: + """持仓行 + 该行「今日盈亏率」的分母(不进响应,只用于账户汇总)。""" + + item: HoldingItem + today_profit_loss_base: Decimal + + +_TODAY_PL_BASIS_MARKET = "market_price" +_TODAY_PL_BASIS_NAV = "nav" + + class TradeService: """场内模拟交易:账户看板、委托提交、撤单、列表、成交、资金明细。""" @@ -144,6 +194,156 @@ class TradeService: total_fund_shares=latest.total_fund_shares, ) + # ---------- 今日盈亏基准 ---------- + + async def _latest_two_trading_days(self, product_id: int) -> list[FundMarketPrice]: + """最近两个交易日的行情(按日期倒序)。 + + 取 8 行再去重:同一交易日可能因重复刷行情而有多行,直接 `limit(2)` 会拿到 + 同一天的两行、把「昨收」算成「今收」。 + """ + rows = ( + ( + await self._session.execute( + select(FundMarketPrice) + .where(FundMarketPrice.product_id == product_id) + .order_by(FundMarketPrice.trade_date.desc(), FundMarketPrice.id.desc()) + .limit(8) + ) + ) + .scalars() + .all() + ) + picked: list[FundMarketPrice] = [] + for row in rows: + if picked and picked[-1].trade_date == row.trade_date: + continue + picked.append(row) + if len(picked) == 2: + break + return picked + + async def _latest_two_nav_days(self, product_id: int) -> list[FundNavHistory]: + """最近两个净值日(按日期倒序,去重口径同行情)。""" + rows = ( + ( + await self._session.execute( + select(FundNavHistory) + .where(FundNavHistory.product_id == product_id) + .order_by(FundNavHistory.nav_date.desc(), FundNavHistory.id.desc()) + .limit(8) + ) + ) + .scalars() + .all() + ) + picked: list[FundNavHistory] = [] + for row in rows: + if picked and picked[-1].nav_date == row.nav_date: + continue + picked.append(row) + if len(picked) == 2: + break + return picked + + async def _today_baseline(self, product: FundProduct) -> _TodayBaseline | None: + """算出「基准日 / 上一交易日」的每份价值;不足两日返回 `None`。 + + 行情优先:它与持仓页展示的 `latest_price` / `market_value` 同源,客户能自己核对。 + """ + prices = await self._latest_two_trading_days(product.id) + if len(prices) == 2: + return _TodayBaseline( + ref_date=prices[0].trade_date, + today_value=prices[0].close_price, + prev_value=prices[1].close_price, + basis=_TODAY_PL_BASIS_MARKET, + ) + navs = await self._latest_two_nav_days(product.id) + if len(navs) == 2: + return _TodayBaseline( + ref_date=navs[0].nav_date, + today_value=navs[0].nav, + prev_value=navs[1].nav, + basis=_TODAY_PL_BASIS_NAV, + ) + return None + + async def _today_trade_flow( + self, *, customer_id: int, product_id: int, ref_date: date + ) -> _TodayTradeFlow: + """基准日当天该客户在该产品上的成交汇总(用于反推昨日持仓数量)。 + + **只认本平台的场内成交**(`transaction_type` ∈ {买入, 卖出})。演示库里 + `fin_transaction` 混进了风控演示用的场外申购/赎回(`RISKDEMO-*`,`transaction_type` + 是「申购」「赎回」,且不改持仓表):把它们算成当日买卖会让「昨日持仓数量」凭空多出 + 几十万份、今日盈亏变成几万元(实测 12001 从 −21.22 变成 −1612.72)。 + `AGENTS.md` 规则 8 也要求场外流程不写场内表 —— 这里按类型甄别,不依赖编号前缀。 + """ + rows = ( + ( + await self._session.execute( + select(FundTransaction).where( + FundTransaction.customer_id == customer_id, + FundTransaction.product_id == product_id, + FundTransaction.transaction_type.in_(("买入", "卖出")), + func.date(FundTransaction.confirmed_at) == ref_date, + ) + ) + ) + .scalars() + .all() + ) + buy_shares = sell_shares = buy_gross = sell_gross = ZERO + for txn in rows: + if txn.order_side == "buy": + buy_shares += txn.shares + buy_gross += txn.gross_amount + elif txn.order_side == "sell": + sell_shares += txn.shares + sell_gross += txn.gross_amount + return _TodayTradeFlow( + buy_shares=buy_shares, + sell_shares=sell_shares, + buy_gross=buy_gross, + sell_gross=sell_gross, + ) + + @staticmethod + def _compute_today_profit_loss( + *, + quantity: Decimal, + today_value: Decimal, + prev_value: Decimal, + flow: _TodayTradeFlow, + ) -> tuple[Decimal, Decimal]: + """返回 `(今日盈亏, 今日盈亏率的分母)`,均按两位小数取整。 + + ``` + 今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用) + 昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 + ``` + + **不含费用**是有意为之:与同页「持有盈亏 = 市值 − 成本」口径一致 + (`fin_holding.cost_amount` 也只累计买入的 `gross_amount`)。 + 分母取 `今日市值 − 今日盈亏`(= 昨日持仓市值 + 今日买入金额 − 今日卖出金额), + 这样「当日买入的部分」也能算出有意义的当日盈亏率,而不是除以 0。 + + `昨日持仓数量 < 0`(当日成交份额与持仓表对不上,正常路径不可达)时按「基准不可信」 + 记 0,不用一个自相矛盾的数量算出一个看着很确定的数。 + """ + qty_at_open = quantity - flow.buy_shares + flow.sell_shares + if qty_at_open < 0: + return ZERO, (quantity * today_value).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + today_pl = ( + quantity * today_value + - qty_at_open * prev_value + - flow.buy_gross + + flow.sell_gross + ).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + base = (quantity * today_value - today_pl).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + return today_pl, base + # ---------- 产品与适当性 ---------- async def _load_tradable_product(self, product_code: str) -> FundProduct: @@ -647,11 +847,17 @@ class TradeService: return self._to_transaction_item(row, product_map[row.product_id], order_map[row.order_id]) async def list_holdings(self, context: RequestContext) -> HoldingListResponse: + views = await self._holding_views(context) + return HoldingListResponse(holdings=[view.item for view in views]) + + async def _holding_views(self, context: RequestContext) -> list[_HoldingView]: + """持仓行 + 今日盈亏(含其分母)。持仓列表与账户看板共用,保证两处口径一致。""" + customer_id = int(context.user_id) rows = ( ( await self._session.execute( select(FundHolding).where( - FundHolding.customer_id == int(context.user_id), + FundHolding.customer_id == customer_id, FundHolding.status == "持有中", ) ) @@ -659,7 +865,7 @@ class TradeService: .scalars() .all() ) - items: list[HoldingItem] = [] + views: list[_HoldingView] = [] for h in rows: product = ( await self._session.execute( @@ -676,24 +882,59 @@ class TradeService: if h.cost_amount > 0 else ZERO ) - items.append( - HoldingItem( - product_id=product.id, - product_code=product.product_code, - product_name=product.product_name, - total_quantity=h.total_quantity, - available_quantity=h.available_quantity, - frozen_quantity=h.frozen_quantity, - average_cost=h.average_cost, - cost_amount=h.cost_amount, - latest_price=quote.price, - market_value=mv, - profit_loss=pl, - profit_loss_ratio=pl_ratio, - today_profit_loss=ZERO, + today_pl, today_base = await self._holding_today_profit_loss( + customer_id=customer_id, + product=product, + quantity=h.total_quantity, + market_value=mv, + ) + views.append( + _HoldingView( + item=HoldingItem( + product_id=product.id, + product_code=product.product_code, + product_name=product.product_name, + total_quantity=h.total_quantity, + available_quantity=h.available_quantity, + frozen_quantity=h.frozen_quantity, + average_cost=h.average_cost, + cost_amount=h.cost_amount, + latest_price=quote.price, + market_value=mv, + profit_loss=pl, + profit_loss_ratio=pl_ratio, + today_profit_loss=today_pl, + ), + today_profit_loss_base=today_base, ) ) - return HoldingListResponse(holdings=items) + return views + + async def _holding_today_profit_loss( + self, + *, + customer_id: int, + product: FundProduct, + quantity: Decimal, + market_value: Decimal, + ) -> tuple[Decimal, Decimal]: + """单只持仓的 `(今日盈亏, 今日盈亏率分母)`。 + + 缺少「上一交易日」基准时(行情与净值都不足两日)无法计算,记 0 且分母取今日市值 + —— 宁可显示 0 也不拿别的日期硬凑一个数(口径必须可解释)。 + """ + baseline = await self._today_baseline(product) + if baseline is None: + return ZERO, market_value + flow = await self._today_trade_flow( + customer_id=customer_id, product_id=product.id, ref_date=baseline.ref_date + ) + return self._compute_today_profit_loss( + quantity=quantity, + today_value=baseline.today_value, + prev_value=baseline.prev_value, + flow=flow, + ) async def list_cash_ledger( self, context: RequestContext, *, limit: int = 20, cursor: int | None = None @@ -735,14 +976,23 @@ class TradeService: async def get_account_dashboard(self, context: RequestContext) -> AccountDashboardResponse: account = await self._load_account(int(context.user_id)) - holdings_resp = await self.list_holdings(context) - total_mv = sum((h.market_value for h in holdings_resp.holdings), ZERO) - total_cost = sum((h.cost_amount for h in holdings_resp.holdings), ZERO) + views = await self._holding_views(context) + holdings = [view.item for view in views] + total_mv = sum((h.market_value for h in holdings), ZERO) + total_cost = sum((h.cost_amount for h in holdings), ZERO) total_pl = (total_mv - total_cost).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) total_pl_ratio = ( (total_pl / total_cost * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) if total_cost > 0 else ZERO ) + today_pl = sum((h.today_profit_loss for h in holdings), ZERO).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + today_base = sum((view.today_profit_loss_base for view in views), ZERO) + today_pl_ratio = ( + (today_pl / today_base * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + if today_base > 0 else ZERO + ) total_asset = (account.cash_balance + total_mv).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) return AccountDashboardResponse( account=AccountSummary( @@ -760,10 +1010,10 @@ class TradeService: total_cost=total_cost, total_profit_loss=total_pl, total_profit_loss_ratio=total_pl_ratio, - today_profit_loss=ZERO, - today_profit_loss_ratio=ZERO, + today_profit_loss=today_pl, + today_profit_loss_ratio=today_pl_ratio, ), - holdings=holdings_resp.holdings, + holdings=holdings, as_of=datetime.now(UTC).replace(tzinfo=None), ) diff --git a/docs/演示用/今日盈亏实现说明-2026-09-14.md b/docs/演示用/今日盈亏实现说明-2026-09-14.md new file mode 100644 index 0000000..8ef91af --- /dev/null +++ b/docs/演示用/今日盈亏实现说明-2026-09-14.md @@ -0,0 +1,113 @@ +# 今日盈亏:从硬编码 `0` 到真实计算(2026-09-14) + +## 一句话 + +客户看板 / 持仓页 / 盈亏分析页的「今日盈亏」原先三处都是**硬编码 `ZERO`**, +现在改成**真实计算**:`今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额`(不含交易费用)。 + +实测(`cust_t` / 9001,2026-09-14):**`-132.70`(`-0.2528%`)**,逐只持仓都有各自数值。 + +--- + +## 1. 为什么不是"读一个字段" + +数据库里**没有**任何一张表存"昨收"或"昨日持仓"。`fin_market_price` 是**日行情**, +`fin_nav_history` 是**净值历史**,两者都只有"每个交易日的值",没有"昨日持仓快照"。 + +所以「今日盈亏」必须**算**。而"昨日持仓数量"也不用快照表——可以从当日成交**反推**: + +``` +昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 +``` + +这个式子对"当日买、当日卖"同样成立,所以不需要任何新表、新字段(符合 `AGENTS.md` +"允许加字段但不许改含义"的约束——本次**一个字段都没加**)。 + +## 2. 口径(最终实现) + +``` +今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用) + = 数量 × 今值 − 昨日数量 × 昨值 − 当日买入成交额 + 当日卖出成交额 +``` + +三个关键取舍: + +| 取舍 | 选择 | 理由 | +|---|---|---| +| **基准取行情还是净值** | **行情优先**(最近两个交易日收盘价),**行情只有一天时回退净值** | 行情与同页的 `latest_price` / `market_value` 同源,客户能自己核对;净值兜底覆盖"行情只有一天"的 6 个产品(`15911` / `159991-159995`,它们的行情本身就来自净值序列) | +| **当日买入的份额怎么算** | 只算「买入价 → 今收」,**不享受**昨日到今日的涨幅 | 昨日它还不属于客户。反推式天然做到这点,不用特判 | +| **含不含交易费用** | **不含** | 与同页「持有盈亏 = 市值 − 成本」同口径(`fin_holding.cost_amount` 也只累计买入的 `gross_amount`)。含费用会和旁边那列对不上,反而像 bug | + +「今日盈亏率」的分母取 `今日市值 − 今日盈亏`(= 昨日持仓市值 + 今日买入金额 − 今日卖出金额): +这样**当日刚建仓**的持仓也能算出有意义的比率,而不是除以 0。 + +**语义边界**:这是「**最近一个交易日**相对前一交易日」的盈亏,**不是盘中实时**。 +当天行情/净值同步之前,它会与"昨日"一致——界面上要这么讲,否则会被当成 bug。 + +## 3. 踩到的三个坑(都是数据问题,不是公式问题) + +### 3.1 同一交易日可能有多行行情 + +`fin_market_price` 每次刷行情都可能再插一行。直接 `ORDER BY trade_date DESC LIMIT 2` +会拿到**同一天的两行**,把"昨收"算成"今收"(盈亏恒等 0)。 +→ 实现里取 8 行**按日期去重**后再取两日(`_latest_two_trading_days` / `_latest_two_nav_days`)。 + +### 3.2 演示库里混进了场外申购/赎回 + +`fin_transaction` 里有 6 行 `transaction_no` 形如 `RISKDEMO-*`、`transaction_type` 是 +「申购」「赎回」的记录(风控演示用,**不改持仓表**,违反 `AGENTS.md` 规则 8 但已在库里)。 +它们 `order_side` 是 `buy`/`sell`,若按 `order_side` 汇总,客户 12001 的"昨日持仓数量" +会被抬到 **760000 份**,今日盈亏从 `-21.22` 变成 **`-1612.72`**(多出两个数量级)。 +→ 实现里**只认 `transaction_type` ∈ {买入, 卖出}**(本平台场内下单的唯一写入值), +按**语义**甄别而不是按编号前缀。 + +### 3.3 货币 ETF 的净值与价格不同量纲 + +`511810` 的净值序列是 `0.24` 上下,而它的价格是 `100.003`(货币 ETF 的净值单位不同)。 +若拿净值做逐份盈亏,200 份会算出 `+1.58`,而价格实际是 `-1.80`。 +→ 这正是"**行情优先**"的另一个好处:`511810` 有两天行情,走行情基准,天然绕开这个坑。 + +## 4. 兼容性与前端 + +- **接口字段没变**:`HoldingItem.today_profit_loss`、`PortfolioSummary.today_profit_loss` / + `today_profit_loss_ratio`,类型仍是 `Decimal`(字符串化),**没有新增字段、没有改可空性**。 +- **前端一行没改**:客户看板卡片、持仓页表格列、盈亏分析页卡片本来就在渲染这个字段, + 数值一变就显示出来了。 +- 唯一行为变化:原来恒为 `0.00`(会被当成真实值),现在是真数。 + +## 5. 怎么证明它是对的 + +```bash +python tools/check_today_profit_loss.py # 默认 9001 / 10001 / 10002 / 12001 +python tools/check_today_profit_loss.py 9101 9102 # 也可指定客户号 +``` + +该脚本**用纯 SQL 独立复算一遍**(不复用服务层任何代码),再与 `TradeService.get_account_dashboard()` +的输出**逐只比对**,任何不一致都以 `!! 不一致` 标出并以退出码 1 结束。 + +2026-09-14 实测输出(节选): + +| 客户 | 产品 | 基准 | 今日盈亏 | +|---|---|---|---| +| 9001 | `159948` | 行情 `3.693000 → 3.649000` | `-8.80` | +| 9001 | `510300` | 行情 `4.579000 → 4.552000`,当日 5 笔成交(净额 0) | `-94.50` | +| 9001 | `510500` | 行情 `7.611000 → 7.598000`,当日卖出 100 份 | `-27.30` | +| 9001 | `511810` | 行情 `100.012000 → 100.003000` | `-1.80` | +| 9001 | **汇总** | 7 只 | **`-132.70` / `-0.2528%`** | +| 10001 | `15911` | **净值** `1.002122 → 1.000000`(行情只有一天) | `-2.12` | +| 10001 | `510300` | 当日建仓 300 份(昨日持仓 0) | `0.00` | +| 12001 | `159991` | **净值** `1.002122 → 1.000000` | `-21.22` | + +**单元测试**(`tests/unit/service/test_trade_service.py`,6 条新增)覆盖:无成交、 +当日买入、当日卖出、当日先买后卖、成交与持仓对不上(记 0)、行情优先 / 净值兜底 / +两者都不足(记 0)三条基准分支。 + +## 6. 相关文件 + +| 文件 | 改动 | +|---|---| +| `app/service/trade_service.py` | 新增基准/成交汇总/公式三组方法 + `_HoldingView`;`list_holdings` 与 `get_account_dashboard` 共用同一套计算 | +| `tests/unit/service/test_trade_service.py` | 新增 6 条用例 | +| `tools/check_today_profit_loss.py` | **新增**:纯 SQL 独立复算 + 与接口逐只比对 | +| `docs/演示用/后端接口文档-2026-09-14.md` | T001 / T006 的字段口径与"恒为 0"告警改为已实现 | +| `docs/演示用/软件需求文档-2026-09-14.md` | Q22 闭环;F-4.2 / 注意事项 / R2 同步 | diff --git a/docs/演示用/后端接口文档-2026-09-14.md b/docs/演示用/后端接口文档-2026-09-14.md index e60c473..fc73e8f 100644 --- a/docs/演示用/后端接口文档-2026-09-14.md +++ b/docs/演示用/后端接口文档-2026-09-14.md @@ -1087,8 +1087,8 @@ Service 层自己拼了信封(`customer_profile_candidate_service.py:52`)。 "total_cost": "...", "total_profit_loss": "...", "total_profit_loss_ratio": "...", - "today_profit_loss": "0", - "today_profit_loss_ratio": "0" + "today_profit_loss": "-132.70", + "today_profit_loss_ratio": "-0.2528" }, "holdings": [ { "...": "HoldingItem" } ], "as_of": "2026-09-14T02:44:20" @@ -1105,13 +1105,34 @@ Service 层自己拼了信封(`customer_profile_candidate_service.py:52`)。 | `account.frozen_cash` | 冻结资金 | | `summary.total_asset` | **`cash_balance + total_market_value`** | | `summary.total_profit_loss` | `total_market_value - total_cost` | -| `summary.today_profit_loss` | **恒为 `"0"`** —— 见注意事项 | -| `summary.today_profit_loss_ratio` | **恒为 `"0"`** | +| `summary.today_profit_loss` | **当日盈亏(真实计算)** —— 见下面的口径说明 | +| `summary.today_profit_loss_ratio` | `today_profit_loss / (今日市值 − today_profit_loss)`,分母 ≤ 0 时为 `0` | | `as_of` | 快照时间(UTC 无时区) | -**⚠️ `today_profit_loss` / `today_profit_loss_ratio` 目前恒为 0** -(`trade_service.py:748-749` 直接写 `ZERO`)。前端若把它当真实当日盈亏展示会误导。 -需要真实当日盈亏时,请以"当日最后一笔成交价 vs 当前价"自行计算,或推动后端补齐。 +**「今日盈亏」口径(2026-09-14 起,替换原先硬编码 `0`)** + +``` +今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用) +昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 (由当日成交反推,无需快照表) +``` + +- **基准**优先取场内行情(`fin_market_price` 最近两个交易日收盘价)——与同页 `latest_price` / + `market_value` 同源,客户能自己核对;行情只有一天(演示数据里 `15911` / `159991-159995` 的行情 + 本身就来自净值序列)时回退基金净值(`fin_nav_history` 最近两个净值日)。 +- **当日买入的份额只算「买入价 → 今收」**,不享受昨日到今日的涨幅;当日卖出的份额仍算 + 「昨收 → 卖出价」的已实现部分。 +- **不含费用**:与同页「持有盈亏 = 市值 − 成本」同口径(`fin_holding.cost_amount` 也只累计 + 买入的 `gross_amount`)。 +- 只统计本平台场内成交(`fin_transaction.transaction_type` ∈ {买入, 卖出});演示库里混进的 + 风控演示场外申购/赎回(`RISKDEMO-*`)**不计入**,否则会把「昨日持仓数量」抬到几十万份。 +- 行情与净值都不足两日时该持仓记 `0`(当前数据下不可达)。 + +实测(2026-09-14,客户 `cust_t` / 9001):`summary.today_profit_loss = "-132.70"`、 +`today_profit_loss_ratio = "-0.2528"`。可复现核对命令: + +```bash +python tools/check_today_profit_loss.py # 纯 SQL 独立复算,与接口逐只比对 +``` **所有金额字段都是字符串化的 Decimal**(`"1234.56"`),不是 JSON number —— 避免浮点精度问题。前端需 `parseFloat`。 @@ -1296,7 +1317,7 @@ curl -X POST http://127.0.0.1:8000/api/v1/users/me/orders \ | `market_value` | 市值 = `total_quantity * latest_price` | | `profit_loss` | 浮动盈亏 = `market_value - cost_amount` | | `profit_loss_ratio` | 盈亏比例(`cost_amount > 0` 时计算,否则 0) | -| `today_profit_loss` | 当日盈亏 | +| `today_profit_loss` | 当日盈亏 = `今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额`(**不含费用**,2026-09-14 起为真实计算,见 T001 注意事项) | **⚠️ `latest_price` 允许是最近一笔行情(可能超过 15 分钟)**。 这是刻意的:只读展示不应因为行情未更新而整页空白。 @@ -2637,11 +2658,12 @@ jr_agent_up 1 ### 🟡 API 契约细节 10. **`meta.trace_id` ≠ `data.trace_id`**(R001):前者是本次请求,后者是该 run 自己。 -11. **`today_profit_loss` 恒为 0**(T001 / T006)—— 硬编码占位值 - (`app/service/trade_service.py` L678 持仓列表、L748-749 账户看板), - **不要当真实当日盈亏展示**。注意同一响应里的 `profit_loss`(持有盈亏)是**真算的**。 - **是否本期实现待业务确认 → 见 `docs/软件需求文档-2026-09-14.md` Q22** - (含两条口径的可行性实测:行情基准不可行、净值基准可行)。 +11. **~~`today_profit_loss` 恒为 0~~ 已于 2026-09-14 实现**(T001 / T006)—— + 现在是**真实计算**的当日盈亏,定义与基准见 T001 的「今日盈亏」口径说明 + (实现:`app/service/trade_service.py`;核对:`python tools/check_today_profit_loss.py`; + 背景:`docs/演示用/软件需求文档-2026-09-14.md` Q22)。 + 注意同一响应里的 `profit_loss`(持有盈亏)与本字段**口径不同**: + 前者是「市值 − 成本」的累计数,后者只算最近一个交易日的变动。 12. **`change_pct` 可能为 `null`**(P001)—— 必须显示"暂无",**绝不可当 `0`**。 13. **P002 净值表为空返回 `count = 0`,不是错误**。 14. **T005 撤单几乎总是 409** —— 市价单下单即成交,只有 `"待风控"` 可撤。 diff --git a/docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md b/docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md index 466dd1e..0e24464 100644 --- a/docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md +++ b/docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md @@ -168,7 +168,7 @@ | `EpisodeExtractionConsumer` 幂等哈希不覆盖记忆内容 | `snapshot_hash` 只算 `fin_customer_profile`+`fin_risk_assessment`,**"只有记忆变了"会被判定 `changed=False` 短路**,`_memory_sources()` 根本不执行。建议把 `memory_uuid+version` 纳入哈希,或拆成 `snapshot_changed`/`memory_changed` 两个判据。 | | `ProjectionReconciliationService.mark_replay` 无调用方 | 有方法没入口(无 CLI / 无 Worker 接线),对账重放实际跑不起来。 | | 投顾线两个生产者不发 `memory_sources` | `profile_governance_service` / `risk_questionnaire_service` 的 payload 仍缺该键(`docs/37` §8.2 已登记)。消费端有兜底所以不死信,但根治在投顾线。 | -| `today_profit_loss` 硬编码 0 | 业务决策项,见 `软件需求文档-2026-09-14.md` **Q22**。 | +| ~~`today_profit_loss` 硬编码 0~~ | ✅ **已修复(2026-09-14)**:改为真实计算,见 `今日盈亏实现说明-2026-09-14.md` 与 `软件需求文档-2026-09-14.md` **Q22**。 | --- diff --git a/docs/演示用/软件需求文档-2026-09-14.md b/docs/演示用/软件需求文档-2026-09-14.md index 651d45f..995a60f 100644 --- a/docs/演示用/软件需求文档-2026-09-14.md +++ b/docs/演示用/软件需求文档-2026-09-14.md @@ -60,18 +60,19 @@ | # | 问题 | 现状 / 冲突点 | 影响 | |---|---|---|---| -| Q22 | **客户看板的「今日盈亏」本期是否实现?** | `app/service/trade_service.py` 里 `today_profit_loss` 是**硬编码 `ZERO`**:持仓列表 **L678**、账户看板 **L748**;`today_profit_loss_ratio` 同理(**L749**)。**对比**:同一函数里的「持有盈亏」是**真算的**(`market_value - cost_amount`,L660 / L726),所以**只有「今日盈亏」恒为 0**。客户看板、持仓页(T006)、盈亏分析页的该字段**永远显示 `0.00`**。 | `[阻塞]` —— 演示必被问。**若本期不做**,需明确对外口径:要么前端不展示该列/卡片,要么显示 `—`,**不要显示 `0`**(`0` 会被当成真实值) | +| Q22 | **客户看板的「今日盈亏」本期是否实现?** | ~~硬编码 `ZERO`~~ **已于 2026-09-14 实现为真实计算**(`app/service/trade_service.py`,口径与基准见下)。✅ **已闭环** | 实现口径:`今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额`(不含费用);基准**优先场内行情最近两个交易日收盘价**(与同页 `latest_price` 同源、可核对),行情只有一天时**回退净值最近两个净值日**。逐只复算工具:`python tools/check_today_profit_loss.py`;说明见 `docs/演示用/今日盈亏实现说明-2026-09-14.md` | -**若决定实现:口径与数据可行性(2026-09-14 实测核实)** +**实现时核实的数据现状(2026-09-14 实测,修正了原先"行情基准不可行"的判断)** -| 候选口径 | 数据现状 | 可行性 | +| 候选口径 | 数据现状 | 结论 | |---|---|---| -| `(今日收盘价 − 昨收) × 数量`,基准取 `fin_market_price` | **不可行**:该表只在**行情同步时**写行,实测每个产品**仅 2 行**(如 515450 只有 `2026-09-11` 与 `2026-09-14`),取不到连续的「昨收」,且这两行还跨了周末 | ❌ | -| `(今日净值 − 上一交易日净值) × 数量`,基准取 `fin_nav_history` | **可行**:该表每个产品有 **120~160 行连续交易日净值**(由 `tools/sync_nav_history.py` 同步,按 `(product_id, nav_date)` 幂等 upsert) | ✅ | +| `(今收 − 昨收) × 数量`,基准取 `fin_market_price` | 每个产品**恰好 2 个交易日**(`2026-09-11` 与 `2026-09-14`,中间是周末)——**正好够算「昨收 → 今收」**;但 `15911` / `159991-159995` **只有 1 天**(这几个产品的行情源就是净值序列) | ✅ 可用,**不足两日时回退净值** | +| `(今日净值 − 上一净值日) × 数量`,基准取 `fin_nav_history` | 每个产品 **120~160 行连续交易日净值**(`tools/sync_nav_history.py` 同步,按 `(product_id, nav_date)` 幂等 upsert);但 `511810`(货币 ETF)净值序列与价格不同量纲(净值 0.24 / 价格 100.0),做逐份盈亏会算错 | ⚠️ 可作**兜底**,不宜作首选 | -⇒ **建议按净值口径实现**。同时请业务确认语义:净值是**日终**数据,该指标实际含义是 -「**最近一个交易日**相对前一交易日的盈亏」,**不是盘中实时**;当天净值同步之前它会与「昨日」一致。 -这一点要在界面文案上讲清楚,否则又会被当成 bug。 +⇒ **最终口径:行情优先、净值兜底**。理由:行情与客户能看到的 `latest_price` / `market_value` 同源, +客户可以自己核对;净值兜底覆盖了"行情只有一天"的 6 个产品,且这 6 个产品的行情本身就来自净值序列。 +语义上仍是「**最近一个交易日**相对前一交易日的盈亏」,**不是盘中实时**(日终数据), +当天净值/行情同步之前它会与"昨日"一致——这点界面上要讲清楚。 > **建议回答方式**:不必逐条写长文,可直接在本章表格后追加"Q1:……"式的短答,或在对话中直接回复序号+结论。**标 `[阻塞]` 的 12 项(Q1/Q2/Q3/Q4/Q5/Q6/Q9/Q12/Q13/Q15/Q17/Q22)建议优先给出**,其余可先按本文给出的默认口径执行,后续修订。 @@ -230,7 +231,7 @@ | 编号 | 需求 | 优先级 | 验收要点 | |---|---|---|---| | F-4.1 | 20 只场内基金(13 ETF + 7 LOF)产品数据 | P0 | 风险等级 R1–R5;管理费 0.15%–1.20%/年;托管费 0.05%–0.20%/年 | -| F-4.2 | 账户看板:现金余额、总市值、总资产 | P0 | **注意**:`today_profit_loss` / `_ratio` 当前为**硬编码 0(占位)**,需业务确认是否本期实现 → **见 Q22**(含数据可行性与建议口径) | +| F-4.2 | 账户看板:现金余额、总市值、总资产 | P0 | `today_profit_loss` / `_ratio` **2026-09-14 已实现为真实计算**(不再是占位 0)→ 口径见 **Q22** | | F-4.3 | 持仓查询 | P0 | 只读持仓**故意不校验行情时效** | | F-4.4 | 下单(买入/卖出),市价全额成交 | P0 | 首版**仅支持 `price_type="market"`**,`limit_price` 恒为 `null` | | F-4.5 | 完整校验链(**顺序不可调**) | P0 | 见 5.5 节 | @@ -476,7 +477,9 @@ - 行情有效期仅 **15 分钟**(`MAX_QUOTE_AGE`),超时后**所有委托一律 503 且无自动刷新**——这是演示最容易翻的一环。补刷命令:`python tools/sync_market_prices.py`(立即生效,**无需重启服务**)。 - 只读持仓查询**故意不校验时效**(`enforce_freshness=False`)。 -- 账户看板的 `today_profit_loss` / `today_profit_loss_ratio` 当前为**硬编码 0**(占位实现),非真实当日盈亏——**需业务确认是否本期实现**。 +- 账户看板的 `today_profit_loss` / `today_profit_loss_ratio` **已是真实计算**(2026-09-14 起): + 语义是「**最近一个交易日**相对前一交易日的盈亏」,**不是盘中实时**;当天行情/净值同步之前 + 它会与"昨日"一致。别把它当 bug,见 **Q22**。 - 所有金额字段均为**字符串化 Decimal**,前端不得按 number 处理。 - 客户必须状态为 `已开户` 才能访问账户接口(`employee` 映射为 `closed`)。 @@ -633,7 +636,7 @@ | # | 风险/缺口 | 影响 | 当前状态 | |---|---|---|---| | R1 | `app/static/portal/employee-console/workspace/workspace.js` 曾存在**真实语法错误**(`submitRule`,约 L500–539,缺一个 `}`) | 管理端工作台该模块无法加载 | ✅ **已修复**(2026-09-14 提交 `4b7ee13`「修复管理员工作台函数缺少闭合大括号」)。复验:`node --experimental-vm-modules` + `vm.SourceTextModule` 遍历 `app/static/portal`,**44 个模块全部通过**。⚠️ 注意 `node --check` 会**假通过**,必须用真实 ESM 解析 | -| R2 | `today_profit_loss` 为占位 0 | 客户看板"今日盈亏"无意义 | ⚠️ **已提级为待决项 → 见 Q22**(含数据可行性实测与建议口径:行情基准不可行、净值基准可行) | +| R2 | ~~`today_profit_loss` 为占位 0~~ | 客户看板"今日盈亏"无意义 | ✅ **已修复(2026-09-14)**:改为真实计算(行情优先、净值兜底,当日买卖按成交价计入),见 **Q22**;复算工具 `tools/check_today_profit_loss.py` | | R3 | 投顾目标/方案状态机失败 **409 复用 `RUN_NOT_CANCELLABLE`** 字面量 | 错误码语义不准 | ⚠️ 已知文档缺陷 | | R4 | `ResourceNotFoundError` 直接抛出得到 `SESSION_NOT_FOUND`,集成测试期望 `RESOURCE_NOT_FOUND` | 错误码不一致 | ⚠️ 推介材料模块遗留(来自 `product_promotion_to_do_list.md`) | | R5 | PDF 转换依赖部署环境 LibreOffice/soffice | 推介材料 PDF 导出不可用 | ⚠️ 本机未验证(Q15) | diff --git a/tests/unit/service/test_trade_service.py b/tests/unit/service/test_trade_service.py index 61383c5..d001fc3 100644 --- a/tests/unit/service/test_trade_service.py +++ b/tests/unit/service/test_trade_service.py @@ -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") diff --git a/tools/check_today_profit_loss.py b/tools/check_today_profit_loss.py new file mode 100644 index 0000000..90c80a6 --- /dev/null +++ b/tools/check_today_profit_loss.py @@ -0,0 +1,186 @@ +"""复算并核对「今日盈亏」(只读,不改任何数据)。 + +`app/service/trade_service.py` 的「今日盈亏」不是把库里某个字段读出来,而是**算**出来的: + +``` +今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用) +昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 +基准:优先场内行情最近两个交易日收盘价;行情只有一天则回退基金净值最近两个净值日 +``` + +这个脚本用 **纯 SQL 独立复算**一遍,再和 `TradeService.get_account_dashboard()` 的输出逐只比对, +用来证明"接口里的数字不是拍出来的"。任何一处不一致都会以 `!! 不一致` 标出并以退出码 1 结束。 + +用法:: + + python tools/check_today_profit_loss.py # 默认查演示客户 9001 10001 10002 12001 + python tools/check_today_profit_loss.py 9101 9102 # 指定客户号 +""" + +from __future__ import annotations + +import asyncio +import sys +from decimal import ROUND_HALF_UP, Decimal + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.contracts import RequestContext +from app.infrastructure.db import SessionFactory +from app.service.trade_service import TradeService + +TWO_PLACES = Decimal("0.01") +FOUR_PLACES = Decimal("0.0001") +HUNDRED = Decimal("100") +DEFAULT_CUSTOMERS = (9001, 10001, 10002, 12001) + +_HOLDINGS_SQL = text( + """ + SELECT h.product_id, f.product_code, h.total_quantity + FROM fin_holding h JOIN fin_product f ON f.id = h.product_id + WHERE h.customer_id = :cid AND h.status = '持有中' + ORDER BY f.product_code + """ +) + +# 每个交易日只取一行(同一天可能被重复刷入),再取最近两天。 +_PRICE_SQL = text( + """ + SELECT trade_date, close_price FROM ( + SELECT trade_date, close_price, + ROW_NUMBER() OVER (PARTITION BY trade_date ORDER BY id DESC) rn + FROM fin_market_price WHERE product_id = :pid + ) t WHERE rn = 1 + ORDER BY trade_date DESC LIMIT 2 + """ +) + +_NAV_SQL = text( + """ + SELECT nav_date, nav FROM ( + SELECT nav_date, nav, + ROW_NUMBER() OVER (PARTITION BY nav_date ORDER BY id DESC) rn + FROM fin_nav_history WHERE product_id = :pid + ) t WHERE rn = 1 + ORDER BY nav_date DESC LIMIT 2 + """ +) + +# 只认本平台场内成交(买入/卖出);场外申购/赎回即便被写进来也不算当日买卖。 +_FLOW_SQL = text( + """ + SELECT + COALESCE(SUM(CASE WHEN order_side = 'buy' THEN shares END), 0), + COALESCE(SUM(CASE WHEN order_side = 'sell' THEN shares END), 0), + COALESCE(SUM(CASE WHEN order_side = 'buy' THEN gross_amount END), 0), + COALESCE(SUM(CASE WHEN order_side = 'sell' THEN gross_amount END), 0), + COUNT(*) + FROM fin_transaction + WHERE customer_id = :cid AND product_id = :pid + AND transaction_type IN ('买入', '卖出') + AND DATE(confirmed_at) = :ref + """ +) + + +async def recompute(session: AsyncSession, customer_id: int) -> list[tuple[str, Decimal, Decimal, str]]: + """独立复算:返回 [(产品代码, 今日盈亏, 今日盈亏率分母, 口径说明)]。""" + out: list[tuple[str, Decimal, Decimal, str]] = [] + holdings = (await session.execute(_HOLDINGS_SQL, {"cid": customer_id})).all() + for product_id, product_code, quantity in holdings: + prices = (await session.execute(_PRICE_SQL, {"pid": product_id})).all() + if len(prices) == 2: + ref_date, today_value, prev_value = prices[0][0], prices[0][1], prices[1][1] + note = f"行情 {ref_date} 昨收={prev_value} 今收={today_value}" + else: + navs = (await session.execute(_NAV_SQL, {"pid": product_id})).all() + if len(navs) < 2: + out.append((product_code, Decimal("0.00"), Decimal("0.00"), "无基准(记 0)")) + continue + ref_date, today_value, prev_value = navs[0][0], navs[0][1], navs[1][1] + note = f"净值 {ref_date} 昨日={prev_value} 今日={today_value}" + + row = ( + await session.execute( + _FLOW_SQL, {"cid": customer_id, "pid": product_id, "ref": ref_date} + ) + ).all()[0] + buy_shares, sell_shares, buy_gross, sell_gross, txn_count = row + qty_at_open = quantity - buy_shares + sell_shares + if qty_at_open < 0: + out.append((product_code, Decimal("0.00"), Decimal("0.00"), "成交与持仓不符(记 0)")) + continue + today_pl = ( + quantity * today_value + - qty_at_open * prev_value + - buy_gross + + sell_gross + ).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + base = (quantity * today_value - today_pl).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + out.append( + ( + product_code, + today_pl, + base, + f"{note} 昨日持仓={qty_at_open} 当日成交={txn_count} 笔", + ) + ) + return out + + +async def check(customer_id: int) -> bool: + async with SessionFactory() as session: + expected = await recompute(session, customer_id) + async with SessionFactory() as session: + service = TradeService(session) + response = await service.get_account_dashboard( + RequestContext( + user_id=str(customer_id), + trace_id=f"today-pl-check-{customer_id}", + roles=("customer",), + customer_ids=(str(customer_id),), + ) + ) + + actual = {item.product_code: item.today_profit_loss for item in response.holdings} + print(f"\n===== 客户 {customer_id} =====") + ok = True + for product_code, today_pl, _base, note in expected: + got = actual.get(product_code) + same = got == today_pl + ok = ok and same + print( + f" {product_code:8s} 复算={today_pl:>12} 接口={str(got):>12} " + f"{'OK' if same else '!! 不一致'} {note}" + ) + + expected_total = sum((pl for _c, pl, _b, _n in expected), Decimal("0")).quantize(TWO_PLACES) + expected_base = sum((b for _c, _p, b, _n in expected), Decimal("0")) + expected_ratio = ( + (expected_total / expected_base * HUNDRED).quantize(FOUR_PLACES, rounding=ROUND_HALF_UP) + if expected_base > 0 + else Decimal("0") + ) + summary = response.summary + totals_ok = ( + summary.today_profit_loss == expected_total + and summary.today_profit_loss_ratio == expected_ratio + ) + ok = ok and totals_ok + print( + f" 汇总:接口 {summary.today_profit_loss} / {summary.today_profit_loss_ratio}% " + f"复算 {expected_total} / {expected_ratio}% {'OK' if totals_ok else '!! 不一致'}" + ) + return ok + + +async def main() -> int: + customers = [int(arg) for arg in sys.argv[1:]] or list(DEFAULT_CUSTOMERS) + results = [await check(customer_id) for customer_id in customers] + print(f"\n结论:{'全部一致' if all(results) else '存在不一致,见上面 !! 行'}") + return 0 if all(results) else 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main()))