Files
group_fqcd_jr/tools/seed_sim_account_demo.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

302 lines
11 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.
"""场内模拟交易演示种子(§T 用户自助)。
按 D4 决策:1 个客户 + 10 万初始资金 + 2 只基金的初始持仓。
执行:
python -m tools.seed_sim_account_demo [--customer-id N] [--quotes-only]
## 已知表结构问题(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:
"""只在**该产品一行行情都没有**时补一条种子行情,供"不跑行情同步也能下单"兜底。
⚠️ 这里**刻意不再按"今天"去 upsert**(原实现如此),因为那是一个真实踩过的坑:
seed 写的 `trade_date` 是**当天**,而真实行情来自行情源、日期是**最近交易日**。
下单取行情时按 `trade_date DESC` 排序,于是这条种子行**永远排在真实行情前面**;
它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让
整个产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。
周末尤其明显:`trade_date` 落在**非交易日**,价格还是编的(4.50 对不上真实的 4.579)。
所以:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争);
一条都没有时,`trade_date` 也要退到最近交易日,避免再次盖住后续同步的真实行情。
"""
existing = (
await session.execute(
select(FundMarketPrice.id)
.where(FundMarketPrice.product_id == product_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
today = datetime.now(UTC).date()
# 周末退到最近的周五,别把 trade_date 写进非交易日
trade_date = today if today.weekday() < 5 else today - timedelta(days=today.weekday() - 4)
now = datetime.now(UTC).replace(tzinfo=None)
next_id = await _next_id(session, FundMarketPrice)
stmt = insert(FundMarketPrice).values(
id=next_id,
product_id=product_id,
trade_date=trade_date,
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,
created_at=now,
)
await session.execute(stmt)
async def _upsert_account(
session: Session, customer_id: int
) -> tuple[FundSimAccount, bool]:
existing = (
await session.execute(
select(FundSimAccount.id).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one_or_none()
if existing is not None:
account = (
await session.execute(
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one()
return account, False
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)
account = (
await session.execute(
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
)
).scalar_one()
return account, True
async def _upsert_holding(
session: Session, customer_id: int, product_id: int, spec: dict
) -> Decimal:
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 Decimal("0")
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)
return cost
async def run(customer_id: int, *, quotes_only: bool = False) -> 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)} 个 + 当日行情")
if quotes_only:
return
account, account_created = await _upsert_account(session, customer_id)
added_cost = Decimal("0")
for pid, spec in zip(product_ids, DEMO_PRODUCTS, strict=True):
added_cost += await _upsert_holding(session, customer_id, pid, spec)
if added_cost:
starting_cash = INITIAL_BALANCE if account_created else account.cash_balance
account.cash_balance = (starting_cash - added_cost).quantize(Decimal("0.01"))
account.available_cash = account.cash_balance
account.updated_at = datetime.now(UTC).replace(tzinfo=None)
await session.execute(
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} 初始余额 CNY {INITIAL_BALANCE}")
print(f" [完成] 持仓已建立,本次新增持仓成本 CNY {added_cost}")
print(f" [完成] 当前账户现金 CNY {account.cash_balance}")
def main() -> int:
parser = argparse.ArgumentParser(description="场内模拟交易演示种子")
parser.add_argument("--customer-id", type=int, default=CUSTOMER_ID)
parser.add_argument(
"--quotes-only",
action="store_true",
help="仅刷新演示产品与当日行情,不修改账户、持仓或余额",
)
args = parser.parse_args()
asyncio.run(run(args.customer_id, quotes_only=args.quotes_only))
return 0
if __name__ == "__main__":
sys.exit(main())