"""场内模拟交易演示种子(§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())