Files
group_fqcd_jr/tools/seed_sim_account_demo.py
wangjianlong_0626 1f62aca6f7 fix(seed): 当日行情重跑必须刷新 source_updated_at(修 503 FUND_QUOTE_UNAVAILABLE)
真机实测到的缺陷:`tools/seed_sim_account_demo.py` 的 `_upsert_market_price()`
在"当天已有行"时直接 `return`,于是同一天重跑种子**不刷新 `source_updated_at`**。
行情是时效数据,过期后 `FundQuoteService` 返回
`503 FUND_QUOTE_UNAVAILABLE:产品 510300 行情已过期`,
连带 `T001` 仪表盘与 `T006` 持仓一起不可用(`T010` 权益不查行情,仍 200)。

表现极具误导性:**刚灌完种子能用,过十几分钟就 503** ——
看起来像行情适配器或缓存故障,实际根因在种子脚本。归因过程:
`memory_sync` / Redis / Milvus 全部正常,`fin_market_price` 里当天那行
`source_updated_at` 停在首次灌入时刻。

修法:把列值抽成共享 `values` 字典,当天已有行时 `update` 刷新全部行情列
(含 `source_updated_at`),不存在才 `insert`。
**幂等的正确含义是"不产生重复行"(`(product_id, trade_date)` 唯一),
不是"不更新值"。** 账户/持仓的"已存在则跳过"保持不变 ——
那是业务数据,不该被种子覆盖。

验证(本机,测试客户 9001 `cust_t`):
- `python -X utf8 -m tools.seed_sim_account_demo --customer-id 9001` 正常
- `GET /api/v1/users/me/account/dashboard` → 200(修前 503)
- `GET /api/v1/users/me/holdings` → 200
- `GET /api/v1/users/me/entitlements` → 200

门禁:ruff `app tests tools alembic` 全过;`mypy app` 0 错(252 文件);
`audit_schema.py` 90 张业务表无差异;文档编号/端点编号/RBAC 种子一致性全过。
2026-09-12 17:32:24 +08:00

297 lines
11 KiB
Python
Raw Permalink 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.
"""场内模拟交易演示种子(§T 用户自助)。
按 D4 决策:1 个客户 + 10 万初始资金 + 2 只基金的初始持仓。
执行:
python -m tools.seed_sim_account_demo [--customer-id N]
## 已知表结构问题(2026-09-12 实测)
底座 `fin_*` 系列表的 `id` 列**没有** `AUTO_INCREMENT` 属性(仅 `fin_knowledge_meta` 有)。
SQLAlchemy ORM 默认期望 auto-increment,会在 INSERT 时省略 id → MySQL 报
`Field 'id' doesn't have a default value`。**底层基础规则不允许 DDL 改动**,
所以本脚本显式查 `MAX(id) + 1` 分配下一个 id,写入时携带 id(不依赖 auto-increment)。
实现:使用 SQLAlchemy Core `insert(...).values(id=..., ...)`,而不是 ORM `session.add()`
——后者对 BIGINT 不会自动注入 id。
幂等:再次执行不会重复建记录(先查 product_code / customer_id / customer_id+product_id)。
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import func, insert, select, update
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.infrastructure.db import SessionFactory
from app.model.fund import (
FundHolding,
FundMarketPrice,
FundProduct,
FundSimAccount,
)
CUSTOMER_ID = 9001
INITIAL_BALANCE = Decimal("100000.00")
DEMO_PRODUCTS = [
{
"product_code": "510300",
"product_name": "沪深300ETF",
"exchange_code": "SSE",
"product_category": "ETF",
"risk_level": "R3",
"lot_size": Decimal("100"),
"price_tick": Decimal("0.001"),
"close_price": Decimal("4.5000"),
"total_fund_shares": Decimal("10000000000"),
"initial_quantity": Decimal("1000"),
},
{
"product_code": "510500",
"product_name": "南方中证500ETF",
"exchange_code": "SSE",
"product_category": "ETF",
"risk_level": "R3",
"lot_size": Decimal("100"),
"price_tick": Decimal("0.001"),
"close_price": Decimal("6.2000"),
"total_fund_shares": Decimal("8000000000"),
"initial_quantity": Decimal("800"),
},
]
async def _next_id(session: Session, model) -> int:
"""返回该表下一个可用的 id(不依赖 AUTO_INCREMENT)。"""
pk_col = model.__table__.primary_key.columns[0]
result = await session.execute(select(func.coalesce(func.max(pk_col), 0)))
return int(result.scalar_one()) + 1
async def _upsert_product(session: Session, spec: dict) -> int:
existing = (
await session.execute(
select(FundProduct.id).where(FundProduct.product_code == spec["product_code"])
)
).scalar_one_or_none()
if existing is not None:
return int(existing)
now = datetime.now(UTC).replace(tzinfo=None)
next_id = await _next_id(session, FundProduct)
stmt = insert(FundProduct).values(
id=next_id,
product_code=spec["product_code"],
product_name=spec["product_name"],
exchange_code=spec["exchange_code"],
product_category=spec["product_category"],
risk_level=spec["risk_level"],
fund_manager="南方基金",
currency="CNY",
lot_size=spec["lot_size"],
price_tick=spec["price_tick"],
current_nav=spec["close_price"],
current_nav_at=now,
min_amount=Decimal("100.00"),
open_start_at=now - timedelta(days=365),
open_end_at=None,
transaction_fee_rate=None,
single_investor_max_holding_ratio=Decimal("5.0000"),
management_fee_rate=Decimal("0.50"),
custodian_fee_rate=Decimal("0.10"),
risk_disclosure_required=0,
second_confirmation_required=0,
recording_required=0,
status="上市",
created_at=now,
updated_at=now,
)
await session.execute(stmt)
return next_id
async def _upsert_market_price(session: Session, product_id: int, spec: dict) -> None:
"""写入/刷新当日行情。
⚠️ 当天已有行时**必须刷新**,不能直接 return(2026-09-12 实测到的缺陷):
行情是**时效数据**,`FundQuoteService` 会按 `source_updated_at` 判新鲜度,
过期即返回 `503 FUND_QUOTE_UNAVAILABLE`,连带 `T001` 仪表盘与 `T006` 持仓一起不可用。
原先"已存在就跳过"会让同一天重跑**不更新 `source_updated_at`** ——
表现为"刚灌完种子能用,过十几分钟仪表盘就 503",而这与种子无关、极难归因。
幂等的正确含义是**不产生重复行**(`(product_id, trade_date)` 唯一),
**不是"不更新值"**。账户/持仓的"已存在则跳过"是另一回事 ——
那是业务数据,不该被种子覆盖。
"""
today = datetime.now(UTC).date()
now = datetime.now(UTC).replace(tzinfo=None)
values = {
"open_price": spec["close_price"],
"high_price": spec["close_price"] + Decimal("0.05"),
"low_price": spec["close_price"] - Decimal("0.05"),
"close_price": spec["close_price"],
"volume": Decimal("1000000"),
"turnover_amount": spec["close_price"] * Decimal("1000000"),
"total_fund_shares": spec["total_fund_shares"],
"source": "eastmoney_demo_seed",
"source_updated_at": now,
}
existing = (
await session.execute(
select(FundMarketPrice.id).where(
FundMarketPrice.product_id == product_id,
FundMarketPrice.trade_date == today,
)
)
).scalar_one_or_none()
if existing is not None:
await session.execute(
update(FundMarketPrice).where(FundMarketPrice.id == existing).values(**values)
)
return
next_id = await _next_id(session, FundMarketPrice)
await session.execute(
insert(FundMarketPrice).values(
id=next_id,
product_id=product_id,
trade_date=today,
created_at=now,
**values,
)
)
async def _upsert_account(session: Session, customer_id: int) -> FundSimAccount:
existing = (
await session.execute(
select(FundSimAccount.id).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one_or_none()
if existing is not None:
# 返回完整对象
return (
await session.execute(
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one()
now = datetime.now(UTC).replace(tzinfo=None)
next_id = await _next_id(session, FundSimAccount)
stmt = insert(FundSimAccount).values(
id=next_id,
account_no=f"FSA{customer_id:06d}",
customer_id=customer_id,
currency="CNY",
cash_balance=INITIAL_BALANCE,
available_cash=INITIAL_BALANCE,
frozen_cash=Decimal("0"),
initial_balance=INITIAL_BALANCE,
status="正常",
version=0,
created_at=now,
updated_at=now,
)
await session.execute(stmt)
return (
await session.execute(
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one()
async def _upsert_holding(
session: Session, customer_id: int, product_id: int, spec: dict
) -> None:
existing = (
await session.execute(
select(FundHolding.id).where(
FundHolding.customer_id == customer_id,
FundHolding.product_id == product_id,
)
)
).scalar_one_or_none()
if existing is not None:
return
qty = spec["initial_quantity"]
cost = (qty * spec["close_price"]).quantize(Decimal("0.01"))
now = datetime.now(UTC).replace(tzinfo=None)
next_id = await _next_id(session, FundHolding)
stmt = insert(FundHolding).values(
id=next_id,
customer_id=customer_id,
trade_account=f"FSA{customer_id:06d}",
product_id=product_id,
total_quantity=qty,
# `shares`/`current_value` 在 docs/00 §6.2 标注为"生成列",但实测 MySQL 表
# `GENERATION_EXPRESSION=''`——表结构里是普通 NOT NULL 列、无默认值。
# 写时必须显式给值,按 §6.2 的语义填(shares=total_quantity, current_value=cost)。
shares=qty,
available_quantity=qty,
frozen_quantity=Decimal("0"),
average_cost=spec["close_price"],
cost_amount=cost,
market_value=None,
current_value=cost,
profit_loss=None,
profit_loss_ratio=None,
status="持有中",
first_acquired_at=now,
version=0,
updated_at=now,
)
await session.execute(stmt)
async def run(customer_id: int) -> None:
s = get_settings()
print(f"数据库:{s.mysql_dsn.split('@')[-1]}")
print(f"目标客户 ID = {customer_id}")
async with SessionFactory() as session:
async with session.begin():
product_ids: list[int] = []
for spec in DEMO_PRODUCTS:
pid = await _upsert_product(session, spec)
await _upsert_market_price(session, pid, spec)
product_ids.append(pid)
print(f" ✓ 演示产品 {len(product_ids)} 个 + 当日行情")
account = await _upsert_account(session, customer_id)
for pid, spec in zip(product_ids, DEMO_PRODUCTS, strict=True):
await _upsert_holding(session, customer_id, pid, spec)
total_cost = sum(
(spec["initial_quantity"] * spec["close_price"]).quantize(Decimal("0.01"))
for spec in DEMO_PRODUCTS
)
from sqlalchemy import update as sa_update
account.cash_balance = (INITIAL_BALANCE - total_cost).quantize(Decimal("0.01"))
account.available_cash = account.cash_balance
account.updated_at = datetime.now(UTC).replace(tzinfo=None)
await session.execute(
sa_update(FundSimAccount)
.where(FundSimAccount.id == account.id)
.values(
cash_balance=account.cash_balance,
available_cash=account.available_cash,
updated_at=account.updated_at,
)
)
print(f" ✓ 虚拟账户 {account.account_no} 初始余额 ¥{INITIAL_BALANCE}")
print(f" ✓ 持仓已建立,账户剩余 ¥{account.cash_balance}(已扣持仓成本 ¥{total_cost})")
def main() -> int:
parser = argparse.ArgumentParser(description="场内模拟交易演示种子")
parser.add_argument("--customer-id", type=int, default=CUSTOMER_ID)
args = parser.parse_args()
asyncio.run(run(args.customer_id))
return 0
if __name__ == "__main__":
sys.exit(main())