"""场内基金模拟交易 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"]