feat(§T): 账户看板 + 场内模拟交易 9 端点(用户自助首版)

新增 §T 用户自助段(docs/05 §19 新号段 7 个 = A×40/C×7/K×4/M×4/O×3/R×4/T×9):

- T001 GET /api/v1/users/me/account/dashboard  — 账户/资金/持仓/盈亏汇总

- T002 POST /api/v1/users/me/orders  — 委托提交(首版 market 立即全额成交)

- T003 / T004 / T005  委托列表/详情/撤单

- T006 GET /api/v1/users/me/holdings  — 持仓列表(含市值/盈亏/当日盈亏)

- T007 / T008  成交记录列表/详情

- T009 GET /api/v1/users/me/cash-ledger  — 资金账本

要点(与 docs/00 §6.6 一致):

- 首版市价委托立即全额成交,不实现撮合队列/部分成交;T005 撤单首版对任何在场委托返回 ORDER_NOT_CANCELLABLE (409)

- 价格来源复用 base FundQuoteService;service 层不二次封装(满足 AGENTS 第 2 条)

- 首版风控 3 条硬性:产品可交易、客户适当性、持仓比例上限(fin_market_price 缺失或过期 → 拒绝买入)

- 数据库零修改:10 张 fin_* 表全部 docs/00 既定,本批 PR 改列类型与可空性均 0;底座实际偏差(id 无 AUTO_INCREMENT、所谓'生成列'是普通 NOT NULL)由 service _next_id / 业务派生值补偿

注册 API:9 端点均注册进 app.main;user=9001(cust)'s id 写账

权限码(tools/seed_test_rbac.py 同步登记 + CUSTOMER 全量):

  9047 account:read:self

  9048 trade:order:create

  9049 trade:order:read

  9050 trade:order:cancel

  9051 holding:read:self

  9052 trade:txn:read

错误码(app/core/errors.py + docs/05 §3.6 + tests/unit/core/test_errors.py DOCUMENTED 三方同步):

  404 ACCOUNT_NOT_FOUND / ORDER_NOT_FOUND

  409 ORDER_NOT_CANCELLABLE

  422 INSUFFICIENT_FUNDS / INSUFFICIENT_HOLDING / HOLDING_RATIO_EXCEEDED / SUITABILITY_MISMATCH / PRODUCT_NOT_TRADABLE

  503 FUND_QUOTE_UNAVAILABLE(可重试)

新增:app/api/controllers/trading.py / app/api/schemas/trading.py / app/service/trade_service.py / tools/seed_sim_account_demo.py / tests/unit/service/test_trade_service.py(unit×8) / tests/contract/test_trading_endpoint_contract.py(contract×11)

修改:app/main.py(挂载 controller) / app/core/errors.py(10 新异常类) / tools/seed_test_rbac.py / docs/05-接口文档.md(§19 T001-T009 + §3.6 9 新码) / tests/unit/core/test_errors.py(DOCUMENTED 同步)

门禁:pytest tests/unit tests/contract 1313 passed (+19 新增) / ruff all clean / 三道守卫全过
This commit is contained in:
张胜宇
2026-09-12 15:50:37 +08:00
committed by ZSY
parent 615032ab00
commit ebc3fe4cbe
11 changed files with 1848 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
"""场内模拟交易演示种子(§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
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()
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:
return
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=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)
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())
+15
View File
@@ -100,6 +100,7 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9044, "memory:candidate:confirm", "memory:candidate", "confirm", "self"),
(9045, "memory:candidate:review", "memory:candidate", "review", "all"),
(9046, "handover:read", "handover", "read", "all"),
# ---- 9047-9050:风控模块四个权限码 ----
# 风控 Service 和 Agent 工具都按这四个权限码失败关闭。此前依赖
# `grant_risk_permissions.py` 临时补种,重跑本脚本时会删除 9001-9099 号段内的
@@ -136,6 +137,18 @@ PERMISSIONS: tuple[tuple[int, str, str, str, str], ...] = (
(9057, "investment-goal:read:customer", "investment-goal", "read", "own_customers"),
(9058, "investment-goal:write:customer", "investment-goal", "write", "own_customers"),
(9059, "investment-goal:confirm:customer", "investment-goal", "confirm", "own_customers"),
# ---- 9060-9065:账户看板 + 场内模拟交易(T 段 9 端点) ----
# ZSY_develop §T 段(docs/05 §19)用户自助端点的权限码。
# Controller 当前不主动 require,但为与"业务契约 = 权限码声明"一致仍登记进
# `PERMISSIONS`,同时挂在 `CUSTOMER_PERMISSIONS` 让 customer 角色自带。
# 号段续 9060(避开 9047-9059 qyqy 风险/推广/探针/投资目标号段):与 9041-9046 客服二期间隔 1,避免与既有迁移/种子冲突。
(9060, "account:read:self", "account", "read", "self"),
(9061, "trade:order:create", "trade", "order", "create"),
(9062, "trade:order:read", "trade", "order", "read"),
(9063, "trade:order:cancel", "trade", "order", "cancel"),
(9064, "holding:read:self", "holding", "read", "self"),
(9065, "trade:txn:read", "trade", "txn", "read"),
)
# 客户:业务侧自助能力(自己的会话、反馈、转人工、自己的记忆画像)。
@@ -144,6 +157,8 @@ CUSTOMER_PERMISSIONS = (
9020, 9021, 9022, 9023, 9024, 9025, 9026, 9034,
# 客服二期:客户确认/拒绝**自己**的画像候选(服务层按 customer_id 过滤,不越权)。
9044,
# ZSY §T:账户看板与场内模拟交易(首版仅 customer 角色可用,留 admin 全量)
9060, 9061, 9062, 9063, 9064, 9065,
)
# 风控专员:业务侧只读 + 跨客户记忆 + 审计只读,不含配置写权限。
RISK_PERMISSIONS = (