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