两件事,都源自组员 e3c316a(下单授权与适当性校验)合并之后: 1) ruff B904:app/service/trade_service.py 在 except 分支里抛 SuitabilityMismatchError 时没写 from None/from exc,门禁因此变红。 这里被捕获的是"风险等级字段解析失败",对上层没有诊断价值, 用 from None 截断因果链即可。 2) tests/integration 出现 2 个失败,**与代码无关**,是常驻 Agent Worker 抢队列: - 带 Worker 跑:102 passed / 2 failed - 停掉 Worker 跑:104 passed(已复现两次) 失败用例是 test_complete_run_rolls_back_every_write_on_outbox_conflict 与 test_cancel_is_idempotent_and_terminates_original_request,都是 run 队列相关 —— Worker 会把测试创建的 run 领走并执行,测试自己的状态机就乱了。 这正是 docs/20 与 AGENTS.md「跑验收前先停常驻 Worker」那条的实际复现, 顺手把结论写进 commit 备查:判断 integration 红不红之前,先确认 Worker 在不在跑。
828 lines
33 KiB
Python
828 lines
33 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 typing import Any
|
||
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,
|
||
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 _next_id(self, model: Any) -> int:
|
||
"""返回 ``model`` 表的下一个可用主键。
|
||
|
||
底座 ``fin_*`` 表 ``id`` 列实际**未**配置 AUTO_INCREMENT(与 ``docs/00`` 设计稿
|
||
存在偏差),但 AGENTS.md 禁止修改既有列类型/可空性/含义。本服务按 seed
|
||
脚本同样思路用 ``SELECT MAX(id)+1`` 显式发号以保持基线零变更。
|
||
并发与单测场景下够用;后续若需要严格序数,再独立 PR 引入发号器。
|
||
"""
|
||
result = await self._session.execute(
|
||
select(func.max(model.id))
|
||
)
|
||
max_id = result.scalar()
|
||
return int(max_id or 0) + 1
|
||
|
||
# ---------- 行情 ----------
|
||
|
||
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) -> FundSimAccount:
|
||
customer_id_int = int(customer_id) if isinstance(customer_id, str) else customer_id
|
||
account = (
|
||
await self._session.execute(
|
||
select(FundSimAccount).where(FundSimAccount.customer_id == customer_id_int)
|
||
)
|
||
).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
|
||
) -> FundHolding | None:
|
||
return (
|
||
await self._session.execute(
|
||
select(FundHolding).where(
|
||
FundHolding.customer_id == customer_id,
|
||
FundHolding.product_id == product_id,
|
||
)
|
||
)
|
||
).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
|
||
) -> OrderCreateResponse:
|
||
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)
|
||
holding = await self._load_holding(customer_id, product.id)
|
||
|
||
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(
|
||
id=await self._next_id(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(
|
||
id=await self._next_id(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(
|
||
id=await self._next_id(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)
|
||
|
||
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(
|
||
id=await self._next_id(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"]
|