2026-09-12 15:35:12 +08:00
|
|
|
"""场内模拟交易演示种子(§T 用户自助)。
|
|
|
|
|
|
|
|
|
|
按 D4 决策:1 个客户 + 10 万初始资金 + 2 只基金的初始持仓。
|
|
|
|
|
|
|
|
|
|
执行:
|
2026-09-13 15:56:54 +08:00
|
|
|
python -m tools.seed_sim_account_demo [--customer-id N] [--quotes-only]
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
## 已知表结构问题(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
|
|
|
|
|
|
2026-09-13 15:56:54 +08:00
|
|
|
from sqlalchemy import func, insert, select, update
|
2026-09-12 15:35:12 +08:00
|
|
|
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:
|
|
|
|
|
today = datetime.now(UTC).date()
|
2026-09-13 15:56:54 +08:00
|
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
2026-09-12 15:35:12 +08:00
|
|
|
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:
|
2026-09-13 15:56:54 +08:00
|
|
|
await session.execute(
|
|
|
|
|
update(FundMarketPrice)
|
|
|
|
|
.where(FundMarketPrice.id == existing)
|
|
|
|
|
.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,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-09-12 15:35:12 +08:00
|
|
|
return
|
|
|
|
|
next_id = await _next_id(session, FundMarketPrice)
|
|
|
|
|
stmt = insert(FundMarketPrice).values(
|
|
|
|
|
id=next_id,
|
|
|
|
|
product_id=product_id,
|
|
|
|
|
trade_date=today,
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 15:56:54 +08:00
|
|
|
async def _upsert_account(
|
|
|
|
|
session: Session, customer_id: int
|
|
|
|
|
) -> tuple[FundSimAccount, bool]:
|
2026-09-12 15:35:12 +08:00
|
|
|
existing = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(FundSimAccount.id).where(FundSimAccount.customer_id == customer_id)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if existing is not None:
|
2026-09-13 15:56:54 +08:00
|
|
|
account = (
|
2026-09-12 15:35:12 +08:00
|
|
|
await session.execute(
|
|
|
|
|
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one()
|
2026-09-13 15:56:54 +08:00
|
|
|
return account, False
|
2026-09-12 15:35:12 +08:00
|
|
|
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)
|
2026-09-13 15:56:54 +08:00
|
|
|
account = (
|
2026-09-12 15:35:12 +08:00
|
|
|
await session.execute(
|
|
|
|
|
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id)
|
|
|
|
|
)
|
|
|
|
|
).scalar_one()
|
2026-09-13 15:56:54 +08:00
|
|
|
return account, True
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _upsert_holding(
|
|
|
|
|
session: Session, customer_id: int, product_id: int, spec: dict
|
2026-09-13 15:56:54 +08:00
|
|
|
) -> Decimal:
|
2026-09-12 15:35:12 +08:00
|
|
|
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:
|
2026-09-13 15:56:54 +08:00
|
|
|
return Decimal("0")
|
2026-09-12 15:35:12 +08:00
|
|
|
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)
|
2026-09-13 15:56:54 +08:00
|
|
|
return cost
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
2026-09-13 15:56:54 +08:00
|
|
|
async def run(customer_id: int, *, quotes_only: bool = False) -> None:
|
2026-09-12 15:35:12 +08:00
|
|
|
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)
|
2026-09-13 15:56:54 +08:00
|
|
|
print(f" [完成] 演示产品 {len(product_ids)} 个 + 当日行情")
|
|
|
|
|
if quotes_only:
|
|
|
|
|
return
|
|
|
|
|
account, account_created = await _upsert_account(session, customer_id)
|
|
|
|
|
added_cost = Decimal("0")
|
2026-09-12 15:35:12 +08:00
|
|
|
for pid, spec in zip(product_ids, DEMO_PRODUCTS, strict=True):
|
2026-09-13 15:56:54 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-09-12 15:35:12 +08:00
|
|
|
)
|
2026-09-13 15:56:54 +08:00
|
|
|
print(f" [完成] 虚拟账户 {account.account_no} 初始余额 CNY {INITIAL_BALANCE}")
|
|
|
|
|
print(f" [完成] 持仓已建立,本次新增持仓成本 CNY {added_cost}")
|
|
|
|
|
print(f" [完成] 当前账户现金 CNY {account.cash_balance}")
|
2026-09-12 15:35:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
parser = argparse.ArgumentParser(description="场内模拟交易演示种子")
|
|
|
|
|
parser.add_argument("--customer-id", type=int, default=CUSTOMER_ID)
|
2026-09-13 15:56:54 +08:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--quotes-only",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="仅刷新演示产品与当日行情,不修改账户、持仓或余额",
|
|
|
|
|
)
|
2026-09-12 15:35:12 +08:00
|
|
|
args = parser.parse_args()
|
2026-09-13 15:56:54 +08:00
|
|
|
asyncio.run(run(args.customer_id, quotes_only=args.quotes_only))
|
2026-09-12 15:35:12 +08:00
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-09-13 15:56:54 +08:00
|
|
|
sys.exit(main())
|