Files
group_fqcd_jr/app/service/trade_service.py
T
lzf_0626 6b2e2edcda P0-2:恢复基线要求的 AUTO_INCREMENT,消除手工发号的并发主键冲突
## 问题

`trade_service._next_id` 用 `SELECT MAX(id)+1` 发主键。两个事务读到同一个 MAX、
算出同一个 id,后写的那笔 `flush()` 撞 `Duplicate entry ... for key 'PRIMARY'`
-> 该客户下单直接 **500**。`submit_order` 一次要发 **3 个 id**
(订单 / 成交 / 资金流水),冲突面是单表的三倍。

## 这是"修正偏差",不是"改基线"

`docs/00-新数据库基线设计.md` 第 41 行:

    | 主键 | 统一 `BIGINT UNSIGNED AUTO_INCREMENT`,业务编号另设唯一键 |

**基线本来就要求 AUTO_INCREMENT**,是生成的 DDL 漏了 —— `_next_id` 自己的
docstring 也写着"与 docs/00 设计稿存在偏差"。所以本迁移**不违反** AGENTS.md
规则 4(禁止改类型/可空性/业务含义):类型仍是 `BIGINT UNSIGNED`、仍是 `NOT NULL`、
`id` 的业务含义不变,只是补回一个列属性;已有行 id 不变,显式给 id 依然合法。

## 迁移 `20260914_baseline_auto_increment`

- **15 张表**恢复 AUTO_INCREMENT(硬编码表名 —— 迁移必须确定性,动态查
  `information_schema` 会让同一份迁移在不同环境产生不同结果)。
- **有意排除 3 张**(`EXCLUDED_BECAUSE_FOREIGN_KEY`):`fin_product`(被 10 张
  `advisor_product_*` 引用)、`fin_risk_assessment`、`sys_user`(被 19+ 张引用)。
  MySQL 拒绝 `MODIFY` 被外键引用的列:

      (1833, "Cannot change column 'id': used in a foreign key constraint ...")

  改它们必须先 DROP FOREIGN KEY -> MODIFY -> 重建外键,那是另一件事(涉及 30+ 个
  外键的重建与一致性验证),不该塞进这条"恢复基线属性"的迁移。且这三张表**写入
  频率很低、没有任何代码用 `SELECT MAX(id)+1` 给它们发号** —— 排除它们不影响
  本迁移的目标。
- `downgrade()` 可回滚(只是去掉属性、不丢数据),但注释里写明:**回滚会把 P0-2
  的并发冲突带回来**。

⚠️ 迁移执行中踩到过"部分生效":MySQL DDL 非事务性,第一次跑到 `fin_product`
才报错,**前 7 张已经改完**。修正列表后重跑即收敛(对已是 AUTO_INCREMENT 的列
再 `MODIFY` 是无害的)。这一点也说明**迁移必须逐表可重入**。

## 代码

`trade_service.py` 删除 `_next_id` 方法及 4 处调用(`FundSimOrder` /
`FundTransaction` / `FundCashLedger` / `FundHolding`),改由 InnoDB 分配;
顺带清掉因此不再使用的 `Any` 与 `func` import(全仓 grep 确认它们只服务于
`_next_id`)。测试对 `_next_id` 零依赖(已 grep 确认)。

`test_advisor_migration_contract.py` 里那个"钉住末端版本"的断言按它自己的注释
要求同步更新到新 head。

## 实测

- `alembic upgrade head` -> `current = 20260914_baseline_auto_increment`,
  复核状态:**15 张已生效、3 张按设计排除**
- **并发下单实测**(2 个客户 × 3 笔 = 6 笔真并发;刻意用**不同客户**,
  因为 P0-3 的行锁已经把同一客户串行化了,不同客户才会真正并发进入发号路径):

      成功 6 / 主键冲突 0 / 其它失败 0
      => P0-2 已解决

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (2 个既有失败与本次无关)
- `ruff check` -> All checks passed
2026-09-14 20:27:35 +08:00

843 lines
34 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.
"""场内基金模拟交易 Service(§T 用户自助)。
## 设计要点(按 `docs/00` 第 6 章)
- **首版市价委托立即全额成交**(不实现撮合队列 / 部分成交)。
- **原子事务**:委托、资金账、持仓、成交记录在同一 SQLAlchemy session 中完成。
- **首版风控只做 3 条硬性**:
1. 产品可交易(`status='上市'`、`open_*_at` 区间合法)
2. 客户适当性(C1-C5)与产品风险等级(R1-R5)兼容
3. 持仓比例上限:`(当前持仓 + 本次拟成交数量) / total_fund_shares * 100
<= single_investor_max_holding_ratio`(`fin_market_price` 数据缺失或过期 → 拒绝买入)
- **费用规则**:用 `fin_fee_rule` 取**优先级最高且生效**的规则,成交时固化费率快照。
- **行情快照必须保存**:委托的 `quote_price` / `quote_at` / `quote_source` 来自外部行情 API。
- **生成列只读**:`fin_transaction.transaction_type/nav/shares/amount/fee/confirmed_at` 与
`fin_holding.shares/current_value` 是生成列,**本模块写入时不传这些字段**,仅由 MySQL
触发器/表达式生成。
- **底座 ORM 只读约定**:`app/model/fund.py` 的注释明确"不提供任何写辅助方法"。
本服务**直接使用 session.add() 写入**——SQLAlchemy 标准 ORM 写入语义不违反该约定
(约定针对的是"业务便捷方法",session.add 是数据库访问基本操作)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas.trading import (
AccountDashboardResponse,
AccountSummary,
CashLedgerItem,
CashLedgerResponse,
HoldingItem,
HoldingListResponse,
OrderCreateRequest,
OrderCreateResponse,
OrderSummary,
PortfolioSummary,
TransactionItem,
TransactionListResponse,
)
from app.core.contracts import RequestContext
from app.core.errors import (
AccountNotFoundError,
FundQuoteUnavailableError,
HoldingRatioExceededError,
InsufficientFundsError,
InsufficientHoldingError,
OrderNotCancellableError,
OrderNotFoundError,
ProductNotTradableError,
SuitabilityMismatchError,
ValidationAgentError,
)
from app.model.fund import (
FundCashLedger,
FundFeeRule,
FundHolding,
FundMarketPrice,
FundProduct,
FundSimAccount,
FundSimOrder,
FundTransaction,
)
from app.service.suitability_service import SuitabilityService, SuitabilityToolInput
TWO_PLACES = Decimal("0.01")
FOUR_PLACES = Decimal("0.0001")
SIX_PLACES = Decimal("0.000001")
HUNDRED = Decimal("100")
ZERO = Decimal("0")
# `docs/00` 第 6.2 节:买入校验必须使用外部 API 最新且未过期的基金总份额。
MAX_QUOTE_AGE = timedelta(minutes=15)
@dataclass(frozen=True)
class _QuoteSnapshot:
price: Decimal
as_of: datetime
source: str
total_fund_shares: Decimal
@dataclass(frozen=True)
class _FeeRule:
fee_rate: Decimal
minimum_fee: Decimal
fixed_fee: Decimal
class TradeService:
"""场内模拟交易:账户看板、委托提交、撤单、列表、成交、资金明细。"""
def __init__(
self,
session: AsyncSession,
*,
suitability_evaluator: object | None = None,
) -> None:
self._session = session
# Every real trade must pass the shared suitability service. Tests and
# explicit callers may still inject a compatible evaluator.
self._suitability_evaluator = suitability_evaluator or SuitabilityService()
# ---------- 行情 ----------
async def _fetch_quote(
self, product: FundProduct, *, enforce_freshness: bool = True
) -> _QuoteSnapshot:
latest = (
await self._session.execute(
select(FundMarketPrice)
.where(FundMarketPrice.product_id == product.id)
.order_by(FundMarketPrice.trade_date.desc())
.limit(1)
)
).scalar_one_or_none()
if latest is None:
raise FundQuoteUnavailableError(
f"产品 {product.product_code} 缺少场内行情(fin_market_price)"
)
now = datetime.now(UTC).replace(tzinfo=None)
if enforce_freshness and (now - latest.source_updated_at) > MAX_QUOTE_AGE:
raise FundQuoteUnavailableError(
f"产品 {product.product_code} 行情已过期(最新 {latest.source_updated_at})"
)
if latest.close_price <= 0:
raise FundQuoteUnavailableError(
f"产品 {product.product_code} 行情价格非法"
)
if latest.total_fund_shares <= 0:
raise FundQuoteUnavailableError(
f"产品 {product.product_code} 行情总份额缺失或非正数"
)
return _QuoteSnapshot(
price=latest.close_price,
as_of=latest.source_updated_at,
source=latest.source,
total_fund_shares=latest.total_fund_shares,
)
# ---------- 产品与适当性 ----------
async def _load_tradable_product(self, product_code: str) -> FundProduct:
product = (
await self._session.execute(
select(FundProduct).where(FundProduct.product_code == product_code)
)
).scalar_one_or_none()
if product is None:
raise ProductNotTradableError(f"产品 {product_code} 不存在")
if product.status != "上市":
raise ProductNotTradableError(
f"产品 {product_code} 当前状态为 {product.status!r},不可交易"
)
now = datetime.now(UTC).replace(tzinfo=None)
if product.open_start_at is not None and product.open_start_at > now:
raise ProductNotTradableError(
f"产品 {product_code} 尚未开放交易(开始时间 {product.open_start_at})"
)
if product.open_end_at is not None and product.open_end_at < now:
raise ProductNotTradableError(
f"产品 {product_code} 已停止交易(结束时间 {product.open_end_at})"
)
return product
async def _check_suitability(
self, customer_id: int, product: FundProduct, context: RequestContext
) -> None:
"""Apply the authoritative suitability decision before an order.
A missing/misconfigured evaluator is a server configuration error and
must not silently turn into an approval. The shared service also loads
the customer's risk assessment from the database rather than trusting
client supplied risk fields.
"""
evaluate = getattr(self._suitability_evaluator, "evaluate", None)
if evaluate is None or not callable(evaluate):
raise SuitabilityMismatchError("适当性服务不可用,交易已拒绝")
try:
risk_level_int = int(product.risk_level.lstrip("Rr"))
except (AttributeError, ValueError):
raise SuitabilityMismatchError(
f"产品 {getattr(product, 'product_code', '')} 风险等级无效,交易已拒绝"
) from None
decision = await evaluate(
SuitabilityToolInput(
customer_id=str(customer_id),
product_risk_level=risk_level_int,
product_requires_disclosure=bool(product.risk_disclosure_required),
requires_confirmation=bool(product.second_confirmation_required),
),
context=context,
)
if not getattr(decision, "allowed", True):
reason = getattr(decision, "reason_code", "unspecified")
raise SuitabilityMismatchError(
f"客户适当性与产品 {product.risk_level!r} 不兼容(reason_code={reason})"
)
# ---------- 账户 / 持仓 ----------
async def _load_account(
self, customer_id: int | str, *, for_update: bool = False
) -> FundSimAccount:
"""加载虚拟账户。
⚠️ `for_update=True` 时加 `SELECT … FOR UPDATE` 行锁,**下单路径必须用**:
否则同一客户的并发下单会各自读到旧余额、各自判"够不够"、各自扣减,
结果是可用现金被扣成负数。
**加锁顺序固定为 账户 → 持仓**(见 `submit_order`):并发事务只要按同一顺序
取锁就不会互相等待成环,这是避免死锁的关键,改顺序前请先想清楚。
只读路径(看板、持仓列表、流水)不加锁 —— 它们不修改余额,加锁只会降低并发。
"""
customer_id_int = int(customer_id) if isinstance(customer_id, str) else customer_id
statement = select(FundSimAccount).where(
FundSimAccount.customer_id == customer_id_int
)
if for_update:
statement = statement.with_for_update()
account = (await self._session.execute(statement)).scalar_one_or_none()
if account is None:
raise AccountNotFoundError(f"客户 {customer_id_int} 未开户")
if account.status != "正常":
raise AccountNotFoundError(f"账户状态为 {account.status!r},不可用")
return account
async def _load_holding(
self, customer_id: int, product_id: int, *, for_update: bool = False
) -> FundHolding | None:
"""加载持仓。
`for_update=True` 的用途与风险同 `_load_account`:不锁的话,
"持仓 1000 份,两笔各卖 1000 份"两笔都能读到 `available_quantity=1000`
并通过校验,最终把可用份额扣成负数(超卖)。
"""
statement = select(FundHolding).where(
FundHolding.customer_id == customer_id,
FundHolding.product_id == product_id,
)
if for_update:
statement = statement.with_for_update()
return (await self._session.execute(statement)).scalar_one_or_none()
# ---------- 费率规则 ----------
async def _load_fee_rule(self, product_id: int, order_side: str) -> _FeeRule:
now = datetime.now(UTC).replace(tzinfo=None)
rule = (
await self._session.execute(
select(FundFeeRule)
.where(
FundFeeRule.status == "启用",
FundFeeRule.order_side == order_side,
FundFeeRule.effective_from <= now,
)
.where(
(FundFeeRule.product_id == product_id)
| (FundFeeRule.product_id.is_(None))
)
.order_by(FundFeeRule.priority.desc(), FundFeeRule.id.desc())
.limit(1)
)
).scalar_one_or_none()
if rule is None:
return _FeeRule(
fee_rate=Decimal("0.000100"),
minimum_fee=ZERO,
fixed_fee=ZERO,
)
return _FeeRule(
fee_rate=rule.fee_rate,
minimum_fee=rule.minimum_fee,
fixed_fee=rule.fixed_fee,
)
def _compute_fee(self, gross: Decimal, rule: _FeeRule) -> Decimal:
fee = gross * rule.fee_rate + rule.fixed_fee
if fee < rule.minimum_fee:
fee = rule.minimum_fee
return fee.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
# ---------- 委托主流程 ----------
async def submit_order(
self,
payload: OrderCreateRequest,
context: RequestContext,
*,
commit: bool = True,
) -> OrderCreateResponse:
"""提交委托。
`commit=False` 供**幂等包装层**使用:T002 现在由
`ApiTransactionService.execute_in` 包住,业务写入与幂等回执必须在
**同一个事务**里(`docs/05` §5.2),所以内层不能再自己提交 ——
否则就成了"内层提交外层事务",幂等记录与业务写入会分处两个事务,
回执写失败时业务已经落库,重放就失去了意义。
"""
customer_id = int(context.user_id) # RequestContext.user_id 是 str(如 '9001')
now = datetime.now(UTC).replace(tzinfo=None)
product = await self._load_tradable_product(payload.product_code)
await self._check_suitability(customer_id, product, context)
quote = await self._fetch_quote(product)
# ⚠️ 「校验 + 扣减」必须在**同一个行锁**内完成,加锁顺序固定:账户 → 持仓。
# 不加锁时,并发下单各自读到旧余额/旧份额,各自的校验都通过,最终把
# 可用现金扣成负数、或让卖出超出可用份额(超卖),也可能突破持仓比例上限。
account = await self._load_account(customer_id, for_update=True)
holding = await self._load_holding(customer_id, product.id, for_update=True)
quantity = payload.quantity.quantize(FOUR_PLACES, rounding=ROUND_HALF_UP)
if quantity <= 0:
raise ValidationAgentError("委托数量必须大于 0")
if quantity % product.lot_size != 0:
raise ValidationAgentError(
f"委托数量必须是产品最小交易单位 {product.lot_size} 的整数倍"
)
gross_amount = (quantity * quote.price).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
fee_rule = await self._load_fee_rule(product.id, payload.order_side)
fee_amount = self._compute_fee(gross_amount, fee_rule)
if payload.order_side == "buy":
net_amount = gross_amount + fee_amount
if account.available_cash < net_amount:
raise InsufficientFundsError(
f"可用余额 {account.available_cash} 不足"
f"(成交金额 {gross_amount} + 费用 {fee_amount})"
)
existing_qty = holding.total_quantity if holding else ZERO
new_total = existing_qty + quantity
ratio_pct = (new_total / quote.total_fund_shares * HUNDRED).quantize(
Decimal("0.0001"), rounding=ROUND_HALF_UP
)
if ratio_pct > product.single_investor_max_holding_ratio:
raise HoldingRatioExceededError(
f"买入后持仓占比 {ratio_pct}%"
f" > 上限 {product.single_investor_max_holding_ratio}%"
)
elif payload.order_side == "sell":
net_amount = (gross_amount - fee_amount).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
if holding is None or holding.available_quantity < quantity:
avail = holding.available_quantity if holding else ZERO
raise InsufficientHoldingError(
f"可用持仓 {avail} 不足以卖出 {quantity}"
)
else:
raise ValidationAgentError(f"未知委托方向 {payload.order_side!r}")
order_no = f"SO{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}{uuid4().hex[:8].upper()}"
txn_no = f"TX{order_no[2:]}"
order = FundSimOrder(
order_no=order_no,
customer_id=customer_id,
account_id=account.id,
product_id=product.id,
order_side=payload.order_side,
price_type="market",
quantity=quantity,
limit_price=None,
quote_price=quote.price,
quote_at=quote.as_of,
quote_source=quote.source,
channel="user_portal",
filled_quantity=quantity,
average_executed_price=quote.price,
status="已成交",
submitted_at=now,
created_at=now,
updated_at=now,
)
self._session.add(order)
await self._session.flush()
# fin_transaction 列全部 NOT NULL(与 docs/00 设计稿「生成列」不同,实环境是
# 普通列)。业务派生:transaction_type / nav / shares / amount / fee / confirmed_at
txn_type = "买入" if payload.order_side == "buy" else "卖出"
txn = FundTransaction(
transaction_no=txn_no,
order_id=order.id,
work_order_id=None,
customer_id=customer_id,
account_id=account.id,
product_id=product.id,
order_side=payload.order_side,
transaction_type=txn_type,
executed_price=quote.price,
nav=quote.price,
executed_quantity=quantity,
shares=quantity,
gross_amount=gross_amount,
amount=net_amount,
fee_rule_id=None,
fee_rate_snapshot=fee_rule.fee_rate,
fee_amount=fee_amount,
fee=fee_amount,
net_amount=net_amount,
quote_at=quote.as_of,
quote_source=quote.source,
executed_at=now,
confirmed_at=now,
confirmed_by=None,
auto_confirmed=1,
created_at=now,
)
self._session.add(txn)
await self._session.flush()
ledger_idem = f"{txn_no}:{payload.order_side}"
if payload.order_side == "buy":
account.cash_balance = (account.cash_balance - net_amount).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
account.available_cash = (account.available_cash - net_amount).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
entry_type = "买入扣款"
ledger_amount = -net_amount
await self._upsert_holding(
holding, customer_id, account.account_no,
product.id, quantity, quote.price, gross_amount,
)
else:
account.cash_balance = (account.cash_balance + net_amount).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
account.available_cash = (account.available_cash + net_amount).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
entry_type = "卖出回款"
ledger_amount = net_amount
await self._reduce_holding(holding, quantity) # type: ignore[arg-type]
account.updated_at = now
# 写入资金账时**不**触碰生成列
ledger = FundCashLedger(
ledger_no=f"L{txn_no[2:]}",
account_id=account.id,
transaction_id=txn.id,
entry_type=entry_type,
amount=ledger_amount,
balance_after=account.cash_balance,
available_cash_after=account.available_cash,
frozen_cash_after=account.frozen_cash,
idempotency_key=ledger_idem,
occurred_at=now,
created_at=now,
)
self._session.add(ledger)
if commit:
await self._session.commit()
return OrderCreateResponse(
order_no=order_no,
status="已成交",
executed_quantity=quantity,
executed_price=quote.price,
gross_amount=gross_amount,
fee_amount=fee_amount,
net_amount=net_amount,
executed_at=now,
)
async def _upsert_holding(
self,
holding: FundHolding | None,
customer_id: int,
trade_account: str,
product_id: int,
quantity: Decimal,
price: Decimal,
gross_amount: Decimal,
) -> None:
if holding is None:
now_h = datetime.now(UTC).replace(tzinfo=None)
self._session.add(
FundHolding(
customer_id=customer_id,
trade_account=trade_account,
product_id=product_id,
total_quantity=quantity,
shares=quantity,
available_quantity=quantity,
frozen_quantity=ZERO,
average_cost=price,
cost_amount=gross_amount,
market_value=quantity * price,
current_value=quantity * price,
profit_loss=None,
profit_loss_ratio=None,
status="持有中",
first_acquired_at=now_h,
version=0,
updated_at=now_h,
)
)
return
new_total = holding.total_quantity + quantity
new_cost = holding.cost_amount + gross_amount
holding.average_cost = (new_cost / new_total).quantize(SIX_PLACES, rounding=ROUND_HALF_UP)
holding.cost_amount = new_cost.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
holding.total_quantity = new_total
holding.shares = new_total
holding.available_quantity = holding.available_quantity + quantity
holding.updated_at = datetime.now(UTC).replace(tzinfo=None)
async def _reduce_holding(
self, holding: FundHolding, quantity: Decimal
) -> None:
ratio = (quantity / holding.total_quantity).quantize(
Decimal("0.0001"), rounding=ROUND_HALF_UP
)
reduce_cost = (holding.cost_amount * ratio).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
holding.total_quantity = holding.total_quantity - quantity
holding.shares = (
max(ZERO, holding.shares - quantity)
if holding.shares is not None
else None
)
holding.available_quantity = holding.available_quantity - quantity
holding.cost_amount = (holding.cost_amount - reduce_cost).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
if holding.total_quantity <= 0:
holding.total_quantity = ZERO
holding.shares = ZERO
holding.available_quantity = ZERO
holding.cost_amount = ZERO
holding.average_cost = ZERO
holding.status = "已清仓"
holding.updated_at = datetime.now(UTC).replace(tzinfo=None)
# ---------- 撤单 ----------
async def cancel_order(
self, order_no: str, context: RequestContext
) -> OrderSummary:
order = (
await self._session.execute(
select(FundSimOrder).where(
FundSimOrder.order_no == order_no,
FundSimOrder.customer_id == int(context.user_id),
)
)
).scalar_one_or_none()
if order is None:
raise OrderNotFoundError(f"委托 {order_no} 不存在")
if order.status != "待风控":
raise OrderNotCancellableError(
f"委托 {order_no} 当前状态为 {order.status!r},不可撤单"
)
order.status = "已撤单"
now = datetime.now(UTC).replace(tzinfo=None)
order.cancelled_at = now
order.updated_at = now
await self._session.commit()
return await self.get_order(order_no, context)
# ---------- 查询 ----------
async def get_order(self, order_no: str, context: RequestContext) -> OrderSummary:
order = (
await self._session.execute(
select(FundSimOrder).where(
FundSimOrder.order_no == order_no,
FundSimOrder.customer_id == int(context.user_id),
)
)
).scalar_one_or_none()
if order is None:
raise OrderNotFoundError(f"委托 {order_no} 不存在")
product = (
await self._session.execute(
select(FundProduct).where(FundProduct.id == order.product_id)
)
).scalar_one()
return self._to_order_summary(order, product)
async def list_orders(
self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
) -> tuple[list[OrderSummary], str | None]:
stmt = (
select(FundSimOrder)
.where(FundSimOrder.customer_id == int(context.user_id))
.order_by(FundSimOrder.id.desc())
.limit(limit + 1)
)
if cursor is not None:
stmt = stmt.where(FundSimOrder.id < cursor)
rows = (await self._session.execute(stmt)).scalars().all()
if not rows:
return [], None
next_cursor = str(rows[-1].id) if len(rows) > limit else None
rows = rows[:limit]
product_map = await self._product_map({r.product_id for r in rows})
items = [self._to_order_summary(r, product_map[r.product_id]) for r in rows]
return items, next_cursor
async def list_transactions(
self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
) -> TransactionListResponse:
stmt = (
select(FundTransaction)
.where(FundTransaction.customer_id == int(context.user_id))
.order_by(FundTransaction.id.desc())
.limit(limit + 1)
)
if cursor is not None:
stmt = stmt.where(FundTransaction.id < cursor)
rows = (await self._session.execute(stmt)).scalars().all()
next_cursor = str(rows[-1].id) if len(rows) > limit else None
rows = rows[:limit]
product_map = await self._product_map({r.product_id for r in rows})
order_map = await self._order_map({r.order_id for r in rows})
items = [
self._to_transaction_item(r, product_map[r.product_id], order_map[r.order_id])
for r in rows
]
return TransactionListResponse(transactions=items, next_cursor=next_cursor)
async def get_transaction(
self, txn_no: str, context: RequestContext
) -> TransactionItem:
row = (
await self._session.execute(
select(FundTransaction).where(
FundTransaction.transaction_no == txn_no,
FundTransaction.customer_id == int(context.user_id),
)
)
).scalar_one_or_none()
if row is None:
raise OrderNotFoundError(f"成交记录 {txn_no} 不存在")
product_map = await self._product_map({row.product_id})
order_map = await self._order_map({row.order_id})
return self._to_transaction_item(row, product_map[row.product_id], order_map[row.order_id])
async def list_holdings(self, context: RequestContext) -> HoldingListResponse:
rows = (
(
await self._session.execute(
select(FundHolding).where(
FundHolding.customer_id == int(context.user_id),
FundHolding.status == "持有中",
)
)
)
.scalars()
.all()
)
items: list[HoldingItem] = []
for h in rows:
product = (
await self._session.execute(
select(FundProduct).where(FundProduct.id == h.product_id)
)
).scalar_one()
# 只读持仓允许展示最近一笔可用行情。15 分钟新鲜度只约束真实下单,
# 避免行情暂时未更新时把客户已有的账户数据整页隐藏。
quote = await self._fetch_quote(product, enforce_freshness=False)
mv = (h.total_quantity * quote.price).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
pl = (mv - h.cost_amount).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
pl_ratio = (
(pl / h.cost_amount * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if h.cost_amount > 0
else ZERO
)
items.append(
HoldingItem(
product_id=product.id,
product_code=product.product_code,
product_name=product.product_name,
total_quantity=h.total_quantity,
available_quantity=h.available_quantity,
frozen_quantity=h.frozen_quantity,
average_cost=h.average_cost,
cost_amount=h.cost_amount,
latest_price=quote.price,
market_value=mv,
profit_loss=pl,
profit_loss_ratio=pl_ratio,
today_profit_loss=ZERO,
)
)
return HoldingListResponse(holdings=items)
async def list_cash_ledger(
self, context: RequestContext, *, limit: int = 20, cursor: int | None = None
) -> CashLedgerResponse:
account = await self._load_account(int(context.user_id))
stmt = (
select(FundCashLedger)
.where(FundCashLedger.account_id == account.id)
.order_by(FundCashLedger.id.desc())
.limit(limit + 1)
)
if cursor is not None:
stmt = stmt.where(FundCashLedger.id < cursor)
rows = (await self._session.execute(stmt)).scalars().all()
next_cursor = str(rows[-1].id) if len(rows) > limit else None
rows = rows[:limit]
txn_ids = {r.transaction_id for r in rows if r.transaction_id is not None}
txn_map = (
await self._txn_map(txn_ids) if txn_ids else {}
)
items = [
CashLedgerItem(
ledger_no=r.ledger_no,
entry_type=r.entry_type,
amount=r.amount,
balance_after=r.balance_after,
available_cash_after=r.available_cash_after,
frozen_cash_after=r.frozen_cash_after,
transaction_no=(
txn_map[r.transaction_id].transaction_no
if r.transaction_id in txn_map
else None
),
occurred_at=r.occurred_at,
)
for r in rows
]
return CashLedgerResponse(entries=items, next_cursor=next_cursor)
async def get_account_dashboard(self, context: RequestContext) -> AccountDashboardResponse:
account = await self._load_account(int(context.user_id))
holdings_resp = await self.list_holdings(context)
total_mv = sum((h.market_value for h in holdings_resp.holdings), ZERO)
total_cost = sum((h.cost_amount for h in holdings_resp.holdings), ZERO)
total_pl = (total_mv - total_cost).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
total_pl_ratio = (
(total_pl / total_cost * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if total_cost > 0 else ZERO
)
total_asset = (account.cash_balance + total_mv).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
return AccountDashboardResponse(
account=AccountSummary(
account_no=account.account_no,
status=account.status,
currency=account.currency,
initial_balance=account.initial_balance,
cash_balance=account.cash_balance,
available_cash=account.available_cash,
frozen_cash=account.frozen_cash,
),
summary=PortfolioSummary(
total_asset=total_asset,
total_market_value=total_mv,
total_cost=total_cost,
total_profit_loss=total_pl,
total_profit_loss_ratio=total_pl_ratio,
today_profit_loss=ZERO,
today_profit_loss_ratio=ZERO,
),
holdings=holdings_resp.holdings,
as_of=datetime.now(UTC).replace(tzinfo=None),
)
# ---------- 内部映射工具 ----------
def _to_order_summary(self, order: FundSimOrder, product: FundProduct) -> OrderSummary:
return OrderSummary(
order_no=order.order_no,
product_id=order.product_id,
product_code=product.product_code,
product_name=product.product_name,
order_side=order.order_side, # type: ignore[arg-type]
price_type=order.price_type, # type: ignore[arg-type]
quantity=order.quantity,
limit_price=order.limit_price,
quote_price=order.quote_price,
quote_at=order.quote_at,
filled_quantity=order.filled_quantity,
average_executed_price=order.average_executed_price,
status=order.status,
submitted_at=order.submitted_at,
cancelled_at=order.cancelled_at,
reject_reason=order.reject_reason,
)
def _to_transaction_item(
self, txn: FundTransaction, product: FundProduct, order: FundSimOrder
) -> TransactionItem:
return TransactionItem(
transaction_no=txn.transaction_no,
order_no=order.order_no,
product_id=txn.product_id,
product_code=product.product_code,
product_name=product.product_name,
order_side=txn.order_side, # type: ignore[arg-type]
executed_price=txn.executed_price,
executed_quantity=txn.executed_quantity,
gross_amount=txn.gross_amount,
fee_amount=txn.fee_amount,
net_amount=txn.net_amount,
quote_at=txn.quote_at,
executed_at=txn.executed_at,
)
async def _product_map(self, ids: set[int]) -> dict[int, FundProduct]:
if not ids:
return {}
rows = (
await self._session.execute(
select(FundProduct).where(FundProduct.id.in_(ids))
)
).scalars()
return {p.id: p for p in rows}
async def _order_map(self, ids: set[int]) -> dict[int, FundSimOrder]:
if not ids:
return {}
rows = (
await self._session.execute(
select(FundSimOrder).where(FundSimOrder.id.in_(ids))
)
).scalars()
return {o.id: o for o in rows}
async def _txn_map(self, ids: set[int]) -> dict[int, FundTransaction]:
if not ids:
return {}
rows = (
await self._session.execute(
select(FundTransaction).where(FundTransaction.id.in_(ids))
)
).scalars()
return {t.id: t for t in rows}
__all__ = ["TradeService"]