- 读者是第一次接触平台的人:全文不用"链路/编排/收敛"这类词,讲"点一下之后发生了什么" - 结构:三个角色(浏览器/API/Worker)→ 访客 → 登入与角色落地 → 客户 8 页 → 风控 → 投顾 → 运营三条线 → 管理员 8 个页签 → 底座 9 件事 → 一条主线时间线 → 常见疑问 → 速查 - 每节都标了文件/接口依据,并写清"已知空数据"与"已知小偏差"(通知类型下拉未接线、 NL2SQL 错误信息列恒为 --、推广页无 form 导致 required 不生效、场外默认 dry-run 等) - 顺带纠正三处易误传的说法:幂等键只防"同一次请求重发"(连点仍会下两笔)、 客户侧前端权限门是装饰性的(后端 403 才是拦截)、FM-03 熔断目前只在前端拦 - 风控演示数据那 6 行 RISKDEMO 场外申购/赎回:代码注释与今日盈亏说明改口径为 "风控异常交易演示的触发材料,刻意保留",不再当脏数据;今日盈亏按 transaction_type 语义排除它们
1094 lines
44 KiB
Python
1094 lines
44 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
|
||
触发器/表达式生成。
|
||
- **「今日盈亏」口径**(2026-09-14 由硬编码 `0` 改为真实计算,见
|
||
`docs/演示用/今日盈亏实现说明-2026-09-14.md`):
|
||
|
||
```
|
||
今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用)
|
||
昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额 (由当日成交反推)
|
||
```
|
||
|
||
基准(`昨日每份价值` / `今日每份价值`)**优先取场内行情**
|
||
(`fin_market_price` 最近两个交易日收盘价,与持仓页 `latest_price` / `market_value` 同源、
|
||
可人工核对);行情只有一天时**回退基金净值**(`fin_nav_history` 最近两个净值日)——
|
||
演示数据里 `15911` / `159991-159995` 的行情本身就来自净值序列,只有一天。
|
||
两者都不足两日 → 该持仓记 `0`(当前数据下不可达)。
|
||
- **底座 ORM 只读约定**:`app/model/fund.py` 的注释明确"不提供任何写辅助方法"。
|
||
本服务**直接使用 session.add() 写入**——SQLAlchemy 标准 ORM 写入语义不违反该约定
|
||
(约定针对的是"业务便捷方法",session.add 是数据库访问基本操作)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import func, 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,
|
||
FundNavHistory,
|
||
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
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _TodayBaseline:
|
||
"""「今日盈亏」的基准:基准日与上一交易日的每份价值。
|
||
|
||
``basis`` 只作排查用(`market_price` = 场内收盘价、`nav` = 基金净值),
|
||
响应里不暴露该字段。
|
||
"""
|
||
|
||
ref_date: date
|
||
today_value: Decimal
|
||
prev_value: Decimal
|
||
basis: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _TodayTradeFlow:
|
||
"""基准日当天的成交汇总(份额与成交金额,**不含费用**)。"""
|
||
|
||
buy_shares: Decimal
|
||
sell_shares: Decimal
|
||
buy_gross: Decimal
|
||
sell_gross: Decimal
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _HoldingView:
|
||
"""持仓行 + 该行「今日盈亏率」的分母(不进响应,只用于账户汇总)。"""
|
||
|
||
item: HoldingItem
|
||
today_profit_loss_base: Decimal
|
||
|
||
|
||
_TODAY_PL_BASIS_MARKET = "market_price"
|
||
_TODAY_PL_BASIS_NAV = "nav"
|
||
|
||
|
||
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 _latest_two_trading_days(self, product_id: int) -> list[FundMarketPrice]:
|
||
"""最近两个交易日的行情(按日期倒序)。
|
||
|
||
取 8 行再去重:同一交易日可能因重复刷行情而有多行,直接 `limit(2)` 会拿到
|
||
同一天的两行、把「昨收」算成「今收」。
|
||
"""
|
||
rows = (
|
||
(
|
||
await self._session.execute(
|
||
select(FundMarketPrice)
|
||
.where(FundMarketPrice.product_id == product_id)
|
||
.order_by(FundMarketPrice.trade_date.desc(), FundMarketPrice.id.desc())
|
||
.limit(8)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
picked: list[FundMarketPrice] = []
|
||
for row in rows:
|
||
if picked and picked[-1].trade_date == row.trade_date:
|
||
continue
|
||
picked.append(row)
|
||
if len(picked) == 2:
|
||
break
|
||
return picked
|
||
|
||
async def _latest_two_nav_days(self, product_id: int) -> list[FundNavHistory]:
|
||
"""最近两个净值日(按日期倒序,去重口径同行情)。"""
|
||
rows = (
|
||
(
|
||
await self._session.execute(
|
||
select(FundNavHistory)
|
||
.where(FundNavHistory.product_id == product_id)
|
||
.order_by(FundNavHistory.nav_date.desc(), FundNavHistory.id.desc())
|
||
.limit(8)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
picked: list[FundNavHistory] = []
|
||
for row in rows:
|
||
if picked and picked[-1].nav_date == row.nav_date:
|
||
continue
|
||
picked.append(row)
|
||
if len(picked) == 2:
|
||
break
|
||
return picked
|
||
|
||
async def _today_baseline(self, product: FundProduct) -> _TodayBaseline | None:
|
||
"""算出「基准日 / 上一交易日」的每份价值;不足两日返回 `None`。
|
||
|
||
行情优先:它与持仓页展示的 `latest_price` / `market_value` 同源,客户能自己核对。
|
||
"""
|
||
prices = await self._latest_two_trading_days(product.id)
|
||
if len(prices) == 2:
|
||
return _TodayBaseline(
|
||
ref_date=prices[0].trade_date,
|
||
today_value=prices[0].close_price,
|
||
prev_value=prices[1].close_price,
|
||
basis=_TODAY_PL_BASIS_MARKET,
|
||
)
|
||
navs = await self._latest_two_nav_days(product.id)
|
||
if len(navs) == 2:
|
||
return _TodayBaseline(
|
||
ref_date=navs[0].nav_date,
|
||
today_value=navs[0].nav,
|
||
prev_value=navs[1].nav,
|
||
basis=_TODAY_PL_BASIS_NAV,
|
||
)
|
||
return None
|
||
|
||
async def _today_trade_flow(
|
||
self, *, customer_id: int, product_id: int, ref_date: date
|
||
) -> _TodayTradeFlow:
|
||
"""基准日当天该客户在该产品上的成交汇总(用于反推昨日持仓数量)。
|
||
|
||
**只认本平台的场内成交**(`transaction_type` ∈ {买入, 卖出})。演示库里 6 行
|
||
`RISKDEMO-*` 记录(`transaction_type` 是「申购」「赎回」)是**风控「异常交易」演示的
|
||
触发材料,刻意保留**,不是真实成交:它们不改持仓表,份额也与持仓对不上
|
||
(客户 12001 实际持有 10000 份,那条"赎回"写的是 750000 份)。
|
||
把它们当当日买卖会把「昨日持仓数量」算成 76 万份、今日盈亏从 −21.22 变成 −1612.72。
|
||
这里按 `transaction_type` **语义**甄别,不依赖编号前缀 —— 换一批演示数据也不用改代码。
|
||
"""
|
||
rows = (
|
||
(
|
||
await self._session.execute(
|
||
select(FundTransaction).where(
|
||
FundTransaction.customer_id == customer_id,
|
||
FundTransaction.product_id == product_id,
|
||
FundTransaction.transaction_type.in_(("买入", "卖出")),
|
||
func.date(FundTransaction.confirmed_at) == ref_date,
|
||
)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
buy_shares = sell_shares = buy_gross = sell_gross = ZERO
|
||
for txn in rows:
|
||
if txn.order_side == "buy":
|
||
buy_shares += txn.shares
|
||
buy_gross += txn.gross_amount
|
||
elif txn.order_side == "sell":
|
||
sell_shares += txn.shares
|
||
sell_gross += txn.gross_amount
|
||
return _TodayTradeFlow(
|
||
buy_shares=buy_shares,
|
||
sell_shares=sell_shares,
|
||
buy_gross=buy_gross,
|
||
sell_gross=sell_gross,
|
||
)
|
||
|
||
@staticmethod
|
||
def _compute_today_profit_loss(
|
||
*,
|
||
quantity: Decimal,
|
||
today_value: Decimal,
|
||
prev_value: Decimal,
|
||
flow: _TodayTradeFlow,
|
||
) -> tuple[Decimal, Decimal]:
|
||
"""返回 `(今日盈亏, 今日盈亏率的分母)`,均按两位小数取整。
|
||
|
||
```
|
||
今日盈亏 = 今日市值 − 昨日持仓市值 − 今日买入金额 + 今日卖出金额 (不含交易费用)
|
||
昨日持仓数量 = 今日持仓数量 − 今日买入份额 + 今日卖出份额
|
||
```
|
||
|
||
**不含费用**是有意为之:与同页「持有盈亏 = 市值 − 成本」口径一致
|
||
(`fin_holding.cost_amount` 也只累计买入的 `gross_amount`)。
|
||
分母取 `今日市值 − 今日盈亏`(= 昨日持仓市值 + 今日买入金额 − 今日卖出金额),
|
||
这样「当日买入的部分」也能算出有意义的当日盈亏率,而不是除以 0。
|
||
|
||
`昨日持仓数量 < 0`(当日成交份额与持仓表对不上,正常路径不可达)时按「基准不可信」
|
||
记 0,不用一个自相矛盾的数量算出一个看着很确定的数。
|
||
"""
|
||
qty_at_open = quantity - flow.buy_shares + flow.sell_shares
|
||
if qty_at_open < 0:
|
||
return ZERO, (quantity * today_value).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||
today_pl = (
|
||
quantity * today_value
|
||
- qty_at_open * prev_value
|
||
- flow.buy_gross
|
||
+ flow.sell_gross
|
||
).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||
base = (quantity * today_value - today_pl).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||
return today_pl, base
|
||
|
||
# ---------- 产品与适当性 ----------
|
||
|
||
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:
|
||
views = await self._holding_views(context)
|
||
return HoldingListResponse(holdings=[view.item for view in views])
|
||
|
||
async def _holding_views(self, context: RequestContext) -> list[_HoldingView]:
|
||
"""持仓行 + 今日盈亏(含其分母)。持仓列表与账户看板共用,保证两处口径一致。"""
|
||
customer_id = int(context.user_id)
|
||
rows = (
|
||
(
|
||
await self._session.execute(
|
||
select(FundHolding).where(
|
||
FundHolding.customer_id == customer_id,
|
||
FundHolding.status == "持有中",
|
||
)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
views: list[_HoldingView] = []
|
||
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
|
||
)
|
||
today_pl, today_base = await self._holding_today_profit_loss(
|
||
customer_id=customer_id,
|
||
product=product,
|
||
quantity=h.total_quantity,
|
||
market_value=mv,
|
||
)
|
||
views.append(
|
||
_HoldingView(
|
||
item=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=today_pl,
|
||
),
|
||
today_profit_loss_base=today_base,
|
||
)
|
||
)
|
||
return views
|
||
|
||
async def _holding_today_profit_loss(
|
||
self,
|
||
*,
|
||
customer_id: int,
|
||
product: FundProduct,
|
||
quantity: Decimal,
|
||
market_value: Decimal,
|
||
) -> tuple[Decimal, Decimal]:
|
||
"""单只持仓的 `(今日盈亏, 今日盈亏率分母)`。
|
||
|
||
缺少「上一交易日」基准时(行情与净值都不足两日)无法计算,记 0 且分母取今日市值
|
||
—— 宁可显示 0 也不拿别的日期硬凑一个数(口径必须可解释)。
|
||
"""
|
||
baseline = await self._today_baseline(product)
|
||
if baseline is None:
|
||
return ZERO, market_value
|
||
flow = await self._today_trade_flow(
|
||
customer_id=customer_id, product_id=product.id, ref_date=baseline.ref_date
|
||
)
|
||
return self._compute_today_profit_loss(
|
||
quantity=quantity,
|
||
today_value=baseline.today_value,
|
||
prev_value=baseline.prev_value,
|
||
flow=flow,
|
||
)
|
||
|
||
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))
|
||
views = await self._holding_views(context)
|
||
holdings = [view.item for view in views]
|
||
total_mv = sum((h.market_value for h in holdings), ZERO)
|
||
total_cost = sum((h.cost_amount for h in 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
|
||
)
|
||
today_pl = sum((h.today_profit_loss for h in holdings), ZERO).quantize(
|
||
TWO_PLACES, rounding=ROUND_HALF_UP
|
||
)
|
||
today_base = sum((view.today_profit_loss_base for view in views), ZERO)
|
||
today_pl_ratio = (
|
||
(today_pl / today_base * HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
||
if today_base > 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=today_pl,
|
||
today_profit_loss_ratio=today_pl_ratio,
|
||
),
|
||
holdings=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"]
|