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

187 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""复算并核对「今日盈亏」(只读,不改任何数据)。
`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()))