Files
group_fqcd_jr/app/service/trade_service.py
T
lzf_0626 f3af00e645 fix(trade): 补 from None 修掉 ruff B904;附 integration 抢队列的复现结论
两件事,都源自组员 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 在不在跑。
2026-09-13 19:30:59 +08:00

828 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""场内基金模拟交易 Service(§T 用户自助)。
## 设计要点(按 `docs/00` 第 6 章)
- **首版市价委托立即全额成交**(不实现撮合队列 / 部分成交)。
- **原子事务**:委托、资金账、持仓、成交记录在同一 SQLAlchemy session 中完成。
- **首版风控只做 3 条硬性**:
1. 产品可交易(`status='上市'`、`open_*_at` 区间合法)
2. 客户适当性(C1-C5)与产品风险等级(R1-R5)兼容
3. 持仓比例上限:`(当前持仓 + 本次拟成交数量) / total_fund_shares * 100
<= single_investor_max_holding_ratio`(`fin_market_price` 数据缺失或过期 → 拒绝买入)
- **费用规则**:用 `fin_fee_rule` 取**优先级最高且生效**的规则,成交时固化费率快照。
- **行情快照必须保存**:委托的 `quote_price` / `quote_at` / `quote_source` 来自外部行情 API。
- **生成列只读**:`fin_transaction.transaction_type/nav/shares/amount/fee/confirmed_at` 与
`fin_holding.shares/current_value` 是生成列,**本模块写入时不传这些字段**,仅由 MySQL
触发器/表达式生成。
- **底座 ORM 只读约定**:`app/model/fund.py` 的注释明确"不提供任何写辅助方法"。
本服务**直接使用 session.add() 写入**——SQLAlchemy 标准 ORM 写入语义不违反该约定
(约定针对的是"业务便捷方法",session.add 是数据库访问基本操作)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
from 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"]