## 问题
`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
843 lines
34 KiB
Python
843 lines
34 KiB
Python
"""场内基金模拟交易 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"]
|