1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""场内模拟交易演示种子(§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())
|