From d17327cee4363706679c697a1f8b8ada62098976 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 12:15:03 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(db):=20strategy=5Fruns=20=E5=8A=A0?= =?UTF-8?q?=20allocation=20=E5=88=97=20=E2=80=94=E2=80=94=20per-run=20?= =?UTF-8?q?=E8=B5=84=E9=87=91=E9=A2=9D=E5=BA=A6=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit live run 的仓位测算此前用固定 1 万虚拟现金,脱离账户真实余额。加 nullable allocation 列(老行为空=旧语义),start 时确定并落库,使 sizing 可复现、可审计。 注:与并行登录分支的 0024 同 down 0023,后并入 main 者需把迁移链修直。 Co-Authored-By: Claude Fable 5 --- .../versions/0025_strategy_run_allocation.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 infra/migrations/versions/0025_strategy_run_allocation.py diff --git a/infra/migrations/versions/0025_strategy_run_allocation.py b/infra/migrations/versions/0025_strategy_run_allocation.py new file mode 100644 index 00000000..7342d960 --- /dev/null +++ b/infra/migrations/versions/0025_strategy_run_allocation.py @@ -0,0 +1,41 @@ +"""strategy_run_allocation —— live run 的 per-run 资金额度(加列,向后兼容) + +Revision ID: 0025 +Revises: 0023 +Create Date: 2026-07-02 + +背景:live run 的仓位测算此前用**固定 1 万虚拟现金**,脱离账户真实余额——多个 run +共享同一账户现金池却各自以为有 1 万,叠加"spot BUY 无购买力检查"后现金桶被买成 +深度负值(USD 1 万开户、USDT 桶 -9,498 实锤)。 + +本迁移给 ``strategy_runs`` 加 ``allocation``(该 run 的资金额度,start 时确定并落库, +使 sizing 行为可复现、可审计): + +- nullable:老行为空 = 沿用旧语义(固定 1 万),新 run 由 API 层写入 + ``min(10000, 账户折算可用现金)`` 或用户显式值。 +- 计价按账户 ``base_currency``(与 accounts.cash_balances 折算口径一致)。 + +注:与并行分支的 0024(users 表)同 down 0023,后并入 main 者需把链修直。 +""" +from __future__ import annotations + +from alembic import op + +revision: str = "0025" +down_revision: str | None = "0023" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE strategy_runs + ADD COLUMN IF NOT EXISTS allocation NUMERIC + CHECK (allocation IS NULL OR allocation > 0) + """ + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE strategy_runs DROP COLUMN IF EXISTS allocation") From f838828f87a3f7e2c81740b2d39968d8b8287c75 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 12:15:30 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(paper):=20=E7=8E=B0=E9=87=91/?= =?UTF-8?q?=E4=BB=93=E4=BD=8D=E4=BD=93=E7=B3=BB=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20spot=20=E8=B4=AD=E4=B9=B0=E5=8A=9B?= =?UTF-8?q?=E5=AE=88=E9=97=A8=20+=20run=20=E8=B5=84=E9=87=91=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=20+=20=E5=90=8C=E6=A0=87=E7=9A=84=E5=AE=88=E9=97=A8?= =?UTF-8?q?=20+=20=E5=BF=AB=E7=85=A7=E5=B8=82=E4=BB=B7=E4=BC=B0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个互相咬合的缺口一并修(共享同一批文件,拆开会破坏各自的可编译性): 1. spot BUY 账户折算购买力守门(此前 live/HTTP 写路径无现金检查,USD 开户买 USDT 计价对把 USDT 桶买成 -9,498 实锤):各币种桶按 FX 折算成 base 总可用 现金,notional+fee 超过可用×0.99 拒单;桶仍可为负(隐式借计价货币),总折算 现金不允许被买穿。HTTP 409 INSUFFICIENT_CASH;live 记 risk_rejected 决策行 不杀 run。事务内账户行 FOR UPDATE 防 TOCTOU,锁内复用预取汇率零网络。 2. per-run allocation:POST /strategy_runs 可选 allocation,默认 min(10000, 账户折算可用现金) 落库;live session 以它为虚拟钱包做 sizing 与 run 级购买力,替代固定 1 万;账户可用 ≤0 时 422 拒 start。 3. 同标的守门(issue #108 短期方案):同账户同 (venue, symbol) 已有 running run 再 start → 409 SYMBOL_RUN_CONFLICT,堵住多 run 共享一行持仓互相打架。 4. /accounts/me 持仓估值改 mark-to-market(data /ticker 缓存价):spot 计 qty×最新价、perp 计未实现盈亏;拿不到最新价降级开仓均价/0 并入 fx_warnings 不静默。此前按开仓均价估值,总权益恒≈初始资金、不反映浮盈。 顺带修:strategy_runs list/list_all_running SELECT 漏 trading_mode/leverage (列表端点恒返 spot;重启 resume 会把 perp run 静默降级 spot/1×);/positions 响应加派生 trading_mode 字段供前端显式标注现货/合约。 Co-Authored-By: Claude Fable 5 --- .../paper/src/inalpha_paper/api/orders.py | 152 ++++++++++++++++-- .../src/inalpha_paper/api/strategy_runs.py | 73 +++++++++ .../paper/src/inalpha_paper/data_client.py | 12 +- .../src/inalpha_paper/execution/spot_guard.py | 70 +++++++- services/paper/src/inalpha_paper/fx.py | 26 +++ .../paper/src/inalpha_paper/live_runner.py | 151 ++++++++++++++++- services/paper/src/inalpha_paper/schemas.py | 26 ++- .../src/inalpha_paper/storage/accounts.py | 8 +- .../inalpha_paper/storage/strategy_runs.py | 44 +++-- services/paper/tests/conftest.py | 12 ++ .../tests/test_api_accounts_multicurrency.py | 52 +++++- services/paper/tests/test_api_orders.py | 75 +++++++++ .../paper/tests/test_api_strategy_runs.py | 65 +++++++- services/paper/tests/test_live_runner.py | 16 +- services/paper/tests/test_spot_guard.py | 69 +++++++- 15 files changed, 801 insertions(+), 50 deletions(-) diff --git a/services/paper/src/inalpha_paper/api/orders.py b/services/paper/src/inalpha_paper/api/orders.py index 2afb0004..1184856f 100644 --- a/services/paper/src/inalpha_paper/api/orders.py +++ b/services/paper/src/inalpha_paper/api/orders.py @@ -29,9 +29,14 @@ from ..execution import risk_guard as risk_guard_mod from ..execution.currency_resolver import resolve_currency from ..execution.order_executor import OrderExecutor -from ..execution.spot_guard import InsufficientPositionError, violates_spot_long_only +from ..execution.spot_guard import ( + InsufficientCashError, + InsufficientPositionError, + violates_spot_buying_power, + violates_spot_long_only, +) from ..fills import apply_fill_to_positions_and_cash -from ..fx import BaseCurrencyConverter +from ..fx import BaseCurrencyConverter, convert_cash_balances from ..fx import needs_network as fx_needs_network from ..schemas import ( AccountSnapshot, @@ -108,7 +113,12 @@ async def post_submit_order( # 落盘 + 持仓 + 现金(事务) async with db.transaction(): - acct = await accounts_store.get_or_create(db, account_id) + # spot BUY 时锁账户行:购买力守门"读余额 → 校验 → 扣款"须与并发 BUY 串行化 + # (TOCTOU,与下方 SELL 守门锁 positions 行同构)。其余路径不锁,避免无谓串行。 + acct = await accounts_store.get_or_create( + db, account_id, + for_update=(req.trading_mode != "perp" and req.side == "BUY"), + ) # perp 保证金购买力守门(**事务内 FOR UPDATE**,与下方 spot SELL 守门同口径防 TOCTOU: # 否则同账户同标的并发 BUY 各读旧 wallet/持仓双双过闸 → double-open、累计 IM 超钱包)。 @@ -135,6 +145,66 @@ async def post_submit_order( "wallet": str(wallet), "currency": currency}, ) + # 现货 BUY 购买力守门(与回测 Portfolio.can_afford_buy 同口径,账户聚合层落地): + # 各币种现金桶按 FX 折算成 base 总可用现金,notional+fee(折 base)超过可用×0.99 + # 即拒——桶允许为负(= 账户内隐式借计价货币,如 USD 户买 USDT 对),但**总折算 + # 现金不允许被买穿**。账户行已在上方 FOR UPDATE,与扣款同事务防 TOCTOU。 + # FX:USD 稳定币本地 1:1 零网络;其余币种在锁内调 data /fx(模拟盘低并发可接 + # 受);拿不到汇率的桶排除出可用现金、订单计价货币折不了则直接拒(fail-closed)。 + if req.trading_mode != "perp" and req.side == "BUY": + balances = { + cur: Decimal(str(amt)) + for cur, amt in (acct.get("cash_balances") or {}).items() + } + order_ccy = resolve_currency(req.venue, req.symbol) + base_ccy = acct["base_currency"] + fx_token = ( + authorization.removeprefix("Bearer ").strip() + if authorization and authorization.startswith("Bearer ") + else None + ) + fx_client = ( + DataClient(settings.data_service_url, fx_token) + if fx_token and fx_needs_network({*balances, order_ccy}, base_ccy) + else None + ) + try: + converter = BaseCurrencyConverter(base_ccy, fx_client) + available = await convert_cash_balances(converter, balances) + order_ccy_rate = await converter.rate(order_ccy) + finally: + if fx_client is not None: + await fx_client.close() + if violates_spot_buying_power( + side=req.side, + quantity=req.quantity, + ref_price=ref_price, + fee_rate=req.fee_rate, + order_ccy_rate=order_ccy_rate, + available_cash_base=available, + trading_mode=req.trading_mode, + ): + fx_note = ( + f"; FX warnings: {'; '.join(converter.warnings)}" + if converter.warnings + else "" + ) + raise InsufficientCashError( + f"BUY 所需资金超过账户可用现金:约 {req.quantity * ref_price:.2f} " + f"{order_ccy}(含手续费),账户折算可用 {available:.2f} {base_ccy}" + f"{fx_note}", + details={ + "venue": req.venue, + "symbol": req.symbol, + "quantity": req.quantity, + "ref_price": ref_price, + "order_currency": order_ccy, + "available_cash_base": str(available), + "base_currency": base_ccy, + "fx_warnings": converter.warnings, + }, + ) + # 现货 long-only 守门(与回测 Portfolio.can_afford_sell 同口径):OrderExecutor # 无状态、apply_fill 又允许负仓——不拦则裸空 / 超卖翻空落账成凭空做空的负仓。 # **必须在本事务内 FOR UPDATE 锁行再校验**:否则两个并发 SELL 各读旧持仓双双过闸 @@ -238,10 +308,16 @@ async def get_my_account( user: Annotated[User, Depends(get_current_user)], authorization: Annotated[str | None, Header()] = None, ) -> AccountSnapshot: - """当前账户快照:多币种 cash 桶 + 持仓估值(按 avg_open_price),折算到 base_currency。 + """当前账户快照:多币种 cash 桶 + 持仓 mark-to-market 估值,折算到 base_currency。 D-11:cash / 持仓可能跨币种,按 base_currency FX 折算汇总。本地可解析的币种 (同币种 / USD 稳定币)不打网络;其余调 data ``/fx``,拿不到的币种排除 + warning。 + + 持仓估值按**最新市价**(data ``/ticker`` 缓存价,与 dashboard 持仓表同源; + ``fresh=false`` 只读缓存不触发慢 backfill):spot 仓贡献 ``qty × mark``;perp 仓 + cash 即钱包、开仓不动名义,贡献未实现盈亏 ``(mark − avg) × qty``。最新价拿不到 → + spot 按开仓均价兜底 / perp 记 0,并入 ``fx_warnings`` 不静默(此前恒按开仓均价 + 估值,总权益基本恒等于初始资金、不反映浮盈)。 """ account_id = account_id_from_user(user) acct = await accounts_store.get_or_create(db, account_id) @@ -255,22 +331,25 @@ async def get_my_account( cash_balances: dict[str, Decimal] = { cur: Decimal(str(amt)) for cur, amt in (acct["cash_balances"] or {}).items() } - # 持仓估值(按 avg_open_price)+ realized_pnl,都按计价货币分桶 - # (NULL 行按 venue/symbol 兜底解析),稍后用同一个 converter 折算到 base。 - pos_value_by_ccy: dict[str, Decimal] = {} + # realized_pnl 按计价货币分桶(NULL 行按 venue/symbol 兜底解析); + # 持仓市值在下方 try 里逐仓取 mark 后再分桶(需要 data client)。 realized_pnl_by_ccy: dict[str, Decimal] = {} + pos_ccys: set[str] = set() + has_open_position = False for p in pos_rows: ccy = p.get("currency") or resolve_currency( p["venue"], p["symbol"], default=base_currency ) - value = Decimal(p["quantity"]) * Decimal(p["avg_open_price"]) - pos_value_by_ccy[ccy] = pos_value_by_ccy.get(ccy, Decimal(0)) + value + pos_ccys.add(ccy) + if Decimal(p["quantity"]) != 0: + has_open_position = True realized_pnl_by_ccy[ccy] = ( realized_pnl_by_ccy.get(ccy, Decimal(0)) + Decimal(p["realized_pnl"]) ) - # 只在存在非本地可解析币种时才开 DataClient(单币种 / crypto-USD 账户零网络) - all_ccys = set(cash_balances) | set(pos_value_by_ccy) | set(realized_pnl_by_ccy) + # DataClient 在两种情况下才开:FX 有非本地可解析币种,或有持仓要取 mark + # (无持仓的单币种 / crypto-USD 账户保持零网络)。 + all_ccys = set(cash_balances) | pos_ccys # token 实际上必非空:get_current_user 依赖已保证 Bearer header 合法,否则先行 401; # 这里 token=None 分支是防御性的(理论不可达),保留以防未来调用方绕过 auth。 token = ( @@ -280,13 +359,53 @@ async def get_my_account( ) data_client = ( DataClient(settings.data_service_url, token) - if token and fx_needs_network(all_ccys, base_currency) + if token and (fx_needs_network(all_ccys, base_currency) or has_open_position) else None ) # try 前初始化,确保即便 convert() 抛非预期异常也不会在 return 处 NameError(CR) fx_warnings: list[str] = [] try: converter = BaseCurrencyConverter(base_currency, data_client) + + # 持仓 mark-to-market 估值,按计价货币分桶。mark 拿不到时 spot 用 avg 兜底 + # (perp 下 (avg−avg)×qty = 0 恰为"未实现盈亏按 0 计"),并记 warning。 + pos_value_by_ccy: dict[str, Decimal] = {} + valuation_warnings: list[str] = [] + for p in pos_rows: + qty = Decimal(p["quantity"]) + if qty == 0: + continue + ccy = p.get("currency") or resolve_currency( + p["venue"], p["symbol"], default=base_currency + ) + avg = Decimal(p["avg_open_price"]) + # perp 行:强平价非空或占用保证金非 0(spot 恒 NULL/0) + is_perp = p.get("liquidation_price") is not None or ( + Decimal(str(p.get("margin_used") or 0)) != 0 + ) + mark: Decimal | None = None + if data_client is not None: + try: + ticker = await data_client.get_ticker( + venue=p["venue"], symbol=p["symbol"], fresh=False + ) + mark = Decimal(str(ticker["price"])) + if ticker.get("is_stale"): + valuation_warnings.append( + f"{p['venue']}/{p['symbol']} 最新价偏旧" + f"({ticker.get('stale_seconds')}s 前),估值可能不准" + ) + except Exception: + mark = None + if mark is None: + valuation_warnings.append( + f"{p['venue']}/{p['symbol']} 最新价不可用," + + ("perp 未实现盈亏按 0 计" if is_perp else "按开仓均价估值") + ) + mark = avg + value = (mark - avg) * qty if is_perp else qty * mark + pos_value_by_ccy[ccy] = pos_value_by_ccy.get(ccy, Decimal(0)) + value + cash_base = Decimal(0) for cur, amt in cash_balances.items(): converted = await converter.convert(amt, cur) @@ -303,7 +422,7 @@ async def get_my_account( converted = await converter.convert(amt, cur) if converted is not None: realized_pnl_base += converted - fx_warnings = converter.warnings + fx_warnings = converter.warnings + valuation_warnings finally: if data_client is not None: await data_client.close() @@ -380,4 +499,11 @@ def _decimal_to_float(row: dict[str, Any]) -> dict[str, Any]: for k in ("quantity", "avg_open_price", "realized_pnl", "margin_used", "liquidation_price"): if k in out and out[k] is not None: out[k] = float(out[k]) + # trading_mode 派生(positions 表无该列):强平价非空或占用保证金非 0 → perp。 + # 让前端显式标注现货/合约,不再靠 liquidation_price 隐式推断。 + out["trading_mode"] = ( + "perp" + if out.get("liquidation_price") is not None or (out.get("margin_used") or 0) != 0 + else "spot" + ) return out diff --git a/services/paper/src/inalpha_paper/api/strategy_runs.py b/services/paper/src/inalpha_paper/api/strategy_runs.py index 443c2453..1c3712d8 100644 --- a/services/paper/src/inalpha_paper/api/strategy_runs.py +++ b/services/paper/src/inalpha_paper/api/strategy_runs.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +from decimal import Decimal from typing import Annotated, Any, Literal from uuid import UUID @@ -22,11 +23,13 @@ from ..account_id import account_id_from_user from ..config import PaperSettings, get_paper_settings from ..execution import perp_margin +from ..fx import BaseCurrencyConverter, convert_cash_balances from ..schemas import ( StartStrategyRunRequest, StrategyRunDecisionRecord, StrategyRunRecord, ) +from ..storage import accounts as accounts_store from ..storage import strategy_candidates as candidates_store from ..storage import strategy_runs as runs_store @@ -54,6 +57,24 @@ class TooManyRunningRunsError(InalphaError): status_code = 429 +class SymbolRunConflictError(InalphaError): + """同账户同 (venue, symbol) 已有 running run(issue #108 短期守门)。 + + positions 主键 = (account_id, venue, symbol) 一标的一行,两个 run 撞同标的会共享 + 同一行持仓互相打架(PnL 归属错乱 / 一个 run 的出场平掉另一个的仓)。 + """ + + code = "SYMBOL_RUN_CONFLICT" + status_code = 409 + + +class InsufficientCashForRunError(InalphaError): + """账户折算可用现金 ≤ 0,无法为新 run 分配默认额度。""" + + code = "INSUFFICIENT_CASH_FOR_RUN" + status_code = 422 + + @router.post("/strategy_runs", response_model=StrategyRunRecord) async def start_strategy_run( req: StartStrategyRunRequest, @@ -114,6 +135,54 @@ async def start_strategy_run( }, ) + # 同标的守门(issue #108):positions 一标的一行,同账户同 (venue, symbol) 两个 run + # 会共享同一行持仓互相打架 → 拒第二个(与 candidate 唯一性约束同风格,check + insert + # 间的 TOCTOU 窗口与上方 running 上限同级,模拟盘可接受)。 + dup = await runs_store.get_running_by_symbol( + db, account_id, venue=req.venue, symbol=req.symbol + ) + if dup is not None: + raise SymbolRunConflictError( + f"account already has a running strategy_run on {req.venue}/{req.symbol} " + f"(run {dup['id']}, candidate {dup['candidate_id']}); positions are keyed by " + "(account, venue, symbol) so two runs on the same symbol would share one " + "position row and corrupt each other — stop it before starting another", + details={ + "venue": req.venue, + "symbol": req.symbol, + "running_run_id": str(dup["id"]), + "running_candidate_id": str(dup["candidate_id"]), + }, + ) + + # per-run 资金额度:显式传则原样落库(可大于账户可用——下单时账户级购买力硬底会 + # 显式拒单,run 不死);省略则取 min(10000, 账户折算可用现金),start 时确定并落库, + # 使 sizing 行为可复现、可审计。折算只用本地汇率(USD 稳定币 1:1;拿不到汇率的 + # 币种桶排除,保守),避免 start 路径依赖 data 服务。 + allocation = Decimal(str(req.allocation)) if req.allocation is not None else None + if allocation is None: + acct = await accounts_store.get_or_create(db, account_id) + converter = BaseCurrencyConverter(acct["base_currency"], None) + available = await convert_cash_balances( + converter, + { + cur: Decimal(str(amt)) + for cur, amt in (acct.get("cash_balances") or {}).items() + }, + ) + allocation = min(Decimal("10000"), available) + if allocation <= 0: + raise InsufficientCashForRunError( + f"account has no available cash for a new run " + f"(converted available {available:.2f} {acct['base_currency']}); " + "stop a run / close positions first, or pass an explicit allocation", + details={ + "available_cash_base": str(available), + "base_currency": acct["base_currency"], + "fx_warnings": converter.warnings, + }, + ) + # 撞同 candidate 已 running → runs_store.insert 抛 StrategyRunConflict(409) run = await runs_store.insert( db, @@ -125,6 +194,7 @@ async def start_strategy_run( params=req.params, trading_mode=req.trading_mode, leverage=req.leverage, + allocation=allocation, ) # 策略误投软告警(不硬拦):perp 模式下若策略疑似 long-only(有 _is_long、无做空标记), @@ -265,6 +335,9 @@ def _row_to_record(row: dict[str, Any]) -> StrategyRunRecord: params=row.get("params") or {}, trading_mode=row.get("trading_mode") or "spot", leverage=int(row.get("leverage") or 1), + allocation=( + float(row["allocation"]) if row.get("allocation") is not None else None + ), last_bar_ts=row.get("last_bar_ts"), cumulative_pnl=float(row["cumulative_pnl"]), run_log=row.get("run_log") or [], diff --git a/services/paper/src/inalpha_paper/data_client.py b/services/paper/src/inalpha_paper/data_client.py index c131848f..c15a0276 100644 --- a/services/paper/src/inalpha_paper/data_client.py +++ b/services/paper/src/inalpha_paper/data_client.py @@ -58,16 +58,20 @@ async def get_ticker( *, venue: str, symbol: str, + fresh: bool | None = None, ) -> dict[str, Any]: """``GET /ticker`` —— 服务端取最新价(D-8a' 加,给 /orders/submit 自取 refPrice)。 + ``fresh=False``:只读 data 缓存不触发慢 backfill(快照类调用用,如 /accounts/me + mark-to-market 估值);``None`` 用服务端默认(下单 refPrice 保持 fresh 语义)。 + Returns dict with: ``venue, symbol, price, ts, source, is_stale, stale_seconds``。 """ + params: dict[str, Any] = {"venue": venue, "symbol": symbol} + if fresh is not None: + params["fresh"] = fresh try: - r = await self._client.get( - "/ticker", - params={"venue": venue, "symbol": symbol}, - ) + r = await self._client.get("/ticker", params=params) except httpx.RequestError as e: raise DataServiceError( f"failed to reach data-service: {e}", diff --git a/services/paper/src/inalpha_paper/execution/spot_guard.py b/services/paper/src/inalpha_paper/execution/spot_guard.py index d4e02c03..29422a39 100644 --- a/services/paper/src/inalpha_paper/execution/spot_guard.py +++ b/services/paper/src/inalpha_paper/execution/spot_guard.py @@ -14,7 +14,10 @@ 边界约定: -- 只管 SELL(做空方向);BUY 恒放行(现金透支守门 ``can_afford_buy`` 是兄弟 gap,不在本模块范围) +- SELL 侧:``violates_spot_long_only`` 禁裸空 / 禁超卖翻空 +- BUY 侧:``violates_spot_buying_power`` 禁现金透支——账户各币种桶按 FX 折算成 base + 总可用现金后守门(桶允许为负 = 账户内隐式借计价货币,但**总折算现金不允许被买穿**; + 折算由调用方做,本模块只收折算结果保持纯函数) - ``positions`` 表底层仍保留**负仓表示能力**(``apply_fill`` 不变),为未来 opt-in 做空 / 保证金(后续单独设计)保留;本守门只在**网关层**拦截 spot long-only 违规, 接合约 / margin 后可按 risk rule 配置放宽 @@ -25,6 +28,9 @@ from inalpha_shared.errors import InalphaError +# BUY 守门保留 1% buffer(手续费 / 滑点 / 价格 jitter),与回测撮合层守门同精神。 +SPOT_BUY_SAFETY_FACTOR = Decimal("0.99") + class InsufficientPositionError(InalphaError): """SELL 量超过当前 LONG 持仓(裸空 / 超卖翻空):现货 long-only 禁止。""" @@ -33,6 +39,13 @@ class InsufficientPositionError(InalphaError): status_code = 409 +class InsufficientCashError(InalphaError): + """spot BUY 所需资金超过账户折算后总可用现金:禁透支买穿现金池。""" + + code = "INSUFFICIENT_CASH" + status_code = 409 + + def violates_spot_long_only( *, side: str, @@ -69,3 +82,58 @@ def violates_spot_long_only( if side != "SELL": return False return Decimal(str(quantity)) > Decimal(str(current_qty)) + + +def violates_spot_buying_power( + *, + side: str, + quantity: float | Decimal, + ref_price: float | Decimal, + fee_rate: float | Decimal, + order_ccy_rate: Decimal | None, + available_cash_base: Decimal, + trading_mode: str = "spot", + safety_factor: Decimal = SPOT_BUY_SAFETY_FACTOR, +) -> bool: + """判一笔订单是否违反 spot 购买力(禁透支买穿现金池)。 + + 与回测撮合层 ``Portfolio.can_afford_buy`` 同口径:``notional + fee``(折算到 + base_currency)超过 ``available_cash_base × safety_factor`` 即违规。SELL 恒不违规 + (由 :func:`violates_spot_long_only` 管);``trading_mode != "spot"`` 恒不违规 + (perp 走保证金购买力校验)。 + + Parameters + ---------- + side : str + 订单方向 ``"BUY"`` / ``"SELL"``。 + quantity : float | Decimal + 本笔订单数量(正数)。 + ref_price : float | Decimal + 撮合参考价(订单计价货币计)。 + fee_rate : float | Decimal + 手续费率。 + order_ccy_rate : Decimal | None + 1 单位订单计价货币折算成多少 base_currency;``None`` = 汇率不可用, + **fail-closed 视为违规**(算不出这笔订单值多少 base,宁拒不猜)。 + available_cash_base : Decimal + 账户各币种现金桶折算到 base_currency 后的总可用现金(拿不到汇率的桶已由 + 调用方排除;可能为负 = 已透支,任何 BUY 必拒)。 + trading_mode : str + ``"spot"``(默认)或 ``"perp"``(短路返 False)。 + safety_factor : Decimal + 可用现金折扣(默认 0.99 留 1% buffer)。 + + Returns + ------- + bool + ``True`` = 违反 spot 购买力,调用方应拒单。 + """ + if trading_mode != "spot": + return False + if side != "BUY": + return False + if order_ccy_rate is None: + return True + notional = Decimal(str(quantity)) * Decimal(str(ref_price)) + required = (notional + notional * Decimal(str(fee_rate))) * order_ccy_rate + return required > available_cash_base * safety_factor diff --git a/services/paper/src/inalpha_paper/fx.py b/services/paper/src/inalpha_paper/fx.py index 6a3d5e9f..bbc2b1cd 100644 --- a/services/paper/src/inalpha_paper/fx.py +++ b/services/paper/src/inalpha_paper/fx.py @@ -82,6 +82,17 @@ async def convert(self, amount: Decimal, currency: str) -> Decimal | None: r = await self.rate(currency) return None if r is None else amount * r + def offline_copy(self) -> BaseCurrencyConverter: + """复制一个**不打网络**的 converter(带走已缓存汇率)。 + + 购买力守门的"事务内权威复检"用:乐观预检阶段已把涉及币种的汇率预取进缓存, + 复检发生在 DB 行锁事务内——绝不能在持锁时发 HTTP。cache miss 的新币种(锁内 + 才出现的桶,极端罕见)按 FX 不可用处理(排除 + warning),fail-closed 不猜。 + """ + c = BaseCurrencyConverter(self._base, None) + c._cache = dict(self._cache) + return c + def _warn(self, currency: str, reason: str) -> None: self._warnings.setdefault(currency, reason) @@ -89,3 +100,18 @@ def _warn(self, currency: str, reason: str) -> None: def warnings(self) -> list[str]: """fx_warnings 文案列表(每币种一条,去重)。""" return list(self._warnings.values()) + + +async def convert_cash_balances( + converter: BaseCurrencyConverter, cash_balances: dict[str, Decimal] +) -> Decimal: + """把多币种现金桶折算到 base 求和(拿不到汇率的桶排除,warning 已记在 converter)。 + + ``/accounts/me`` 快照与 spot BUY 购买力守门共用,保证两处"总可用现金"口径一致。 + """ + total = Decimal(0) + for cur, amt in cash_balances.items(): + converted = await converter.convert(amt, cur) + if converted is not None: + total += converted + return total diff --git a/services/paper/src/inalpha_paper/live_runner.py b/services/paper/src/inalpha_paper/live_runner.py index 04dc8c98..812b32d1 100644 --- a/services/paper/src/inalpha_paper/live_runner.py +++ b/services/paper/src/inalpha_paper/live_runner.py @@ -35,12 +35,14 @@ from .execution.order_executor import OrderExecutor from .execution.risk_guard_factory import RiskGuardFactory from .execution.spot_guard import ( + InsufficientCashError, InsufficientPositionError, + violates_spot_buying_power, violates_spot_long_only, ) from .factor_patrol import capture_factor_baseline from .fills import apply_fill_to_positions_and_cash -from .fx import BaseCurrencyConverter, needs_network +from .fx import BaseCurrencyConverter, convert_cash_balances, needs_network from .kernel.identifiers import InstrumentId, StrategyId from .model.data import Bar from .model.orders import Order, is_protective_order @@ -94,7 +96,9 @@ def _classify_build_error(exc: BaseException) -> tuple[str, bool]: _FEE_RATE = 0.001 -_LIVE_INITIAL_CASH = 10_000.0 # session 内部持仓视图用;真实现金在 DB 账户 +# run 无 allocation(老数据)时的 fallback 额度;新 run 由 API 层落库 +# min(10000, 账户折算可用现金) 或用户显式值,session 以它为虚拟钱包做 sizing。 +_LIVE_INITIAL_CASH = 10_000.0 _LIVE_RUNNER_APPROVER = "system:live_runner" _PLAN_EXPIRE_S = 300 @@ -459,12 +463,15 @@ async def _build_session( strategy_cls = load_strategy_class(code) verify_strategy_contract(strategy_cls) instrument_id = InstrumentId(symbol=run["symbol"], venue=run["venue"]) + # per-run 资金额度:sizing 与 run 级购买力(step 1.7 ①)都以它为上限; + # 老 run 行 allocation 为空 → 沿用旧语义固定 1 万。 + allocation = run.get("allocation") session = LiveEngineSession( strategy_cls=strategy_cls, instrument_id=instrument_id, timeframe=run["timeframe"], params=run.get("params") or {}, - initial_cash=_LIVE_INITIAL_CASH, + initial_cash=float(allocation) if allocation is not None else _LIVE_INITIAL_CASH, fee_rate=_FEE_RATE, # ADR-0052:框架级持仓保护止损(与回测共用同一阈值,行为一致) protective_stop_loss_pct=self._settings.protective_stop_loss_pct, @@ -970,6 +977,87 @@ async def _route_through_plan_exec( ) return "rejected" + # 1.7 现货 BUY 购买力守门(与 HTTP /orders/submit 同口径,双层): + # ① run 级:session Portfolio 以本 run 的 allocation 为虚拟钱包,cash 不足 = + # 该 run 额度花完 → 拒(多 run 共享账户时各自的资金边界)。 + # ② 账户级硬底:各币种现金桶按 FX 折算成 base 总可用现金,notional+fee 超过 + # 可用×0.99 即拒——桶允许为负(隐式借计价货币),总折算现金不允许被买穿。 + # 此处为乐观预检;plan/exec 事务内另有 FOR UPDATE 权威复检闭 TOCTOU(复用本处 + # 预取的汇率缓存,锁内零网络)。拒单记 risk_rejected 决策行,不杀 run——下一根 + # bar 重估(平仓回血后自然恢复)。spot 无保护性 BUY(protective 出场恒 SELL), + # 不需豁免;perp BUY 由 1.6 保证金守门管。 + spot_buy_converter: BaseCurrencyConverter | None = None + if (run.get("trading_mode") or "spot") != "perp" and side == "BUY": + close = float(bar.close) + if not session.portfolio.can_afford_buy(order.quantity, close): + reason = ( + f"ALLOCATION_EXCEEDED: BUY {order.quantity} × {close} 超出本 run " + f"剩余额度 {session.portfolio.cash:.2f}(run 虚拟钱包)" + ) + session.reject_order( + order=order, strategy_id=strategy_id, reason=reason, ts_event=bar.ts_event, + ) + async with get_conn() as conn: + await runs_store.append_log( + conn, run_id, "warn", f"order rejected by allocation: {reason}" + ) + await self._record_decision( + conn, run, order, bar, outcome="risk_rejected", intent=intent, + reason=reason, + ) + return "risk_rejected" + + order_ccy = resolve_currency(venue, symbol) + async with get_conn() as conn: + acct = await accounts_store.get_or_create(conn, account_id) + balances = { + cur: Decimal(str(amt)) + for cur, amt in (acct.get("cash_balances") or {}).items() + } + base_ccy = acct["base_currency"] + fx_client = ( + DataClient( + self._settings.data_service_url, + self._mint_service_token(account_id), + ) + if needs_network({*balances, order_ccy}, base_ccy) + else None + ) + try: + spot_buy_converter = BaseCurrencyConverter(base_ccy, fx_client) + available = await convert_cash_balances(spot_buy_converter, balances) + order_ccy_rate = await spot_buy_converter.rate(order_ccy) + finally: + if fx_client is not None: + await fx_client.close() + if violates_spot_buying_power( + side=side, quantity=order.quantity, ref_price=close, + fee_rate=_FEE_RATE, order_ccy_rate=order_ccy_rate, + available_cash_base=available, + trading_mode=run.get("trading_mode") or "spot", + ): + fx_note = ( + f"; FX warnings: {'; '.join(spot_buy_converter.warnings)}" + if spot_buy_converter.warnings + else "" + ) + reason = ( + f"INSUFFICIENT_CASH: BUY 约 {order.quantity * close:.2f} {order_ccy}" + f"(含手续费)超过账户折算可用 {available:.2f} {base_ccy}{fx_note}" + ) + session.reject_order( + order=order, strategy_id=strategy_id, reason=reason, ts_event=bar.ts_event, + ) + async with get_conn() as conn: + await runs_store.append_log( + conn, run_id, "warn", f"order rejected by buying power: {reason}" + ) + await self._record_decision( + conn, run, order, bar, outcome="risk_rejected", intent=intent, + reason=reason, + ) + return "risk_rejected" + # 2. 撮合(纯函数,ref_price = bar.close) result = OrderExecutor.execute( venue=venue, @@ -1071,6 +1159,46 @@ async def _route_through_plan_exec( quantity=exec_qty, price=order.price, ref_price=float(bar.close), fee_rate=_FEE_RATE, ) + # 现货 BUY 购买力权威复检(事务内 FOR UPDATE 锁账户行):闭合 step 1.7 + # 乐观预检与本 apply 跨事务的 TOCTOU——并发 run 各读旧余额双双过预检, + # 这里锁行串行化,第二个读到扣款后余额 → raise 回滚转 except 拒单。 + # 汇率复用预检的缓存(offline_copy 不打网络,持锁期间零 HTTP);锁内才 + # 出现的新币种桶按 FX 不可用排除(fail-closed)。 + if ( + (run.get("trading_mode") or "spot") != "perp" + and side == "BUY" + and result["status"] == "FILLED" + ): + locked_acct = await accounts_store.get_or_create( + conn, account_id, for_update=True + ) + locked_base_ccy = locked_acct["base_currency"] + offline_fx = ( + spot_buy_converter.offline_copy() + if spot_buy_converter is not None + else BaseCurrencyConverter(locked_base_ccy, None) + ) + locked_balances = { + cur: Decimal(str(amt)) + for cur, amt in (locked_acct.get("cash_balances") or {}).items() + } + locked_available = await convert_cash_balances( + offline_fx, locked_balances + ) + locked_order_ccy = resolve_currency(venue, symbol) + if violates_spot_buying_power( + side=side, quantity=exec_qty, ref_price=float(bar.close), + fee_rate=_FEE_RATE, + order_ccy_rate=await offline_fx.rate(locked_order_ccy), + available_cash_base=locked_available, + trading_mode=run.get("trading_mode") or "spot", + ): + raise InsufficientCashError( + f"INSUFFICIENT_CASH: BUY 约 " + f"{exec_qty * float(bar.close):.2f} {locked_order_ccy}" + f"(含手续费)超过账户折算可用 " + f"{locked_available:.2f} {locked_base_ccy}" + ) plan = await plans_store.create( conn, account_id=account_id, intent=intent, venue=venue, symbol=symbol, order_params=order_params, rationale=rationale, @@ -1151,6 +1279,23 @@ async def _route_through_plan_exec( reason=e.message, ) return "rejected" + except InsufficientCashError as e: + # 现货 BUY 购买力权威复检命中并发竞态(另一 run/HTTP 单先扣了款):事务已 + # 回滚,补 session 拒单 + risk_rejected 决策行,不杀 run(下一根 bar 重估)。 + session.reject_order( + order=order, strategy_id=strategy_id, + reason=e.message, ts_event=bar.ts_event, + ) + async with get_conn() as conn: + await runs_store.append_log( + conn, run_id, "warn", + f"order rejected by buying power (txn race): {e.message}", + ) + await self._record_decision( + conn, run, order, bar, outcome="risk_rejected", intent=intent, + reason=e.message, + ) + return "risk_rejected" # 4. 回灌 session:成交更新 portfolio + 策略持仓视图;未成交清理 ExecutionEngine 状态 # 已知残差(保护性钳量场景):confirm_fill 按钳后量(如 0.5)增量减仓,DB 已被钳到全平 diff --git a/services/paper/src/inalpha_paper/schemas.py b/services/paper/src/inalpha_paper/schemas.py index 1d9d031c..b9213ceb 100644 --- a/services/paper/src/inalpha_paper/schemas.py +++ b/services/paper/src/inalpha_paper/schemas.py @@ -725,6 +725,12 @@ class PositionRecord(BaseModel): liquidation_price: float | None = Field( default=None, description="perp 强平价(mark 穿越即强平);spot 为 null" ) + trading_mode: Literal["spot", "perp"] = Field( + default="spot", + description="派生字段(positions 表无该列):强平价非空或占用保证金非 0 → perp。" + "前端据此显式标注现货/合约,不要再靠 liquidation_price 隐式推断。" + "已平仓(quantity=0)的 perp 行保证金已清零,会派生成 spot——无敞口,可接受", + ) updated_at: datetime @@ -746,10 +752,12 @@ class AccountSnapshot(BaseModel): ) positions_value: float = Field( default=0.0, - description="所有持仓按 avg_open_price 估值并折算到 base_currency(D-8b 不接实时 mark)", + description="持仓 mark-to-market 估值折算到 base_currency:spot 仓 = qty×最新价" + "(含未实现盈亏);perp 仓 cash 即钱包,贡献未实现盈亏 (mark−avg)×qty。" + "最新价拿不到的仓 spot 按开仓均价兜底 / perp 记 0,见 fx_warnings", ) total_equity: float = Field( - default=0.0, description="base_currency 计:cash + positions_value" + default=0.0, description="base_currency 计:cash + positions_value(含未实现盈亏)" ) realized_pnl: float = Field( default=0.0, @@ -757,8 +765,8 @@ class AccountSnapshot(BaseModel): ) fx_warnings: list[str] = Field( default_factory=list, - description="D-11:折算时 FX 不可用 / 偏旧的币种告警;非空时估值可能不完整," - "agent 须把告警原样转告用户", + description="D-11:估值告警——FX 不可用 / 偏旧的币种,或持仓最新价不可用 / 偏旧;" + "非空时估值可能不完整,agent 须把告警原样转告用户", ) created_at: datetime updated_at: datetime @@ -868,6 +876,12 @@ class StartStrategyRunRequest(BaseModel): "仅 crypto 永续标的 BTC/USDT:USDT 生效)。perp 须配做空逻辑的策略,否则会告警。", ) leverage: int = Field(default=1, ge=1, le=20, description="杠杆倍数(perp 用,1..20);spot 恒 1") + allocation: float | None = Field( + default=None, gt=0, le=1e9, + description="本 run 的资金额度(账户 base_currency 计):sizing 与 run 级购买力" + "都以它为上限,多 run 共享账户时各自的资金边界。省略时服务端取 " + "min(10000, 账户折算可用现金);账户可用 ≤0 时拒绝 start", + ) class StrategyRunRecord(BaseModel): @@ -883,6 +897,10 @@ class StrategyRunRecord(BaseModel): params: dict[str, Any] = Field(default_factory=dict) trading_mode: str = "spot" leverage: int = 1 + allocation: float | None = Field( + default=None, + description="本 run 的资金额度(账户 base_currency 计);老数据为 null(旧语义固定 1 万)", + ) last_bar_ts: datetime | None = None cumulative_pnl: float = 0.0 run_log: list[dict[str, Any]] = Field( diff --git a/services/paper/src/inalpha_paper/storage/accounts.py b/services/paper/src/inalpha_paper/storage/accounts.py index 93d3b30a..facc3881 100644 --- a/services/paper/src/inalpha_paper/storage/accounts.py +++ b/services/paper/src/inalpha_paper/storage/accounts.py @@ -30,12 +30,17 @@ async def get_or_create( *, initial_cash: Decimal = DEFAULT_INITIAL_CASH, base_currency: str = DEFAULT_BASE_CURRENCY, + for_update: bool = False, ) -> dict[str, Any]: """按 account_id 查账户;不存在则按默认初始资金创建。 初始资金落在 ``base_currency`` 桶(``cash_balances = {base_currency: initial_cash}``)。 幂等:UPSERT 走 ON CONFLICT DO NOTHING,并发首单不会重复初始化。 返回最新账户行(含 ``initial_cash`` / ``base_currency`` / ``cash_balances`` dict)。 + + ``for_update=True``:``SELECT ... FOR UPDATE`` 锁账户行——spot BUY 购买力守门在 + 事务内复检时用,把"读余额 → 校验 → 扣款"串行化,堵并发 BUY 各读旧余额双双过闸 + 的 TOCTOU(与 positions 行 SELL 守门同构)。须在事务内调用。 """ async with conn.cursor() as cur: await cur.execute( @@ -50,7 +55,8 @@ async def get_or_create( await cur.execute( "SELECT account_id, initial_cash, base_currency, cash_balances, " "created_at, updated_at " - "FROM accounts WHERE account_id = %s", + "FROM accounts WHERE account_id = %s" + + (" FOR UPDATE" if for_update else ""), (str(account_id),), ) row = await cur.fetchone() diff --git a/services/paper/src/inalpha_paper/storage/strategy_runs.py b/services/paper/src/inalpha_paper/storage/strategy_runs.py index f201ba3e..8a37b497 100644 --- a/services/paper/src/inalpha_paper/storage/strategy_runs.py +++ b/services/paper/src/inalpha_paper/storage/strategy_runs.py @@ -38,6 +38,7 @@ async def insert( params: dict[str, Any] | None = None, trading_mode: str = "spot", leverage: int = 1, + allocation: Decimal | None = None, ) -> dict[str, Any]: """创建一行 status='running' 的 run。同 candidate 已有 running → StrategyRunConflict。""" try: @@ -46,16 +47,16 @@ async def insert( """ INSERT INTO strategy_runs ( candidate_id, account_id, status, venue, symbol, timeframe, params, - trading_mode, leverage - ) VALUES (%s, %s, 'running', %s, %s, %s, %s::jsonb, %s, %s) + trading_mode, leverage, allocation + ) VALUES (%s, %s, 'running', %s, %s, %s, %s::jsonb, %s, %s, %s) RETURNING id, candidate_id, account_id, status, venue, symbol, - timeframe, params, trading_mode, leverage, + timeframe, params, trading_mode, leverage, allocation, last_bar_ts, cumulative_pnl, run_log, started_at, stopped_at """, ( str(candidate_id), str(account_id), venue, symbol, timeframe, - json.dumps(params or {}), trading_mode, leverage, + json.dumps(params or {}), trading_mode, leverage, allocation, ), ) row = await cur.fetchone() @@ -73,8 +74,8 @@ async def get(conn: AsyncConnection, run_id: UUID) -> dict[str, Any] | None: async with conn.cursor() as cur: await cur.execute( "SELECT id, candidate_id, account_id, status, venue, symbol, timeframe, " - "params, trading_mode, leverage, last_bar_ts, cumulative_pnl, run_log, " - "started_at, stopped_at, factor_baseline, factor_alerts " + "params, trading_mode, leverage, allocation, last_bar_ts, cumulative_pnl, " + "run_log, started_at, stopped_at, factor_baseline, factor_alerts " "FROM strategy_runs WHERE id = %s", (str(run_id),), ) @@ -90,9 +91,12 @@ async def list_by_account( candidate_id: UUID | None = None, limit: int = 200, ) -> list[dict[str, Any]]: + # trading_mode / leverage / allocation 必须在列(此前漏了前两者 → list 端点 + # _row_to_record 恒 fallback 'spot'/1,dashboard runner 列表看不出合约模式)。 sql = ( "SELECT id, candidate_id, account_id, status, venue, symbol, timeframe, " - "params, last_bar_ts, cumulative_pnl, run_log, started_at, stopped_at " + "params, trading_mode, leverage, allocation, last_bar_ts, cumulative_pnl, " + "run_log, started_at, stopped_at " "FROM strategy_runs WHERE account_id = %s" ) args: list[Any] = [str(account_id)] @@ -124,6 +128,26 @@ async def count_running_by_account(conn: AsyncConnection, account_id: UUID) -> i return int(row["n"]) if row else 0 +async def get_running_by_symbol( + conn: AsyncConnection, account_id: UUID, *, venue: str, symbol: str +) -> dict[str, Any] | None: + """查同账户同 (venue, symbol) 是否已有 running 的 run(issue #108 同标的守门)。 + + positions 主键 = (account_id, venue, symbol) 一标的一行,两个 run 撞同标的会共享 + 同一行持仓互相打架(PnL 归属错乱 / 互相平仓)——start 前用本查询拒绝第二个。 + 返回命中的第一行(带 id / candidate_id 供错误信息引用),无则 None。 + """ + async with conn.cursor() as cur: + await cur.execute( + "SELECT id, candidate_id FROM strategy_runs " + "WHERE account_id = %s AND status = %s AND venue = %s AND symbol = %s " + "LIMIT 1", + (str(account_id), _RUNNING, venue, symbol), + ) + row = await cur.fetchone() + return row # type: ignore[return-value] + + async def set_status( conn: AsyncConnection, run_id: UUID, @@ -246,10 +270,12 @@ async def list_all_running(conn: AsyncConnection) -> list[dict[str, Any]]: 多实例横向扩展时需按 runner_instance_id 限定作用域(#38.1),那之前别多副本跑。 """ async with conn.cursor() as cur: + # trading_mode / leverage / allocation 必须在列:resume 的 run dict 直接喂 + # _build_session——此前漏了前两者,重启 resume 后 perp run 会静默降级成 spot/1×。 await cur.execute( "SELECT id, candidate_id, account_id, status, venue, symbol, timeframe, " - "params, last_bar_ts, cumulative_pnl, run_log, started_at, stopped_at, " - "factor_baseline, factor_alerts " + "params, trading_mode, leverage, allocation, last_bar_ts, cumulative_pnl, " + "run_log, started_at, stopped_at, factor_baseline, factor_alerts " "FROM strategy_runs WHERE status = %s ORDER BY started_at", (_RUNNING,), ) diff --git a/services/paper/tests/conftest.py b/services/paper/tests/conftest.py index 787d790a..9ef7e764 100644 --- a/services/paper/tests/conftest.py +++ b/services/paper/tests/conftest.py @@ -161,13 +161,25 @@ async def _isolate_risk_state_in_tests(app_with_lifespan: Any) -> AsyncIterator[ test_api_orders.py 的 BTC/USDT 请求;session 内每个 test 前清表。 test_api_risk.py 自带 ``risk_locks_table`` fixture 在 yield 后再 DELETE, 两层叠加无害。 + + 3. **重置共享 test-user 账户现金** —— 共享 sub 的账户跨测试/跨运行累计买入, + spot BUY 购买力守门下反复本地跑同一套件会把折算现金逐次扣穿,让老的 + happy-path 买入用例在第 N 次运行才开始 409(隐性 flaky)。每个 test 前删 + 账户行,get_or_create 会按默认 1 万重建;positions 保留(裸空守门只会因此 + 更宽松,不产生误拒)。 """ from inalpha_shared.db import get_conn + from inalpha_paper.account_id import account_id_from_sub + app_with_lifespan.state.risk_guard_factory = None async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute("TRUNCATE TABLE risk_locks RESTART IDENTITY") + await cur.execute( + "DELETE FROM accounts WHERE account_id = %s", + (str(account_id_from_sub("test-user")),), + ) yield diff --git a/services/paper/tests/test_api_accounts_multicurrency.py b/services/paper/tests/test_api_accounts_multicurrency.py index e5eecc08..fe41483c 100644 --- a/services/paper/tests/test_api_accounts_multicurrency.py +++ b/services/paper/tests/test_api_accounts_multicurrency.py @@ -43,12 +43,14 @@ def test_accounts_me_multicurrency_after_crypto_buy(client: TestClient) -> None: assert body["cash_balances"]["USDT"] == pytest.approx(-500.5) # base(USD) 折算总现金 = 10000 + (-500.5)×1.0 assert body["cash"] == pytest.approx(9_499.5) - # 持仓估值(avg_open_price)折算到 USD:0.01×50000×1.0 + # 持仓 mark-to-market:测试环境 data 不可达 → 最新价拿不到,fallback 开仓均价 + # (0.01×50000×1.0)并出估值告警(不静默,金融时效硬约束) assert body["positions_value"] == pytest.approx(500.0) # 总权益 = 现金 + 持仓 = 10000 - fee(0.5) assert body["total_equity"] == pytest.approx(9_999.5) - # USD / USDT 都本地可解析 → 无 FX 告警、无网络 - assert body["fx_warnings"] == [] + # USD / USDT 都本地可解析 → 无 FX 折算告警;唯一告警是最新价不可用的降级说明 + assert len(body["fx_warnings"]) == 1 + assert "最新价不可用" in body["fx_warnings"][0] def test_realized_pnl_converted_to_base(client: TestClient) -> None: @@ -69,7 +71,8 @@ def test_realized_pnl_converted_to_base(client: TestClient) -> None: body = client.get("/accounts/me", headers=headers).json() # realized_pnl 经 USDT→USD(1.0) 折算 = 100;走的是分桶折算路径而非裸相加 assert body["realized_pnl"] == pytest.approx(100.0) - assert body["fx_warnings"] == [] + # 剩余 0.01 未平仓 → 测试环境拿不到最新价,只有 mark 降级告警,无 FX 折算告警 + assert all("FX" not in w for w in body["fx_warnings"]) def test_realized_pnl_includes_fully_closed_positions(client: TestClient) -> None: @@ -93,6 +96,45 @@ def test_realized_pnl_includes_fully_closed_positions(client: TestClient) -> Non assert body["realized_pnl"] == pytest.approx(50.0) +def test_positions_value_marks_to_market( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """最新价可用时持仓按市价估值(含浮盈),总权益随行情浮动。 + + stub 掉 DataClient:买 0.01 @ 50000 后 mark 涨到 60000 → + positions_value = 0.01×60000 = 600,总权益 = 9499.5 + 600 = 10099.5(浮盈计入)。 + """ + + class _StubDataClient: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + async def get_ticker( + self, *, venue: str, symbol: str, fresh: bool | None = None + ) -> dict[str, object]: + return {"venue": venue, "symbol": symbol, "price": 60_000.0, "is_stale": False} + + async def close(self) -> None: + pass + + monkeypatch.setattr("inalpha_paper.api.orders.DataClient", _StubDataClient) + + _, token = fresh_account_token("mc") + headers = {"Authorization": f"Bearer {token}"} + client.post( + "/orders/submit", + headers=headers, + json={ + "symbol": "BTC/USDT", "side": "BUY", "type": "MARKET", + "quantity": 0.01, "ref_price": 50_000.0, + }, + ) + body = client.get("/accounts/me", headers=headers).json() + assert body["positions_value"] == pytest.approx(600.0) + assert body["total_equity"] == pytest.approx(10_099.5) + assert body["fx_warnings"] == [] + + def test_positions_carry_currency(client: TestClient) -> None: """/positions 行带 currency(crypto → USDT)。""" _, token = fresh_account_token("mc") @@ -112,3 +154,5 @@ def test_positions_carry_currency(client: TestClient) -> None: assert len(rows) == 1 assert rows[0]["symbol"] == "BTC/USDT" assert rows[0]["currency"] == "USDT" + # 派生模式字段:现货仓显式标 spot(前端徽标判定依据) + assert rows[0]["trading_mode"] == "spot" diff --git a/services/paper/tests/test_api_orders.py b/services/paper/tests/test_api_orders.py index 72c9a4af..9fc08fab 100644 --- a/services/paper/tests/test_api_orders.py +++ b/services/paper/tests/test_api_orders.py @@ -222,6 +222,81 @@ def test_submit_negative_quantity_rejected( assert r.status_code == 400 +def test_spot_buy_insufficient_cash_rejected(client: TestClient) -> None: + """spot BUY 超过账户折算可用现金 → 409 INSUFFICIENT_CASH,不落账。 + + 账户初始 10000 USD;买 0.5 BTC @ 50000 = 25000 USDT(1:1 折 USD)远超可用 → 拒。 + 独立账户(fresh sub):订单表不 truncate,共享 test-user 会带上别的用例的流水。 + """ + from .conftest import fresh_account_token + + _, token = fresh_account_token("bp") + headers = {"Authorization": f"Bearer {token}"} + r = client.post( + "/orders/submit", + headers=headers, + json={ + "symbol": "BTC/USDT", + "side": "BUY", + "type": "MARKET", + "quantity": 0.5, + "ref_price": 50_000.0, + }, + ) + assert r.status_code == 409, r.json() + assert r.json()["code"] == "INSUFFICIENT_CASH" + listed = client.get("/orders", headers=headers, params={"symbol": "BTC/USDT"}) + assert listed.status_code == 200 + assert listed.json() == [] + + +def test_spot_buy_cross_currency_within_converted_cash_ok(client: TestClient) -> None: + """USD 开户买 USDT 计价对:USDT 桶允许为负,但总折算现金不被买穿。 + + 买 0.1 BTC @ 50000 = 5005(USDT,含 fee):USDT 桶 → 负,USD 桶不动, + 折算总现金仍为正 → 放行;随后再买 0.12 BTC(6006 > 剩余 4995×0.99)→ 拒。 + """ + from .conftest import fresh_account_token + + _, token = fresh_account_token("bp") + headers = {"Authorization": f"Bearer {token}"} + r1 = client.post( + "/orders/submit", + headers=headers, + json={ + "symbol": "BTC/USDT", + "side": "BUY", + "type": "MARKET", + "quantity": 0.1, + "ref_price": 50_000.0, + }, + ) + assert r1.status_code == 200, r1.json() + assert r1.json()["status"] == "FILLED" + + acct = client.get("/accounts/me", headers=headers) + assert acct.status_code == 200 + body = acct.json() + # USDT 桶为负(账户内隐式借计价货币),USD 桶原封不动,总折算现金为正 + assert body["cash_balances"]["USDT"] < 0 + assert body["cash_balances"]["USD"] == 10_000.0 + assert 0 < body["cash"] < 10_000.0 + + r2 = client.post( + "/orders/submit", + headers=headers, + json={ + "symbol": "BTC/USDT", + "side": "BUY", + "type": "MARKET", + "quantity": 0.12, + "ref_price": 50_000.0, + }, + ) + assert r2.status_code == 409, r2.json() + assert r2.json()["code"] == "INSUFFICIENT_CASH" + + def test_submit_naked_short_rejected( client: TestClient, auth_headers: dict[str, str] ) -> None: diff --git a/services/paper/tests/test_api_strategy_runs.py b/services/paper/tests/test_api_strategy_runs.py index bd77a8e1..22e52141 100644 --- a/services/paper/tests/test_api_strategy_runs.py +++ b/services/paper/tests/test_api_strategy_runs.py @@ -189,18 +189,71 @@ async def test_start_happy_path_and_duplicate( body = r.json() assert body["status"] == "running" assert body["symbol"] == "BTC/USDT" + # 默认 allocation = min(10000, 账户折算可用现金);新账户即 10000,落库并回显 + assert body["allocation"] == 10_000.0 assert len(started) == 1 # manager.start 被调用 - # 同 candidate 第二个 running → 409 + # 同 candidate 第二个 running → 409(换 symbol 避开同标的守门,专测 candidate 唯一性) r2 = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid), "venue": "binance", "symbol": "BTC/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid), "venue": "binance", "symbol": "ETH/USDT", "timeframe": "1h"}, ) assert r2.status_code == 409 assert r2.json()["code"] == "STRATEGY_RUN_ALREADY_RUNNING" +async def test_same_symbol_second_run_conflict( + client: TestClient, app_with_lifespan: Any +) -> None: + """同账户同 (venue, symbol) 第二个 run → 409 SYMBOL_RUN_CONFLICT(issue #108)。 + + positions 一标的一行,两个 run 撞同标的会共享持仓互相打架 → start 即拒。 + """ + _stub_manager(app_with_lifespan) + headers = _headers(client) + cid1 = await _make_promoted_candidate() + r1 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid1), "venue": "binance", "symbol": "SOL/USDT", "timeframe": "1h"}, + ) + assert r1.status_code == 200, r1.json() + + # 不同 candidate、同 venue+symbol → 被同标的守门拒 + cid2 = await _make_promoted_candidate() + r2 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid2), "venue": "binance", "symbol": "SOL/USDT", "timeframe": "1h"}, + ) + assert r2.status_code == 409, r2.json() + assert r2.json()["code"] == "SYMBOL_RUN_CONFLICT" + + # 换 symbol 即可正常 start(守门只按标的,不锁账户) + r3 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid2), "venue": "binance", "symbol": "ETH/USDT", "timeframe": "1h"}, + ) + assert r3.status_code == 200, r3.json() + + +async def test_explicit_allocation_recorded( + client: TestClient, app_with_lifespan: Any +) -> None: + """显式 allocation 原样落库回显(可大于账户可用——下单时账户级硬底会拒单,start 不拦)。""" + started = _stub_manager(app_with_lifespan) + cid = await _make_promoted_candidate() + r = client.post( + "/strategy_runs", headers=_headers(client), + json={"candidate_id": str(cid), "venue": "binance", "symbol": "BTC/USDT", + "timeframe": "1h", "allocation": 2_500.0}, + ) + assert r.status_code == 200, r.json() + assert r.json()["allocation"] == 2_500.0 + # manager 收到的 run dict 也带 allocation(runner 用它当 session 虚拟钱包) + assert len(started) == 1 + assert float(started[0]["allocation"]) == 2_500.0 + + async def test_list_invalid_status_rejected(client: TestClient, app_with_lifespan: Any) -> None: """status 传非法值 → 请求校验失败(不静默返空列表)。""" _stub_manager(app_with_lifespan) @@ -332,12 +385,12 @@ async def test_per_account_run_cap(client: TestClient, app_with_lifespan: Any) - ) app_with_lifespan.dependency_overrides[get_paper_settings] = lambda: small - # 起满 2 个(不同 candidate,各归本账户) - for _ in range(2): + # 起满 2 个(不同 candidate + 不同 symbol,避开同标的守门,各归本账户) + for symbol in ("BTC/USDT", "ETH/USDT"): cid = await _make_promoted_candidate(owner_account_id=acct) r = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid), "venue": "binance", "symbol": "BTC/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid), "venue": "binance", "symbol": symbol, "timeframe": "1h"}, ) assert r.status_code == 200, r.json() @@ -345,7 +398,7 @@ async def test_per_account_run_cap(client: TestClient, app_with_lifespan: Any) - cid3 = await _make_promoted_candidate(owner_account_id=acct) r = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid3), "venue": "binance", "symbol": "BTC/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid3), "venue": "binance", "symbol": "SOL/USDT", "timeframe": "1h"}, ) assert r.status_code == 429 assert r.json()["code"] == "TOO_MANY_RUNNING_RUNS" diff --git a/services/paper/tests/test_live_runner.py b/services/paper/tests/test_live_runner.py index f81ee80e..5712b164 100644 --- a/services/paper/tests/test_live_runner.py +++ b/services/paper/tests/test_live_runner.py @@ -45,20 +45,32 @@ pytestmark = pytest.mark.integration +# 测试策略买 1 BTC @ 50000 ≈ 50050(含 fee):session 虚拟钱包与账户都要盖得住, +# 否则被 spot BUY 购买力守门拒掉(守门前这些买单是"碰巧"能成交的透支单)。 +_TEST_CASH = 100_000.0 + + def _make_session() -> LiveEngineSession: return LiveEngineSession( strategy_cls=_BuyOnceStrategy, instrument_id=_INSTRUMENT, timeframe="1h", params={}, - initial_cash=10_000.0, + initial_cash=_TEST_CASH, fee_rate=0.001, ) async def _insert_run(account_id, candidate_id=None): # type: ignore[no-untyped-def] - """插一行 run;candidate_id=None 时先建一个真候选(strategy_runs.candidate_id 有 FK)。""" + """插一行 run;candidate_id=None 时先建一个真候选(strategy_runs.candidate_id 有 FK)。 + + 顺带按 ``_TEST_CASH`` 预建账户:spot BUY 购买力守门按账户折算现金放行,默认 1 万 + 盖不住测试策略的 1 BTC@50000 买单。 + """ async with get_conn() as conn: + await accounts_store.get_or_create( + conn, account_id, initial_cash=Decimal(str(_TEST_CASH)) + ) if candidate_id is None: # 结构可区分 salt 作 STRING 字面量(非注释):结构指纹去重剥注释后会让 # 注释-only / 注释-salt 候选全撞成同一个 → 同 candidate 第二次起跑 409。 diff --git a/services/paper/tests/test_spot_guard.py b/services/paper/tests/test_spot_guard.py index 609d5d05..9c419921 100644 --- a/services/paper/tests/test_spot_guard.py +++ b/services/paper/tests/test_spot_guard.py @@ -1,12 +1,16 @@ -"""``spot_guard.violates_spot_long_only`` 纯函数边界单测(禁裸空 / 禁超卖翻空)。 +"""``spot_guard`` 纯函数边界单测。 -与回测 ``Portfolio.can_afford_sell`` 同口径:SELL 量严格大于当前 LONG 持仓即违规。 +- ``violates_spot_long_only``:禁裸空 / 禁超卖翻空(与回测 ``can_afford_sell`` 同口径) +- ``violates_spot_buying_power``:禁透支买穿现金池(与回测 ``can_afford_buy`` 同口径) """ from __future__ import annotations from decimal import Decimal -from inalpha_paper.execution.spot_guard import violates_spot_long_only +from inalpha_paper.execution.spot_guard import ( + violates_spot_buying_power, + violates_spot_long_only, +) def test_buy_never_violates() -> None: @@ -40,3 +44,62 @@ def test_sell_against_existing_short_violates() -> None: violates_spot_long_only(side="SELL", quantity=1.0, current_qty=Decimal("-2.0")) is True ) + + +# ─── violates_spot_buying_power ─── + + +def _bp(**overrides: object) -> bool: + kwargs: dict = dict( + side="BUY", + quantity=1.0, + ref_price=100.0, + fee_rate=0.001, + order_ccy_rate=Decimal(1), + available_cash_base=Decimal(10_000), + trading_mode="spot", + ) + kwargs.update(overrides) + return violates_spot_buying_power(**kwargs) + + +def test_bp_sell_never_violates() -> None: + # SELL 不归本守门管(裸空由 long-only 守门管) + assert _bp(side="SELL", quantity=999_999) is False + + +def test_bp_perp_never_violates() -> None: + # perp 走保证金购买力校验,本守门短路 + assert _bp(trading_mode="perp", quantity=999_999) is False + + +def test_bp_within_available_ok() -> None: + # 100.1(含 fee)≤ 10000×0.99 → 放行 + assert _bp() is False + + +def test_bp_exceeds_available_violates() -> None: + # 200×100×1.001 = 20020 > 9900 → 拒 + assert _bp(quantity=200.0) is True + + +def test_bp_safety_factor_boundary() -> None: + # 恰好压线:可用 10000,notional+fee 落在 (9900, 10000] 区间 → 仍拒(留 1% buffer) + assert _bp(quantity=99.5) is True # 99.5×100×1.001 = 9959.95 > 9900 + + +def test_bp_negative_available_rejects_any_buy() -> None: + # 折算可用现金已为负(已透支)→ 任何 BUY 必拒 + assert _bp(quantity=0.0001, available_cash_base=Decimal(-1)) is True + + +def test_bp_missing_rate_fail_closed() -> None: + # 订单计价货币汇率拿不到 → fail-closed 拒单(算不出值多少 base,宁拒不猜) + assert _bp(order_ccy_rate=None, quantity=0.0001) is True + + +def test_bp_cross_currency_rate_applied() -> None: + # 汇率参与折算:notional 700(CNY)×rate 0.14 = 98(USD)+fee ≤ 9900 → 放行 + assert ( + _bp(quantity=7.0, ref_price=100.0, order_ccy_rate=Decimal("0.14")) is False + ) From 87d44d695338038127bd2e1f1d50470cf66815ae Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 12:15:45 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(orchestration):=20paper.start=5Fstra?= =?UTF-8?q?tegy=20=E9=80=8F=E4=BC=A0=20allocation=20+=20StrategyRunRecord?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start_strategy tool/client 加可选 allocation(run 资金额度,省略走服务端默认), tool 描述补同标的 409 与额度不足 422 两个坑 - TS StrategyRunRecord 镜像补 trading_mode / leverage / allocation; AccountSnapshot 注释对齐 mark-to-market 新口径 Co-Authored-By: Claude Fable 5 --- packages/orchestration/src/clients/paper.ts | 16 +++++++++++++++- packages/orchestration/src/tools/paper.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/orchestration/src/clients/paper.ts b/packages/orchestration/src/clients/paper.ts index 912ff0b1..d9110686 100644 --- a/packages/orchestration/src/clients/paper.ts +++ b/packages/orchestration/src/clients/paper.ts @@ -482,10 +482,12 @@ export type AccountSnapshot = { cash: number; /** D-11:折算前的按币种现金桶(如 {"USD": 5000, "USDT": -1000})。 */ cash_balances: Record; + /** mark-to-market:spot 仓 qty×最新价(含浮盈);perp 仓贡献未实现盈亏。 */ positions_value: number; + /** cash + positions_value(含未实现盈亏)。 */ total_equity: number; realized_pnl: number; - /** D-11:折算时 FX 不可用 / 偏旧的币种告警;非空时须原样转告用户。 */ + /** D-11:估值告警(FX 或最新价不可用 / 偏旧);非空时须原样转告用户。 */ fx_warnings: string[]; created_at: string; updated_at: string; @@ -501,6 +503,12 @@ export type StrategyRunRecord = { symbol: string; timeframe: string; params: Record; + /** spot(现货 long-only)或 perp(USDT-M 永续,做空/杠杆)。 */ + trading_mode: "spot" | "perp"; + /** 杠杆倍数;spot 恒 1。 */ + leverage: number; + /** 本 run 的资金额度(账户 base_currency 计);老数据为 null(旧语义固定 1 万)。 */ + allocation: number | null; last_bar_ts: string | null; cumulative_pnl: number; error_log: Array>; @@ -519,6 +527,11 @@ export type StartStrategyParams = { tradingMode?: "spot" | "perp"; /** 杠杆倍数(perp 用,1..20);spot 恒 1。 */ leverage?: number; + /** + * 本 run 的资金额度(账户 base_currency 计):sizing 与 run 级购买力以它为上限。 + * 省略时服务端取 min(10000, 账户折算可用现金);账户可用 ≤0 时 422 拒绝 start。 + */ + allocation?: number; }; /** D-11 · live runner 决策复盘日志一行。 */ @@ -834,6 +847,7 @@ export class PaperClient { params: params.params ?? {}, trading_mode: params.tradingMode ?? "spot", leverage: params.leverage ?? 1, + allocation: params.allocation, }); } diff --git a/packages/orchestration/src/tools/paper.ts b/packages/orchestration/src/tools/paper.ts index 1608bac2..654e34b1 100644 --- a/packages/orchestration/src/tools/paper.ts +++ b/packages/orchestration/src/tools/paper.ts @@ -806,7 +806,11 @@ export const paperStartStrategyTool = createTool({ 坑: - **promote ≠ 自动跑**:promote 只是状态切换,必须再调本工具才真正按行情跑 - 同一个 candidate 同时只能有一个 running(再起会 409);先 stop 再换 symbol + - 同账户同 venue+symbol 同时只能有一个 running(撞会 409 SYMBOL_RUN_CONFLICT, + 两个 run 会共享同一行持仓互相打架);换策略先 stop 旧 run - candidate 表不含 venue/symbol/timeframe,必须在这里指定 + - allocation 是该 run 的资金额度(sizing 上限);省略时服务端取 + min(10000, 账户可用现金),账户可用 ≤0 会 422——先平仓/停 run 释放资金 - 机器自动审批下单(approved_by=system:live_runner),正当性靠"人先 promote + 人显式 start" `.trim(), inputSchema: z.object({ @@ -836,6 +840,15 @@ export const paperStartStrategyTool = createTool({ .max(20) .default(1) .describe("杠杆倍数(perp 用,1..20);spot 恒 1"), + allocation: z + .number() + .positive() + .optional() + .describe( + "本 run 的资金额度(账户 base_currency 计):sizing 与 run 级购买力以它为上限," + + "多 run 共享账户时各自的资金边界。省略 → 服务端取 min(10000, 账户折算可用现金)。" + + "用户没明确给金额就不要传。", + ), }), execute: async (inputData, ctx) => { const tc = ctx?.requestContext as ToolRequestContext | undefined; @@ -848,6 +861,7 @@ export const paperStartStrategyTool = createTool({ params: inputData.params, tradingMode: inputData.tradingMode, leverage: inputData.leverage, + allocation: inputData.allocation, }); }, }); From 3af212dd77e04a9cabc79d8633f3251fae198b45 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 12:15:45 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(dashboard):=20=E6=8C=81=E4=BB=93/run?= =?UTF-8?q?ner=20=E7=8E=B0=E8=B4=A7=E5=90=88=E7=BA=A6=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=20+=20=E6=9D=A0=E6=9D=86=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=E9=87=91=E5=88=97=20+=20run=20=E8=B5=84=E9=87=91=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前持仓表靠强平价非空隐式判 perp、杠杆徽标仅 >1× 显示、保证金藏 tooltip, runner 列表完全不显示交易模式——用户分不清跑的是现货还是合约: - 持仓表:每行「现货/合约」徽标(用后端派生 trading_mode,不再隐式推断), perp 杠杆徽标恒显示(含 1×),保证金提为可见列 - 总览 runner 面板 / runner 卡片 / run 详情页:标的旁「合约 N×」/「现货」徽标, 详情与卡片展示 allocation(资金额度) - zh/en 文案同步 Co-Authored-By: Claude Fable 5 --- apps/dashboard/messages/en.json | 22 ++++++++++++---- apps/dashboard/messages/zh.json | 22 ++++++++++++---- .../components/overview/PositionsTable.tsx | 26 ++++++++++++++++--- .../src/components/overview/RunnersPanel.tsx | 13 ++++++++++ .../src/components/runners/RunnerCard.tsx | 23 +++++++++++++++- .../components/runners/RunnerDetailClient.tsx | 22 +++++++++++++++- apps/dashboard/src/lib/types.ts | 10 ++++++- 7 files changed, 121 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index af2b9c0c..23556c90 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -65,10 +65,14 @@ "mark": "Mark", "liqPrice": "Liq.", "unrealized": "Unrealized", - "realized": "Realized PnL" + "realized": "Realized PnL", + "margin": "Margin" }, "leverageTitle": "{leverage}× leverage · margin {margin}", - "liqStale": "Liquidation price (mark crossing it triggers liquidation)" + "liqStale": "Liquidation price (mark crossing it triggers liquidation)", + "marginTitle": "Initial margin used by this position", + "modeSpot": "SPOT", + "modePerp": "PERP" }, "orders": { "title": "Recent Orders", @@ -100,7 +104,9 @@ "running": "Running", "stopped": "Stopped", "errored": "Errored" - } + }, + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×" }, "strategyPanel": { "title": "Strategy Pool", @@ -149,7 +155,10 @@ "errors": "{count} error(s)", "viewDecisions": "View decisions", "neverRan": "no bars processed yet", - "history": "Past runs ({count})" + "history": "Past runs ({count})", + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×", + "allocation": "Allocation" }, "detail": { "back": "Live Runners", @@ -178,7 +187,10 @@ "fee": "Fee", "outcome": "Outcome", "reason": "Reason" - } + }, + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×", + "allocation": "allocation {amount}" }, "factors": { "title": "Effective Factors (Instrument)", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 81c2db03..85d6d2f8 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -65,10 +65,14 @@ "mark": "最新价", "liqPrice": "强平价", "unrealized": "浮动盈亏", - "realized": "已实现盈亏" + "realized": "已实现盈亏", + "margin": "保证金" }, "leverageTitle": "{leverage}× 杠杆 · 占用保证金 {margin}", - "liqStale": "强平价(mark 穿越即强平)" + "liqStale": "强平价(mark 穿越即强平)", + "marginTitle": "该仓占用的初始保证金", + "modeSpot": "现货", + "modePerp": "合约" }, "orders": { "title": "最近订单", @@ -100,7 +104,9 @@ "running": "运行中", "stopped": "已停止", "errored": "错误" - } + }, + "modeSpot": "现货", + "modePerp": "合约 {leverage}×" }, "strategyPanel": { "title": "策略池", @@ -149,7 +155,10 @@ "errors": "{count} 条错误", "viewDecisions": "查看决策", "neverRan": "尚未处理任何 bar", - "history": "历史运行 {count} 次" + "history": "历史运行 {count} 次", + "modeSpot": "现货", + "modePerp": "合约 {leverage}×", + "allocation": "资金额度" }, "detail": { "back": "Live Runner", @@ -178,7 +187,10 @@ "fee": "手续费", "outcome": "结果", "reason": "原因" - } + }, + "modeSpot": "现货", + "modePerp": "合约 {leverage}×", + "allocation": "资金额度 {amount}" }, "factors": { "title": "标的有效因子", diff --git a/apps/dashboard/src/components/overview/PositionsTable.tsx b/apps/dashboard/src/components/overview/PositionsTable.tsx index 91782b14..e8245113 100644 --- a/apps/dashboard/src/components/overview/PositionsTable.tsx +++ b/apps/dashboard/src/components/overview/PositionsTable.tsx @@ -50,6 +50,7 @@ export function PositionsTable({ {t("col.qty")} {t("col.avgPrice")} {t("col.mark")} + {t("col.margin")} {t("col.liqPrice")} {t("col.unrealized")} {t("col.realized")} @@ -58,8 +59,8 @@ export function PositionsTable({ {positions.map((p) => { const ccy = p.currency ?? baseCcy; - // perp 仓:后端给非 null 强平价(spot 恒 null);杠杆 >1 才挂徽标。 - const isPerp = p.liquidation_price !== null; + // 后端派生的显式模式;perp 杠杆徽标恒显示(含 1×),现货/合约一眼可辨。 + const isPerp = p.trading_mode === "perp"; return ( {instrumentLabel(p.symbol, p.venue)} - {p.leverage > 1 && ( + + {isPerp ? t("modePerp") : t("modeSpot")} + + {isPerp && ( )} + + {!isPerp ? ( + + ) : ( + + {fmtNum(p.margin_used, locale, 2)} + + )} + {!isPerp || p.liquidation_price === null ? ( diff --git a/apps/dashboard/src/components/overview/RunnersPanel.tsx b/apps/dashboard/src/components/overview/RunnersPanel.tsx index d988c0ca..48741bdc 100644 --- a/apps/dashboard/src/components/overview/RunnersPanel.tsx +++ b/apps/dashboard/src/components/overview/RunnersPanel.tsx @@ -111,6 +111,19 @@ export function RunnersPanel({ runs }: { runs: StrategyRunRecord[] }) { > {instrumentLabel(r.symbol, r.venue)} + {/* 现货/合约一眼可辨:perp 带杠杆倍数,spot 灰字轻量不抢视线 */} + + {r.trading_mode === "perp" + ? t("modePerp", { leverage: r.leverage }) + : t("modeSpot")} + {r.timeframe} diff --git a/apps/dashboard/src/components/runners/RunnerCard.tsx b/apps/dashboard/src/components/runners/RunnerCard.tsx index debb0705..3632ab38 100644 --- a/apps/dashboard/src/components/runners/RunnerCard.tsx +++ b/apps/dashboard/src/components/runners/RunnerCard.tsx @@ -7,7 +7,7 @@ import { ChevronDown, ChevronRight, TriangleAlert } from "lucide-react"; import type { StrategyRunRecord } from "@/lib/types"; import { Link } from "@/i18n/navigation"; import { cn } from "@/lib/cn"; -import { fmtDateTime, fmtRelative, fmtSigned, pnlColor } from "@/lib/format"; +import { fmtDateTime, fmtNum, fmtRelative, fmtSigned, pnlColor } from "@/lib/format"; import { RunStatusBadge } from "@/components/ui/StatusBadge"; /** @@ -55,6 +55,19 @@ export function RunnerCard({ · {run.venue} + {/* 现货/合约显式标注:perp 金色带杠杆,spot 灰色轻量 */} + + {run.trading_mode === "perp" + ? t("modePerp", { leverage: run.leverage }) + : t("modeSpot")} +
{run.timeframe} @@ -92,6 +105,14 @@ export function RunnerCard({ {run.candidate_id.slice(0, 8)} + {/* 资金额度(sizing 上限);老 run 为 null 不显示 */} + {run.allocation !== null && ( + + + {fmtNum(run.allocation, locale, 0)} + + + )} {/* 错误角标 */} diff --git a/apps/dashboard/src/components/runners/RunnerDetailClient.tsx b/apps/dashboard/src/components/runners/RunnerDetailClient.tsx index 3718e9ed..44c803c3 100644 --- a/apps/dashboard/src/components/runners/RunnerDetailClient.tsx +++ b/apps/dashboard/src/components/runners/RunnerDetailClient.tsx @@ -14,7 +14,7 @@ import type { } from "@/lib/types"; import { Link } from "@/i18n/navigation"; import { cn } from "@/lib/cn"; -import { fmtRelative, fmtSigned, pnlColor } from "@/lib/format"; +import { fmtNum, fmtRelative, fmtSigned, pnlColor } from "@/lib/format"; import { jsonFetcher } from "@/lib/fetcher"; import { ErrorState, SkeletonBlock } from "@/components/ui/Feedback"; import { PositionsTable } from "@/components/overview/PositionsTable"; @@ -81,6 +81,26 @@ export function RunnerDetailClient({ runId }: { runId: string }) {
{run.venue} · {run.timeframe} + {/* 现货/合约 + 杠杆 + 资金额度:不点开持仓也知道这是什么模式在跑 */} + + {run.trading_mode === "perp" + ? t("modePerp", { leverage: run.leverage }) + : t("modeSpot")} + + {run.allocation !== null && ( + + {t("allocation", { + amount: fmtNum(run.allocation, locale, 0), + })} + + )}
{/* 当前所跑策略 —— 可点进策略详情(模拟盘 → 策略可追溯)。 */}
diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 5e0b306b..50ae1c4e 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -36,8 +36,10 @@ export interface PositionRecord { leverage: number; /** perp 该仓占用保证金;spot 为 0。 */ margin_used: number; - /** perp 强平价(mark 穿越即强平);spot 为 null。null 也用来判定是否 perp 仓。 */ + /** perp 强平价(mark 穿越即强平);spot 为 null。 */ liquidation_price: number | null; + /** 后端派生:现货 spot / 合约 perp——UI 判定用这个,别再靠 liquidation_price 推断。 */ + trading_mode: "spot" | "perp"; } /** GET /orders 元素。 */ @@ -82,6 +84,12 @@ export interface StrategyRunRecord { symbol: string; timeframe: string; params: Record; + /** 现货 spot / 合约 perp(USDT-M 永续)。 */ + trading_mode: "spot" | "perp"; + /** 杠杆倍数;spot 恒 1。 */ + leverage: number; + /** 本 run 的资金额度(账户 base_currency 计);老数据为 null(旧语义固定 1 万)。 */ + allocation: number | null; last_bar_ts: string | null; cumulative_pnl: number; /** 运行日志(滚动窗口,最近 N 条):起跑 / 出单 / 停止 / 退避 / 错误。 */ From 5981be7ad278144dc99d72f2bbd12236be522b74 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 13:22:05 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(db):=20account=5Fcash=5Fflows=20?= =?UTF-8?q?=E8=A1=A8=20=E2=80=94=E2=80=94=20=E8=B4=A6=E6=88=B7=E5=A4=96?= =?UTF-8?q?=E7=94=9F=E8=B5=84=E9=87=91=E4=BA=8B=E4=BB=B6=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 充值/提取/重置一律先留痕再改余额(同事务):模拟盘改钱=改绩效口径,无流水的 余额变更会让收益率/榜单/审计链失信。成交现金变动仍由 orders/closed_trades 承载,不重复记账。 Co-Authored-By: Claude Fable 5 --- .../versions/0026_account_cash_flows.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 infra/migrations/versions/0026_account_cash_flows.py diff --git a/infra/migrations/versions/0026_account_cash_flows.py b/infra/migrations/versions/0026_account_cash_flows.py new file mode 100644 index 00000000..135d59da --- /dev/null +++ b/infra/migrations/versions/0026_account_cash_flows.py @@ -0,0 +1,48 @@ +"""account_cash_flows —— 账户外生资金事件流水(充值/提取/重置) + +Revision ID: 0026 +Revises: 0025 +Create Date: 2026-07-02 + +背景:账户现金此前只能 lazy create 固定 1 万,无充值/重置入口。模拟盘改钱 = 改绩效 +口径(收益率分母/榜单/审计链),直接 UPDATE 余额会让战绩不可信——资金变更一律走 +流水行 + 同事务更新 ``cash_balances``,``balance_after`` 冗余存变更后桶值供对账。 + +只记**外生**资金事件(deposit/withdraw/reset);成交现金变动由 orders / +closed_trades 承载,不重复记账。 +""" +from __future__ import annotations + +from alembic import op + +revision: str = "0026" +down_revision: str | None = "0025" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS account_cash_flows ( + id BIGSERIAL PRIMARY KEY, + account_id UUID NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('deposit', 'withdraw', 'reset')), + currency TEXT NOT NULL, + amount NUMERIC NOT NULL, + balance_after NUMERIC NOT NULL, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_cash_flows_account_created + ON account_cash_flows (account_id, created_at DESC) + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS account_cash_flows") From 9fd183fc7b23cad9e3ca079f871b20eb74384d92 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 13:22:20 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(paper):=20=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=85=85=E5=80=BC/=E9=87=8D=E7=BD=AE/=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=20+=20perp=20=E8=B7=A8=E4=BB=93=E4=BF=9D?= =?UTF-8?q?=E8=AF=81=E9=87=91=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、用户可改可用资金,全程留痕: - POST /accounts/me/deposit:入指定币种桶(默认 base),流水 kind=deposit; 不改 initial_cash(充值≠赚钱,绩效口径由流水可还原) - POST /accounts/me/reset:删全部持仓行 + 现金回 {base: initial_cash}(可传 新基准);有 running run 时 409(runner 会立刻把仓开回来);orders/ closed_trades/strategy_runs 历史保留(审计不可抹);流水 note 记旧桶明细 - GET /accounts/me/cash_flows:外生资金事件列表 - 事务内账户行 FOR UPDATE,与购买力守门/并发充值串行化 二、perp 保证金守门跨仓聚合(issue #114): - HTTP 与 live 两条写路径从「单笔目标 IM vs 全钱包」改为「其他活跃仓已占 IM (positions.margin_used 权威值,同计价货币) + 本笔目标 IM + fee ≤ 钱包」, 堵住多仓合计超钱包的洞;拒单 details 带 others_im 供排查 - 与回测引擎 Portfolio free_margin 口径对齐 Co-Authored-By: Claude Fable 5 --- .../paper/src/inalpha_paper/api/orders.py | 132 +++++++++++++- .../paper/src/inalpha_paper/live_runner.py | 15 +- services/paper/src/inalpha_paper/schemas.py | 47 +++++ .../src/inalpha_paper/storage/accounts.py | 75 ++++++++ .../src/inalpha_paper/storage/positions.py | 40 +++++ .../tests/test_api_account_cash_flows.py | 166 ++++++++++++++++++ 6 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 services/paper/tests/test_api_account_cash_flows.py diff --git a/services/paper/src/inalpha_paper/api/orders.py b/services/paper/src/inalpha_paper/api/orders.py index 1184856f..8487e634 100644 --- a/services/paper/src/inalpha_paper/api/orders.py +++ b/services/paper/src/inalpha_paper/api/orders.py @@ -40,14 +40,18 @@ from ..fx import needs_network as fx_needs_network from ..schemas import ( AccountSnapshot, + CashFlowRecord, + DepositRequest, OrderRecord, PositionRecord, + ResetAccountRequest, SubmitOrderRequest, SubmitOrderResponse, ) from ..storage import accounts as accounts_store from ..storage import orders as orders_store from ..storage import positions as positions_store +from ..storage import strategy_runs as runs_store router = APIRouter(tags=["orders"]) @@ -57,6 +61,13 @@ class RefPriceUnavailableError(InalphaError): status_code = 400 +class AccountHasRunningRunsError(InalphaError): + """reset 前须先停掉所有 running run(否则 runner 下一根 bar 又把仓开回来)。""" + + code = "ACCOUNT_HAS_RUNNING_RUNS" + status_code = 409 + + @router.post("/orders/submit", response_model=SubmitOrderResponse) async def post_submit_order( req: SubmitOrderRequest, @@ -124,7 +135,9 @@ async def post_submit_order( # 否则同账户同标的并发 BUY 各读旧 wallet/持仓双双过闸 → double-open、累计 IM 超钱包)。 # 与回测 Portfolio.can_afford_buy/sell 同口径:按**成交后目标仓**算 prospective IM = # |cur_qty ± qty| × price / leverage——平 / 减仓 IM 降不误拒 cover;开 / 加 / 反手按目标 - # 仓校验。跨 symbol 聚合仍留 Phase 2(#114)。raise 触发回滚 → 不落单/不落账(409)。 + # 仓校验。**跨仓聚合(#114)**:其他活跃 perp 仓已占 IM(positions.margin_used, + # 每笔 fill 后重算的权威值)一并计入——多仓合计不得超钱包,堵单笔各自比全钱包的洞。 + # raise 触发回滚 → 不落单/不落账(409)。 if req.trading_mode == "perp": currency = resolve_currency(req.venue, req.symbol) wallet = Decimal(str((acct.get("cash_balances") or {}).get(currency, "0"))) @@ -137,11 +150,17 @@ async def post_submit_order( prospective_qty = abs(cur_qty + signed_qty) im = Decimal(str(prospective_qty * ref_price / req.leverage)) fee_amt = Decimal(str(req.quantity * ref_price * req.fee_rate)) - if im + fee_amt > wallet: + others_im = await positions_store.sum_other_margin_used( + db, account_id, currency=currency, + exclude_venue=req.venue, exclude_symbol=req.symbol, + ) + if others_im + im + fee_amt > wallet: raise InalphaError( - f"perp 保证金不足:需 IM {im} + fee {fee_amt} 超钱包 {wallet} {currency}", + f"perp 保证金不足:其他仓已占 IM {others_im} + 本笔目标 IM {im} " + f"+ fee {fee_amt} 超钱包 {wallet} {currency}", code="INSUFFICIENT_MARGIN", status_code=409, - details={"im": str(im), "fee": str(fee_amt), + details={"im": str(im), "others_im": str(others_im), + "fee": str(fee_amt), "wallet": str(wallet), "currency": currency}, ) @@ -442,11 +461,116 @@ async def get_my_account( ) +@router.post("/accounts/me/deposit", response_model=CashFlowRecord) +async def deposit_to_my_account( + req: DepositRequest, + db: DBConn, + user: Annotated[User, Depends(get_current_user)], +) -> CashFlowRecord: + """给当前账户充值(外生资金事件):流水行 + 余额更新同事务,先留痕再改钱。 + + 充值不改 ``initial_cash``(充值 ≠ 赚钱;真实绩效口径由流水可还原)。 + """ + account_id = account_id_from_user(user) + async with db.transaction(): + # 锁账户行:与购买力守门/并发充值串行化(读余额 → 变更 → 记流水) + acct = await accounts_store.get_or_create(db, account_id, for_update=True) + currency = (req.currency or acct["base_currency"]).strip().upper() + amount = Decimal(str(req.amount)) + new_balance = await accounts_store.apply_cash_delta( + db, account_id, amount, currency=currency + ) + flow = await accounts_store.record_cash_flow( + db, account_id, kind="deposit", currency=currency, + amount=amount, balance_after=new_balance, note=req.note, + ) + return _row_to_cash_flow(flow) + + +@router.post("/accounts/me/reset", response_model=CashFlowRecord) +async def reset_my_account( + req: ResetAccountRequest, + db: DBConn, + user: Annotated[User, Depends(get_current_user)], +) -> CashFlowRecord: + """重置当前账户到初始状态:删全部持仓行 + 现金回到 ``{base: initial_cash}``。 + + - **有 running run 时 409**(否则 runner 下一根 bar 又把仓开回来); + - orders / closed_trades / strategy_runs 历史全部保留(审计不可抹),重置后 + 绩效从新基准起算(``initial_cash`` 更新为本轮值); + - 流水 kind=reset:amount = 新初始额 − 旧折算总现金(本地汇率近似),note 记 + 旧桶明细与清仓行数,审计可还原。 + """ + account_id = account_id_from_user(user) + async with db.transaction(): + acct = await accounts_store.get_or_create(db, account_id, for_update=True) + running = await runs_store.count_running_by_account(db, account_id) + if running > 0: + raise AccountHasRunningRunsError( + f"account has {running} running strategy_runs; stop them before reset " + "(a running runner would immediately re-open positions)", + details={"running": running}, + ) + base_ccy = acct["base_currency"] + new_initial = ( + Decimal(str(req.initial_cash)) + if req.initial_cash is not None + else Decimal(str(acct["initial_cash"])) + ) + old_balances = { + cur: Decimal(str(amt)) + for cur, amt in (acct.get("cash_balances") or {}).items() + } + # 旧总额只做流水 amount 的近似口径(本地汇率,拿不到的桶排除);精确旧桶 + # 明细原样进 note,审计不失真。 + converter = BaseCurrencyConverter(base_ccy, None) + old_total = await convert_cash_balances(converter, old_balances) + deleted = await positions_store.delete_by_account(db, account_id) + await accounts_store.reset_cash_balances( + db, account_id, initial_cash=new_initial, base_currency=base_ccy + ) + note_auto = ( + f"reset: 清持仓 {deleted} 行; 旧现金桶 " + + ", ".join(f"{c}={a}" for c, a in sorted(old_balances.items())) + + f"; 新基准 {new_initial} {base_ccy}" + ) + flow = await accounts_store.record_cash_flow( + db, account_id, kind="reset", currency=base_ccy, + amount=new_initial - old_total, balance_after=new_initial, + note=f"{note_auto}; {req.note}" if req.note else note_auto, + ) + return _row_to_cash_flow(flow) + + +@router.get("/accounts/me/cash_flows", response_model=list[CashFlowRecord]) +async def list_my_cash_flows( + db: DBConn, + user: Annotated[User, Depends(get_current_user)], + limit: Annotated[int, Query(ge=1, le=500)] = 100, +) -> list[CashFlowRecord]: + """列当前账户外生资金流水(充值/提取/重置),最近的在前。""" + account_id = account_id_from_user(user) + rows = await accounts_store.list_cash_flows(db, account_id, limit=limit) + return [_row_to_cash_flow(r) for r in rows] + + # ──────────────────────────────────────────────────────────────────── # 内部辅助 # ──────────────────────────────────────────────────────────────────── +def _row_to_cash_flow(row: dict[str, Any]) -> CashFlowRecord: + return CashFlowRecord( + id=row["id"], + kind=row["kind"], + currency=row["currency"], + amount=float(row["amount"]), + balance_after=float(row["balance_after"]), + note=row.get("note"), + created_at=row["created_at"], + ) + + async def _resolve_ref_price( req: SubmitOrderRequest, settings: PaperSettings, diff --git a/services/paper/src/inalpha_paper/live_runner.py b/services/paper/src/inalpha_paper/live_runner.py index 812b32d1..9ab1d92d 100644 --- a/services/paper/src/inalpha_paper/live_runner.py +++ b/services/paper/src/inalpha_paper/live_runner.py @@ -945,7 +945,9 @@ async def _route_through_plan_exec( # 1.6 perp 保证金购买力守门(与回测 Portfolio.can_afford_* + HTTP 同口径:按**成交后 # 目标仓**算 prospective IM = |cur_qty ± qty| × price / leverage——平 / 减仓 IM 降, - # 不误拒策略自发的 cover 单)。保护性出场(强平/止损)已在上方豁免。 + # 不误拒策略自发的 cover 单)。**跨仓聚合(#114)**:其他活跃 perp 仓已占 IM + # (positions.margin_used 权威值)一并计入,多仓合计不得超钱包。 + # 保护性出场(强平/止损)已在上方豁免。 if (run.get("trading_mode") or "spot") == "perp" and not is_protective_exit: leverage = int(run.get("leverage") or 1) close = float(bar.close) @@ -955,14 +957,21 @@ async def _route_through_plan_exec( cur_pos = await positions_store.get( conn, account_id=account_id, venue=venue, symbol=symbol ) + others_im = float( + await positions_store.sum_other_margin_used( + conn, account_id, currency=currency, + exclude_venue=venue, exclude_symbol=symbol, + ) + ) cur_qty = float(cur_pos["quantity"]) if cur_pos else 0.0 signed_qty = order.quantity if side == "BUY" else -order.quantity im = abs(cur_qty + signed_qty) * close / leverage fee_amt = order.quantity * close * _FEE_RATE wallet = float((acct.get("cash_balances") or {}).get(currency, 0) or 0) - if im + fee_amt > wallet: + if others_im + im + fee_amt > wallet: reason = ( - f"INSUFFICIENT_MARGIN: 需 IM {im:.2f} + fee {fee_amt:.4f} " + f"INSUFFICIENT_MARGIN: 其他仓已占 IM {others_im:.2f} + " + f"本笔目标 IM {im:.2f} + fee {fee_amt:.4f} " f"超钱包 {wallet:.2f} {currency}" ) session.reject_order( diff --git a/services/paper/src/inalpha_paper/schemas.py b/services/paper/src/inalpha_paper/schemas.py index b9213ceb..c0d8b769 100644 --- a/services/paper/src/inalpha_paper/schemas.py +++ b/services/paper/src/inalpha_paper/schemas.py @@ -772,6 +772,53 @@ class AccountSnapshot(BaseModel): updated_at: datetime +class DepositRequest(BaseModel): + """``POST /accounts/me/deposit`` 请求体:给账户充值(外生资金事件,写流水)。""" + + amount: float = Field( + ..., gt=0, le=1e9, + description="充值金额(>0);上限 1e9 防超大值", + ) + currency: str | None = Field( + default=None, min_length=1, max_length=16, + description="入账币种桶;省略 = 账户 base_currency", + ) + note: str | None = Field( + default=None, max_length=500, description="备注(留痕,可空)" + ) + + +class ResetAccountRequest(BaseModel): + """``POST /accounts/me/reset`` 请求体:重置账户到初始状态。 + + 删全部持仓行 + 现金重置为 ``{base_currency: initial_cash}``;orders / + closed_trades / strategy_runs 历史保留(审计不可抹)。有 running run 时 409。 + """ + + initial_cash: float | None = Field( + default=None, gt=0, le=1e9, + description="新一轮初始资金(base_currency 计);省略 = 沿用账户当前 initial_cash", + ) + note: str | None = Field( + default=None, max_length=500, description="备注(留痕,可空)" + ) + + +class CashFlowRecord(BaseModel): + """``GET /accounts/me/cash_flows`` 元素:一笔外生资金事件。 + + 只含 deposit / withdraw / reset(成交现金变动在 orders / closed_trades)。 + """ + + id: int + kind: Literal["deposit", "withdraw", "reset"] + currency: str + amount: float = Field(description="带符号变更额(充值为正)") + balance_after: float = Field(description="变更后该币种桶余额(审计对账用)") + note: str | None = None + created_at: datetime + + # ──────────────────────────────────────────────────────────────────── # Plan API schema # ──────────────────────────────────────────────────────────────────── diff --git a/services/paper/src/inalpha_paper/storage/accounts.py b/services/paper/src/inalpha_paper/storage/accounts.py index facc3881..a44dd079 100644 --- a/services/paper/src/inalpha_paper/storage/accounts.py +++ b/services/paper/src/inalpha_paper/storage/accounts.py @@ -77,6 +77,81 @@ async def get(conn: AsyncConnection, account_id: UUID) -> dict[str, Any] | None: return row # type: ignore[return-value] +async def record_cash_flow( + conn: AsyncConnection, + account_id: UUID, + *, + kind: str, + currency: str, + amount: Decimal, + balance_after: Decimal, + note: str | None = None, +) -> dict[str, Any]: + """写一行外生资金事件流水(deposit/withdraw/reset)。 + + 资金变更一律先留痕再改余额(同事务,由调用方保证):模拟盘改钱 = 改绩效口径, + 无流水的余额变更会让收益率/榜单/审计链失信。成交现金变动不走本表(orders / + closed_trades 已是成交审计源,重复记账会制造两套对不上的口径)。 + """ + async with conn.cursor() as cur: + await cur.execute( + """ + INSERT INTO account_cash_flows (account_id, kind, currency, amount, + balance_after, note) + VALUES (%s::uuid, %s, %s, %s::numeric, %s::numeric, %s) + RETURNING id, account_id, kind, currency, amount, balance_after, note, + created_at + """, + (str(account_id), kind, currency, amount, balance_after, note), + ) + row = await cur.fetchone() + if row is None: # 理论不会 + raise RuntimeError("account_cash_flows insert returned no row") + return row # type: ignore[return-value] + + +async def list_cash_flows( + conn: AsyncConnection, account_id: UUID, *, limit: int = 100 +) -> list[dict[str, Any]]: + """列账户资金流水,created_at DESC(最近的在前)。""" + async with conn.cursor() as cur: + await cur.execute( + "SELECT id, account_id, kind, currency, amount, balance_after, note, " + "created_at " + "FROM account_cash_flows WHERE account_id = %s " + "ORDER BY created_at DESC, id DESC LIMIT %s", + (str(account_id), limit), + ) + rows = await cur.fetchall() + return list(rows) # type: ignore[arg-type] + + +async def reset_cash_balances( + conn: AsyncConnection, + account_id: UUID, + *, + initial_cash: Decimal, + base_currency: str, +) -> None: + """把账户现金重置为 ``{base_currency: initial_cash}`` 并同步 ``initial_cash`` 列。 + + 重置语义(reset 端点专用):initial_cash = "当前一轮的基准",总收益率分母随之 + 更新。调用方负责同事务内:先锁行(get_or_create for_update)、清持仓、写 + kind=reset 流水。 + """ + async with conn.cursor() as cur: + await cur.execute( + """ + UPDATE accounts + SET initial_cash = %s::numeric, + cash_balances = jsonb_build_object(%s::text, (%s::numeric)::text), + updated_at = NOW() + WHERE account_id = %s::uuid + """, + (initial_cash, base_currency, initial_cash, str(account_id)), + ) + + async def apply_cash_delta( conn: AsyncConnection, account_id: UUID, diff --git a/services/paper/src/inalpha_paper/storage/positions.py b/services/paper/src/inalpha_paper/storage/positions.py index 0c21b43a..fa41747d 100644 --- a/services/paper/src/inalpha_paper/storage/positions.py +++ b/services/paper/src/inalpha_paper/storage/positions.py @@ -304,3 +304,43 @@ async def list_by_account( await cur.execute(sql, (str(account_id),)) rows = await cur.fetchall() return list(rows) # type: ignore[arg-type] + + +async def sum_other_margin_used( + conn: AsyncConnection, + account_id: UUID, + *, + currency: str, + exclude_venue: str, + exclude_symbol: str, +) -> Decimal: + """同账户同计价货币**其他**活跃仓已占用保证金之和(perp 跨仓聚合守门用)。 + + ``margin_used`` 由每笔 fill 后的保证金重算维护,是现成权威值;排除本 + (venue, symbol)——本仓按"成交后目标仓 IM"在调用方另算,不能重复计。 + 读不加锁,与钱包读同级(模拟盘 TOCTOU 容忍度一致)。 + """ + async with conn.cursor() as cur: + await cur.execute( + "SELECT COALESCE(SUM(margin_used), 0) AS total FROM positions " + "WHERE account_id = %s AND currency = %s AND quantity <> 0 " + "AND NOT (venue = %s AND symbol = %s)", + (str(account_id), currency, exclude_venue, exclude_symbol), + ) + row = await cur.fetchone() + return Decimal(str(row["total"])) if row else Decimal(0) # type: ignore[index] + + +async def delete_by_account(conn: AsyncConnection, account_id: UUID) -> int: + """删除某 account 的**全部**持仓行,返回删除行数。 + + 仅供账户 reset 用(与现金重置、reset 流水同事务):重置 = 回到无仓起点,含 + quantity=0 的历史行一并删(其累计 realized_pnl 属上一轮口径,保留会污染新一轮 + 的账户快照汇总;逐笔历史仍在 orders / closed_trades,审计不受影响)。 + 交易路径禁止调用——正常平仓走 apply_fill 保留行。 + """ + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM positions WHERE account_id = %s", (str(account_id),) + ) + return cur.rowcount or 0 diff --git a/services/paper/tests/test_api_account_cash_flows.py b/services/paper/tests/test_api_account_cash_flows.py new file mode 100644 index 00000000..a813a225 --- /dev/null +++ b/services/paper/tests/test_api_account_cash_flows.py @@ -0,0 +1,166 @@ +"""账户外生资金事件(充值/重置/流水)+ perp 跨仓保证金聚合 集成测试。 + +资金变更一律"流水行 + 余额更新同事务":充值不改 initial_cash,重置删持仓、 +现金回基准、历史订单保留。perp 守门从单笔 IM vs 全钱包升级为跨仓聚合 +(其他仓已占 IM + 本笔目标 IM + fee ≤ 钱包)。 +""" +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from inalpha_shared.db import get_conn + +from inalpha_paper.account_id import account_id_from_sub +from inalpha_paper.storage import strategy_candidates as candidates_store +from inalpha_paper.storage import strategy_runs as runs_store + +from .conftest import fresh_account_token + +pytestmark = pytest.mark.integration + + +def _headers(prefix: str = "cf") -> tuple[str, dict[str, str]]: + sub, token = fresh_account_token(prefix) + return sub, {"Authorization": f"Bearer {token}"} + + +def test_deposit_records_flow_and_updates_balance(client: TestClient) -> None: + """充值:余额更新 + 流水留痕;initial_cash 不变(充值 ≠ 赚钱)。""" + _, headers = _headers() + r = client.post( + "/accounts/me/deposit", headers=headers, json={"amount": 5_000.0} + ) + assert r.status_code == 200, r.json() + flow = r.json() + assert flow["kind"] == "deposit" + assert flow["currency"] == "USD" # 默认 base_currency + assert flow["amount"] == pytest.approx(5_000.0) + assert flow["balance_after"] == pytest.approx(15_000.0) + + acct = client.get("/accounts/me", headers=headers).json() + assert acct["cash_balances"]["USD"] == pytest.approx(15_000.0) + assert acct["initial_cash"] == pytest.approx(10_000.0) # 基准不随充值动 + + flows = client.get("/accounts/me/cash_flows", headers=headers).json() + assert len(flows) == 1 and flows[0]["kind"] == "deposit" + + +def test_deposit_non_base_currency_bucket(client: TestClient) -> None: + """指定币种充值进对应桶(如 USDT),折算总现金随之增加。""" + _, headers = _headers() + r = client.post( + "/accounts/me/deposit", + headers=headers, + json={"amount": 1_000.0, "currency": "USDT"}, + ) + assert r.status_code == 200, r.json() + assert r.json()["currency"] == "USDT" + assert r.json()["balance_after"] == pytest.approx(1_000.0) + + acct = client.get("/accounts/me", headers=headers).json() + assert acct["cash_balances"]["USDT"] == pytest.approx(1_000.0) + assert acct["cash"] == pytest.approx(11_000.0) # USD 10000 + USDT 1000×1.0 + + +def test_deposit_invalid_amount_rejected(client: TestClient) -> None: + _, headers = _headers() + r = client.post("/accounts/me/deposit", headers=headers, json={"amount": -1}) + assert r.status_code == 400 + + +def test_reset_clears_positions_keeps_history(client: TestClient) -> None: + """重置:删持仓 + 现金回基准 + reset 流水;订单历史保留(审计不可抹)。""" + _, headers = _headers() + # 先买出一个持仓(USDT 桶变负) + r = client.post( + "/orders/submit", headers=headers, + json={"symbol": "BTC/USDT", "side": "BUY", "type": "MARKET", + "quantity": 0.1, "ref_price": 50_000.0}, + ) + assert r.status_code == 200, r.json() + assert client.get("/positions", headers=headers).json() != [] + + r = client.post("/accounts/me/reset", headers=headers, json={}) + assert r.status_code == 200, r.json() + flow = r.json() + assert flow["kind"] == "reset" + assert flow["balance_after"] == pytest.approx(10_000.0) + assert "旧现金桶" in (flow["note"] or "") + + acct = client.get("/accounts/me", headers=headers).json() + assert acct["cash_balances"] == {"USD": 10_000.0} # USDT 负桶被清 + assert acct["positions_value"] == pytest.approx(0.0) + assert client.get("/positions", headers=headers).json() == [] + # 历史订单仍在(审计):重置不抹交易流水 + orders = client.get( + "/orders", headers=headers, params={"symbol": "BTC/USDT"} + ).json() + assert len(orders) == 1 + + +async def test_reset_blocked_by_running_run( + client: TestClient, app_with_lifespan: Any +) -> None: + """有 running run 时重置 → 409(runner 下一根 bar 会把仓开回来)。""" + sub, headers = _headers("cfrun") + account_id = account_id_from_sub(sub) + async with get_conn() as conn: + cid, _ = await candidates_store.insert_candidate( + conn, code=f'"cash-flow reset test candidate {uuid4().hex}"\n' + ) + await runs_store.insert( + conn, candidate_id=cid, account_id=account_id, + venue="binance", symbol="BTC/USDT", timeframe="1h", params={}, + ) + r = client.post("/accounts/me/reset", headers=headers, json={}) + assert r.status_code == 409, r.json() + assert r.json()["code"] == "ACCOUNT_HAS_RUNNING_RUNS" + + +def test_perp_cross_position_margin_aggregated(client: TestClient) -> None: + """perp 跨仓保证金聚合:多仓合计 IM 不得超钱包(单笔各自过闸的洞已堵)。 + + 钱包 10000 USDT:仓 A(BTC 0.15@50000, 1×)占 IM 7500;仓 B(ETH 1@3000, 1×) + 单笔 IM 3000 < 钱包,但 7500+3000+fee > 钱包 → 拒;缩到 0.5 → 放行。 + """ + _, headers = _headers("perpagg") + r = client.post( + "/accounts/me/deposit", + headers=headers, + json={"amount": 10_000.0, "currency": "USDT"}, + ) + assert r.status_code == 200, r.json() + + r_a = client.post( + "/orders/submit", headers=headers, + json={"symbol": "BTC/USDT:USDT", "side": "BUY", "type": "MARKET", + "quantity": 0.15, "ref_price": 50_000.0, + "trading_mode": "perp", "leverage": 1}, + ) + assert r_a.status_code == 200, r_a.json() + assert r_a.json()["status"] == "FILLED" + + # 仓 B 单笔 IM(3000)本身 < 钱包,聚合后超 → 必须被拒(聚合前会放行,回归点) + r_b = client.post( + "/orders/submit", headers=headers, + json={"symbol": "ETH/USDT:USDT", "side": "BUY", "type": "MARKET", + "quantity": 1.0, "ref_price": 3_000.0, + "trading_mode": "perp", "leverage": 1}, + ) + assert r_b.status_code == 409, r_b.json() + body = r_b.json() + assert body["code"] == "INSUFFICIENT_MARGIN" + assert float(body["details"]["others_im"]) == pytest.approx(7_500.0) + + # 合计仍在钱包内的小仓 → 放行 + r_c = client.post( + "/orders/submit", headers=headers, + json={"symbol": "ETH/USDT:USDT", "side": "BUY", "type": "MARKET", + "quantity": 0.5, "ref_price": 3_000.0, + "trading_mode": "perp", "leverage": 1}, + ) + assert r_c.status_code == 200, r_c.json() + assert r_c.json()["status"] == "FILLED" From 3c95de4bd428d112ae1536c15b86154e31193995 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 15:12:01 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(paper):=20=E5=A4=8D=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94perp=20=E8=81=9A=E5=90=88=20TOCTOU/?= =?UTF-8?q?=E9=94=81=E5=BA=8F=E7=BB=9F=E4=B8=80/=E5=87=80=E5=85=A5?= =?UTF-8?q?=E9=87=91=E5=8F=A3=E5=BE=84/=E9=A2=9D=E5=BA=A6=E6=89=A3?= =?UTF-8?q?=E5=87=8F/=E5=B8=81=E7=A7=8D=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对账后复查发现的 5 个问题一并修(前两个是上一批引入的锁粒度不对称): 1. perp 跨仓聚合 TOCTOU(高):守门只锁 per-symbol 持仓行,两笔并发开仓在 **不同 symbol** 同一钱包时互不阻塞,各读旧 others_im/钱包双双过闸 → 合计 IM 超钱包——恰是聚合要堵的洞。改为下单事务恒锁账户行,live 路径补事务内 保证金权威复检(新 InsufficientMarginError,竞态拒单不杀 run)。 2. 锁序统一 accounts → positions(中):此前 spot SELL/perp 先锁持仓行再扣 cash,与 deposit/reset(先锁账户)反序,并发有死锁窗口;恒锁账户行后消除。 3. 账户快照加 net_external_flows(中):自最近一次 reset 以来的净充值(折 base)。真实收益 = equity − initial_cash − net_external_flows——此前消费者 按 initial_cash 算收益率会把充值当成盈利(1 万户充 1 万显示 +100%)。 4. 自动 allocation 扣减已分配额度(中):min(10000, 可用 − Σrunning run 额度), 防 N 个 run 集体超额认领资本(∑allocation ≫ 现金,per-run 钱包虚高)。 5. 充值币种白名单(低):KNOWN_CASH_CURRENCIES(各市场本币+USD 稳定币), 任意字符串会建出 FX 永远折算不了的垃圾桶 → 422 UNSUPPORTED_CURRENCY。 Co-Authored-By: Claude Fable 5 --- .../paper/src/inalpha_paper/api/orders.py | 40 +++++++++--- .../src/inalpha_paper/api/strategy_runs.py | 17 +++-- .../execution/currency_resolver.py | 10 ++- .../inalpha_paper/execution/perp_margin.py | 7 +++ .../paper/src/inalpha_paper/live_runner.py | 63 +++++++++++++++++-- services/paper/src/inalpha_paper/schemas.py | 6 ++ .../src/inalpha_paper/storage/accounts.py | 28 +++++++++ .../inalpha_paper/storage/strategy_runs.py | 18 ++++++ .../tests/test_api_account_cash_flows.py | 16 +++++ .../paper/tests/test_api_strategy_runs.py | 57 ++++++++++++++--- 10 files changed, 234 insertions(+), 28 deletions(-) diff --git a/services/paper/src/inalpha_paper/api/orders.py b/services/paper/src/inalpha_paper/api/orders.py index 8487e634..d4ede7e7 100644 --- a/services/paper/src/inalpha_paper/api/orders.py +++ b/services/paper/src/inalpha_paper/api/orders.py @@ -27,7 +27,7 @@ from ..data_client import DataClient from ..execution import perp_margin from ..execution import risk_guard as risk_guard_mod -from ..execution.currency_resolver import resolve_currency +from ..execution.currency_resolver import KNOWN_CASH_CURRENCIES, resolve_currency from ..execution.order_executor import OrderExecutor from ..execution.spot_guard import ( InsufficientCashError, @@ -124,12 +124,15 @@ async def post_submit_order( # 落盘 + 持仓 + 现金(事务) async with db.transaction(): - # spot BUY 时锁账户行:购买力守门"读余额 → 校验 → 扣款"须与并发 BUY 串行化 - # (TOCTOU,与下方 SELL 守门锁 positions 行同构)。其余路径不锁,避免无谓串行。 - acct = await accounts_store.get_or_create( - db, account_id, - for_update=(req.trading_mode != "perp" and req.side == "BUY"), - ) + # **恒锁账户行,统一全局锁序 accounts → positions**: + # - spot BUY 购买力守门 / perp 跨仓保证金聚合都要"读钱包+其他仓 IM → 校验 → + # 落账"原子——perp 若不锁账户行,两笔并发开仓在**不同 symbol**(锁不同持仓 + # 行,互不阻塞)会各读旧 others_im/钱包双双过闸,合计 IM 超钱包(恰是跨仓 + # 聚合要堵的洞); + # - 与 deposit/reset(先锁 accounts 再动 positions)同序,消除 spot SELL 先锁 + # positions 再扣 cash 的反序死锁窗口。 + # 代价:同账户下单串行化——模拟盘量级可接受。 + acct = await accounts_store.get_or_create(db, account_id, for_update=True) # perp 保证金购买力守门(**事务内 FOR UPDATE**,与下方 spot SELL 守门同口径防 TOCTOU: # 否则同账户同标的并发 BUY 各读旧 wallet/持仓双双过闸 → double-open、累计 IM 超钱包)。 @@ -155,10 +158,9 @@ async def post_submit_order( exclude_venue=req.venue, exclude_symbol=req.symbol, ) if others_im + im + fee_amt > wallet: - raise InalphaError( + raise perp_margin.InsufficientMarginError( f"perp 保证金不足:其他仓已占 IM {others_im} + 本笔目标 IM {im} " f"+ fee {fee_amt} 超钱包 {wallet} {currency}", - code="INSUFFICIENT_MARGIN", status_code=409, details={"im": str(im), "others_im": str(others_im), "fee": str(fee_amt), "wallet": str(wallet), "currency": currency}, @@ -366,9 +368,12 @@ async def get_my_account( realized_pnl_by_ccy.get(ccy, Decimal(0)) + Decimal(p["realized_pnl"]) ) + # 净外生入金(自最近一次 reset,按币种)——真实收益口径的第三项,防充值算成盈利 + flows_by_ccy = await accounts_store.sum_external_flows_since_reset(db, account_id) + # DataClient 在两种情况下才开:FX 有非本地可解析币种,或有持仓要取 mark # (无持仓的单币种 / crypto-USD 账户保持零网络)。 - all_ccys = set(cash_balances) | pos_ccys + all_ccys = set(cash_balances) | pos_ccys | set(flows_by_ccy) # token 实际上必非空:get_current_user 依赖已保证 Bearer header 合法,否则先行 401; # 这里 token=None 分支是防御性的(理论不可达),保留以防未来调用方绕过 auth。 token = ( @@ -441,6 +446,12 @@ async def get_my_account( converted = await converter.convert(amt, cur) if converted is not None: realized_pnl_base += converted + # 净外生入金折算(同一 converter,汇率已缓存) + net_flows_base = Decimal(0) + for cur, amt in flows_by_ccy.items(): + converted = await converter.convert(amt, cur) + if converted is not None: + net_flows_base += converted fx_warnings = converter.warnings + valuation_warnings finally: if data_client is not None: @@ -455,6 +466,7 @@ async def get_my_account( positions_value=float(positions_base), total_equity=float(cash_base + positions_base), realized_pnl=float(realized_pnl_base), + net_external_flows=float(net_flows_base), fx_warnings=fx_warnings, created_at=acct["created_at"], updated_at=acct["updated_at"], @@ -476,6 +488,14 @@ async def deposit_to_my_account( # 锁账户行:与购买力守门/并发充值串行化(读余额 → 变更 → 记流水) acct = await accounts_store.get_or_create(db, account_id, for_update=True) currency = (req.currency or acct["base_currency"]).strip().upper() + # 白名单:任意字符串会建出 FX 永远折算不了的垃圾桶(常驻 fx_warnings 且删不掉) + if currency not in KNOWN_CASH_CURRENCIES: + raise InalphaError( + f"unsupported deposit currency {currency!r}; " + f"supported: {', '.join(sorted(KNOWN_CASH_CURRENCIES))}", + code="UNSUPPORTED_CURRENCY", + status_code=422, + ) amount = Decimal(str(req.amount)) new_balance = await accounts_store.apply_cash_delta( db, account_id, amount, currency=currency diff --git a/services/paper/src/inalpha_paper/api/strategy_runs.py b/services/paper/src/inalpha_paper/api/strategy_runs.py index 1c3712d8..a4ee73af 100644 --- a/services/paper/src/inalpha_paper/api/strategy_runs.py +++ b/services/paper/src/inalpha_paper/api/strategy_runs.py @@ -156,9 +156,11 @@ async def start_strategy_run( ) # per-run 资金额度:显式传则原样落库(可大于账户可用——下单时账户级购买力硬底会 - # 显式拒单,run 不死);省略则取 min(10000, 账户折算可用现金),start 时确定并落库, - # 使 sizing 行为可复现、可审计。折算只用本地汇率(USD 稳定币 1:1;拿不到汇率的 - # 币种桶排除,保守),避免 start 路径依赖 data 服务。 + # 显式拒单,run 不死);省略则取 min(10000, 账户折算可用现金 − 其他 running run + # 已分配额度)——不扣减已分配额度会让 N 个 run 集体超额认领资本(∑allocation ≫ + # 现金,per-run 钱包虚高,表现为连环静默拒单)。start 时确定并落库,使 sizing 行为 + # 可复现、可审计。折算只用本地汇率(USD 稳定币 1:1;拿不到汇率的币种桶排除,保守), + # 避免 start 路径依赖 data 服务。 allocation = Decimal(str(req.allocation)) if req.allocation is not None else None if allocation is None: acct = await accounts_store.get_or_create(db, account_id) @@ -170,14 +172,17 @@ async def start_strategy_run( for cur, amt in (acct.get("cash_balances") or {}).items() }, ) - allocation = min(Decimal("10000"), available) + already_allocated = await runs_store.sum_running_allocation(db, account_id) + allocation = min(Decimal("10000"), available - already_allocated) if allocation <= 0: raise InsufficientCashForRunError( - f"account has no available cash for a new run " - f"(converted available {available:.2f} {acct['base_currency']}); " + f"account has no unallocated cash for a new run " + f"(converted available {available:.2f} {acct['base_currency']}, " + f"already allocated to running runs {already_allocated:.2f}); " "stop a run / close positions first, or pass an explicit allocation", details={ "available_cash_base": str(available), + "already_allocated": str(already_allocated), "base_currency": acct["base_currency"], "fx_warnings": converter.warnings, }, diff --git a/services/paper/src/inalpha_paper/execution/currency_resolver.py b/services/paper/src/inalpha_paper/execution/currency_resolver.py index b623953b..5a61d28a 100644 --- a/services/paper/src/inalpha_paper/execution/currency_resolver.py +++ b/services/paper/src/inalpha_paper/execution/currency_resolver.py @@ -39,6 +39,14 @@ # crypto symbol 无 quote 时的默认计价货币 _DEFAULT_CRYPTO_QUOTE = "USDT" +# 账户现金桶允许的币种全集:各市场本币 + USD 稳定币(与 fx._STABLE_USD 一致)。 +# 充值端点用它做白名单——任意字符串建桶会造出 FX 永远折算不了的垃圾桶, +# 常驻 fx_warnings 且无法删除(无 withdraw 端点)。 +KNOWN_CASH_CURRENCIES: frozenset[str] = ( + frozenset(_CALENDAR_CODE_TO_CURRENCY.values()) + | frozenset({"USD", "USDT", "USDC", "BUSD", "DAI"}) +) + def _crypto_quote(symbol: str) -> str: """从 crypto symbol 取 quote 货币:``BTC/USDT`` → ``USDT``;无 ``/`` 兜底 USDT。 @@ -72,4 +80,4 @@ def resolve_currency(venue: str, symbol: str, *, default: str = "USD") -> str: return default # fred / 未识别 venue / 未知标的 → fail-open -__all__ = ["resolve_currency"] +__all__ = ["KNOWN_CASH_CURRENCIES", "resolve_currency"] diff --git a/services/paper/src/inalpha_paper/execution/perp_margin.py b/services/paper/src/inalpha_paper/execution/perp_margin.py index 35a15156..cb4f0289 100644 --- a/services/paper/src/inalpha_paper/execution/perp_margin.py +++ b/services/paper/src/inalpha_paper/execution/perp_margin.py @@ -154,6 +154,13 @@ def is_perp_symbol(symbol: str) -> bool: return ":" in symbol +class InsufficientMarginError(InalphaError): + """perp 开/加仓所需保证金(含其他仓已占 IM,跨仓聚合)超钱包:守门拒单。""" + + code = "INSUFFICIENT_MARGIN" + status_code = 409 + + class PerpNotEligibleError(InalphaError): """开杠杆/做空但标的不符合 perp 资格(非 crypto / 非永续 / 杠杆越界)。""" diff --git a/services/paper/src/inalpha_paper/live_runner.py b/services/paper/src/inalpha_paper/live_runner.py index 9ab1d92d..16f931f2 100644 --- a/services/paper/src/inalpha_paper/live_runner.py +++ b/services/paper/src/inalpha_paper/live_runner.py @@ -1106,7 +1106,12 @@ async def _route_through_plan_exec( ) try: async with get_conn() as conn, conn.transaction(): - await accounts_store.get_or_create(conn, account_id) # 首单 lazy create 账户 + # 首单 lazy create + **恒锁账户行**(统一全局锁序 accounts → positions, + # 与 HTTP /orders/submit、deposit/reset 同序防死锁);spot BUY / perp + # 开仓的权威复检都以本锁为串行化点。 + locked_acct = await accounts_store.get_or_create( + conn, account_id, for_update=True + ) # 现货 long-only 权威守门(事务内 FOR UPDATE):闭合 step 1.5 乐观读与本 apply # 跨事务的 TOCTOU——并发同账户同标的 SELL 各读旧持仓双双过 step 1.5,这里锁行 # 串行化,第二个读到更新后持仓 → raise 回滚(不落 plan/order/fill)转 except 拒单。 @@ -1178,9 +1183,6 @@ async def _route_through_plan_exec( and side == "BUY" and result["status"] == "FILLED" ): - locked_acct = await accounts_store.get_or_create( - conn, account_id, for_update=True - ) locked_base_ccy = locked_acct["base_currency"] offline_fx = ( spot_buy_converter.offline_copy() @@ -1208,6 +1210,42 @@ async def _route_through_plan_exec( f"(含手续费)超过账户折算可用 " f"{locked_available:.2f} {locked_base_ccy}" ) + # perp 开/加仓保证金权威复检(事务内,账户行已锁):闭合 1.6 乐观预检与 + # 落账间的 TOCTOU——两笔并发 perp 开仓在**不同 symbol**(锁不同持仓行, + # 互不阻塞)但同一钱包时,各读旧 others_im/wallet 双双过闸 → 合计 IM 超 + # 钱包。账户行锁把同账户 perp 下单串行化,复检读到的都是已提交终值。 + # 保护性平仓(is_protective_exit)不复检——上方钳量分支已保证 reduce-only。 + elif ( + (run.get("trading_mode") or "spot") == "perp" + and not is_protective_exit + and result["status"] == "FILLED" + ): + _leverage = int(run.get("leverage") or 1) + _close = float(bar.close) + _ccy = resolve_currency(venue, symbol) + _locked_pos = await positions_store.get( + conn, account_id=account_id, venue=venue, symbol=symbol, + for_update=True, + ) + _cur_qty = float(_locked_pos["quantity"]) if _locked_pos else 0.0 + _signed = exec_qty if side == "BUY" else -exec_qty + _im = abs(_cur_qty + _signed) * _close / _leverage + _fee = exec_qty * _close * _FEE_RATE + _others_im = float( + await positions_store.sum_other_margin_used( + conn, account_id, currency=_ccy, + exclude_venue=venue, exclude_symbol=symbol, + ) + ) + _wallet = float( + (locked_acct.get("cash_balances") or {}).get(_ccy, 0) or 0 + ) + if _others_im + _im + _fee > _wallet: + raise perp_margin.InsufficientMarginError( + f"INSUFFICIENT_MARGIN: 其他仓已占 IM {_others_im:.2f} + " + f"本笔目标 IM {_im:.2f} + fee {_fee:.4f} " + f"超钱包 {_wallet:.2f} {_ccy}" + ) plan = await plans_store.create( conn, account_id=account_id, intent=intent, venue=venue, symbol=symbol, order_params=order_params, rationale=rationale, @@ -1305,6 +1343,23 @@ async def _route_through_plan_exec( reason=e.message, ) return "risk_rejected" + except perp_margin.InsufficientMarginError as e: + # perp 保证金权威复检命中并发竞态(另一笔先占走了保证金):事务已回滚, + # 补 session 拒单 + rejected 决策行(与 1.6 乐观预检同 outcome),不杀 run。 + session.reject_order( + order=order, strategy_id=strategy_id, + reason=e.message, ts_event=bar.ts_event, + ) + async with get_conn() as conn: + await runs_store.append_log( + conn, run_id, "warn", + f"order rejected by perp margin (txn race): {e.message}", + ) + await self._record_decision( + conn, run, order, bar, outcome="rejected", intent=intent, + reason=e.message, + ) + return "rejected" # 4. 回灌 session:成交更新 portfolio + 策略持仓视图;未成交清理 ExecutionEngine 状态 # 已知残差(保护性钳量场景):confirm_fill 按钳后量(如 0.5)增量减仓,DB 已被钳到全平 diff --git a/services/paper/src/inalpha_paper/schemas.py b/services/paper/src/inalpha_paper/schemas.py index c0d8b769..6e051987 100644 --- a/services/paper/src/inalpha_paper/schemas.py +++ b/services/paper/src/inalpha_paper/schemas.py @@ -763,6 +763,12 @@ class AccountSnapshot(BaseModel): default=0.0, description="所有持仓累计实现 PnL,按各自计价货币折算到 base_currency 后汇总", ) + net_external_flows: float = Field( + default=0.0, + description="自最近一次重置以来的净外生入金(充值−提取,折 base_currency)。" + "真实收益 = total_equity − initial_cash − net_external_flows——" + "不减它会把充值当成盈利(1 万户充 1 万显示 +100%)", + ) fx_warnings: list[str] = Field( default_factory=list, description="D-11:估值告警——FX 不可用 / 偏旧的币种,或持仓最新价不可用 / 偏旧;" diff --git a/services/paper/src/inalpha_paper/storage/accounts.py b/services/paper/src/inalpha_paper/storage/accounts.py index a44dd079..70391438 100644 --- a/services/paper/src/inalpha_paper/storage/accounts.py +++ b/services/paper/src/inalpha_paper/storage/accounts.py @@ -126,6 +126,34 @@ async def list_cash_flows( return list(rows) # type: ignore[arg-type] +async def sum_external_flows_since_reset( + conn: AsyncConnection, account_id: UUID +) -> dict[str, Decimal]: + """自最近一次 reset 之后的净外生入金(deposit/withdraw 带符号求和),按币种。 + + 账户快照的 ``net_external_flows`` 用:真实收益 = 权益 − 基准 − 净入金,不给 + 消费者把充值当成盈利的机会。reset 会重置基准(initial_cash),故只统计最近 + 一次 reset 之后的流水;从未 reset 则统计全部。 + """ + async with conn.cursor() as cur: + await cur.execute( + """ + SELECT currency, COALESCE(SUM(amount), 0) AS total + FROM account_cash_flows + WHERE account_id = %s + AND kind IN ('deposit', 'withdraw') + AND created_at > COALESCE( + (SELECT MAX(created_at) FROM account_cash_flows + WHERE account_id = %s AND kind = 'reset'), + '-infinity'::timestamptz) + GROUP BY currency + """, + (str(account_id), str(account_id)), + ) + rows = await cur.fetchall() + return {r["currency"]: Decimal(str(r["total"])) for r in rows} # type: ignore[index] + + async def reset_cash_balances( conn: AsyncConnection, account_id: UUID, diff --git a/services/paper/src/inalpha_paper/storage/strategy_runs.py b/services/paper/src/inalpha_paper/storage/strategy_runs.py index 8a37b497..d0329ebd 100644 --- a/services/paper/src/inalpha_paper/storage/strategy_runs.py +++ b/services/paper/src/inalpha_paper/storage/strategy_runs.py @@ -128,6 +128,24 @@ async def count_running_by_account(conn: AsyncConnection, account_id: UUID) -> i return int(row["n"]) if row else 0 +async def sum_running_allocation(conn: AsyncConnection, account_id: UUID) -> Decimal: + """同账户所有 running run 已分配额度之和(自动 allocation 扣减用)。 + + 不扣减会让多个 run 集体超额认领资本:账户 1 万先后自动起 N 个 run,现金还没 + 花出去时每个都拿到近全额 allocation,∑allocation ≫ 账户现金——账户级购买力 + 硬底虽兜得住不买穿,但 per-run 虚拟钱包虚高,表现为连环静默拒单误导用户。 + allocation 为 NULL 的老 run 按旧语义固定 1 万计。 + """ + async with conn.cursor() as cur: + await cur.execute( + "SELECT COALESCE(SUM(COALESCE(allocation, 10000)), 0) AS total " + "FROM strategy_runs WHERE account_id = %s AND status = %s", + (str(account_id), _RUNNING), + ) + row = await cur.fetchone() + return Decimal(str(row["total"])) if row else Decimal(0) # type: ignore[index] + + async def get_running_by_symbol( conn: AsyncConnection, account_id: UUID, *, venue: str, symbol: str ) -> dict[str, Any] | None: diff --git a/services/paper/tests/test_api_account_cash_flows.py b/services/paper/tests/test_api_account_cash_flows.py index a813a225..2649bc3c 100644 --- a/services/paper/tests/test_api_account_cash_flows.py +++ b/services/paper/tests/test_api_account_cash_flows.py @@ -43,6 +43,8 @@ def test_deposit_records_flow_and_updates_balance(client: TestClient) -> None: acct = client.get("/accounts/me", headers=headers).json() assert acct["cash_balances"]["USD"] == pytest.approx(15_000.0) assert acct["initial_cash"] == pytest.approx(10_000.0) # 基准不随充值动 + # 净外生入金:真实收益 = equity − initial_cash − net_external_flows,充值不算盈利 + assert acct["net_external_flows"] == pytest.approx(5_000.0) flows = client.get("/accounts/me/cash_flows", headers=headers).json() assert len(flows) == 1 and flows[0]["kind"] == "deposit" @@ -71,6 +73,18 @@ def test_deposit_invalid_amount_rejected(client: TestClient) -> None: assert r.status_code == 400 +def test_deposit_unknown_currency_rejected(client: TestClient) -> None: + """未知币种 → 422(任意字符串会建出 FX 永远折算不了的垃圾桶)。""" + _, headers = _headers() + r = client.post( + "/accounts/me/deposit", + headers=headers, + json={"amount": 100.0, "currency": "FOO"}, + ) + assert r.status_code == 422, r.json() + assert r.json()["code"] == "UNSUPPORTED_CURRENCY" + + def test_reset_clears_positions_keeps_history(client: TestClient) -> None: """重置:删持仓 + 现金回基准 + reset 流水;订单历史保留(审计不可抹)。""" _, headers = _headers() @@ -93,6 +107,8 @@ def test_reset_clears_positions_keeps_history(client: TestClient) -> None: acct = client.get("/accounts/me", headers=headers).json() assert acct["cash_balances"] == {"USD": 10_000.0} # USDT 负桶被清 assert acct["positions_value"] == pytest.approx(0.0) + # 净外生入金按"最近一次 reset 之后"统计 → 重置即归零(新一轮口径) + assert acct["net_external_flows"] == pytest.approx(0.0) assert client.get("/positions", headers=headers).json() == [] # 历史订单仍在(审计):重置不抹交易流水 orders = client.get( diff --git a/services/paper/tests/test_api_strategy_runs.py b/services/paper/tests/test_api_strategy_runs.py index 22e52141..30b4752b 100644 --- a/services/paper/tests/test_api_strategy_runs.py +++ b/services/paper/tests/test_api_strategy_runs.py @@ -193,11 +193,13 @@ async def test_start_happy_path_and_duplicate( assert body["allocation"] == 10_000.0 assert len(started) == 1 # manager.start 被调用 - # 同 candidate 第二个 running → 409(换 symbol 避开同标的守门,专测 candidate 唯一性) + # 同 candidate 第二个 running → 409(换 symbol 避开同标的守门、显式 allocation + # 避开自动额度 422,专测 candidate 唯一性) r2 = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid), "venue": "binance", "symbol": "ETH/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid), "venue": "binance", "symbol": "ETH/USDT", + "timeframe": "1h", "allocation": 1_000.0}, ) assert r2.status_code == 409 assert r2.json()["code"] == "STRATEGY_RUN_ALREADY_RUNNING" @@ -228,14 +230,52 @@ async def test_same_symbol_second_run_conflict( assert r2.status_code == 409, r2.json() assert r2.json()["code"] == "SYMBOL_RUN_CONFLICT" - # 换 symbol 即可正常 start(守门只按标的,不锁账户) + # 换 symbol 即可正常 start(守门只按标的,不锁账户)。显式 allocation:首个 run + # 已把默认未分配额度(1 万)占满,自动额度会 422(见 allocation 扣减测试)。 r3 = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid2), "venue": "binance", "symbol": "ETH/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid2), "venue": "binance", "symbol": "ETH/USDT", + "timeframe": "1h", "allocation": 2_000.0}, ) assert r3.status_code == 200, r3.json() +async def test_auto_allocation_deducts_running_runs( + client: TestClient, app_with_lifespan: Any +) -> None: + """自动 allocation 扣减其他 running run 已分配额度,防集体超额认领资本。 + + 账户 1 万:首个 run 显式占 4000;第二个自动额度 = min(10000, 10000−4000) = 6000; + 第三个自动额度 = 0 → 422(现金没花出去也不能再虚拟认领)。 + """ + _stub_manager(app_with_lifespan) + headers = _headers(client) + cid1 = await _make_promoted_candidate() + r1 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid1), "venue": "binance", "symbol": "BTC/USDT", + "timeframe": "1h", "allocation": 4_000.0}, + ) + assert r1.status_code == 200, r1.json() + + cid2 = await _make_promoted_candidate() + r2 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid2), "venue": "binance", "symbol": "ETH/USDT", "timeframe": "1h"}, + ) + assert r2.status_code == 200, r2.json() + assert r2.json()["allocation"] == pytest.approx(6_000.0) + + cid3 = await _make_promoted_candidate() + r3 = client.post( + "/strategy_runs", headers=headers, + json={"candidate_id": str(cid3), "venue": "binance", "symbol": "SOL/USDT", "timeframe": "1h"}, + ) + assert r3.status_code == 422, r3.json() + assert r3.json()["code"] == "INSUFFICIENT_CASH_FOR_RUN" + assert float(r3.json()["details"]["already_allocated"]) == pytest.approx(10_000.0) + + async def test_explicit_allocation_recorded( client: TestClient, app_with_lifespan: Any ) -> None: @@ -385,12 +425,14 @@ async def test_per_account_run_cap(client: TestClient, app_with_lifespan: Any) - ) app_with_lifespan.dependency_overrides[get_paper_settings] = lambda: small - # 起满 2 个(不同 candidate + 不同 symbol,避开同标的守门,各归本账户) + # 起满 2 个(不同 candidate + 不同 symbol 避开同标的守门;显式 allocation 避开 + # 自动额度扣减——本测试只测数量上限) for symbol in ("BTC/USDT", "ETH/USDT"): cid = await _make_promoted_candidate(owner_account_id=acct) r = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid), "venue": "binance", "symbol": symbol, "timeframe": "1h"}, + json={"candidate_id": str(cid), "venue": "binance", "symbol": symbol, + "timeframe": "1h", "allocation": 3_000.0}, ) assert r.status_code == 200, r.json() @@ -398,7 +440,8 @@ async def test_per_account_run_cap(client: TestClient, app_with_lifespan: Any) - cid3 = await _make_promoted_candidate(owner_account_id=acct) r = client.post( "/strategy_runs", headers=headers, - json={"candidate_id": str(cid3), "venue": "binance", "symbol": "SOL/USDT", "timeframe": "1h"}, + json={"candidate_id": str(cid3), "venue": "binance", "symbol": "SOL/USDT", + "timeframe": "1h", "allocation": 3_000.0}, ) assert r.status_code == 429 assert r.json()["code"] == "TOO_MANY_RUNNING_RUNS" From 9287ddc5c82c94fdc59faeea858d2cfa51879da1 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 15:13:37 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(orchestration):=20=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E8=B5=84=E9=87=91=20agent=20=E5=B7=A5=E5=85=B7=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=85=85=E5=80=BC/=E9=87=8D=E7=BD=AE=E8=B5=B0?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=97=A8,=E6=B5=81=E6=B0=B4=E5=8F=AF?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户改资金走 agent 对话而非前端页面: - paper.deposit_cash / paper.reset_account:permission ask(前端气泡确认,与 promote_candidate 同门)——改钱=改绩效口径必须人点头,流水留痕是第二道防线; tool 描述三段式,写明 reset 前须停 running run、调前先报告将被清的持仓 - paper.list_cash_flows:命中 paper.list_* allow,解释收益率口径用 - paper.get_account 描述更新:mark-to-market 新口径 + net_external_flows (报告收益率必须减净充值);start_strategy 描述补自动额度扣减语义 - AccountSnapshot/StartStrategyParams 类型对齐;permissions YAML 与 defaults.ts 同步 Co-Authored-By: Claude Fable 5 --- .../config/permissions.default.yaml | 5 + packages/orchestration/src/clients/paper.ts | 52 +++++++ .../orchestration/src/permissions/defaults.ts | 5 + packages/orchestration/src/tools/index.ts | 10 ++ packages/orchestration/src/tools/paper.ts | 127 +++++++++++++++++- 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/packages/orchestration/config/permissions.default.yaml b/packages/orchestration/config/permissions.default.yaml index 5c32b1b3..7dfb0211 100644 --- a/packages/orchestration/config/permissions.default.yaml +++ b/packages/orchestration/config/permissions.default.yaml @@ -80,6 +80,11 @@ ask: # 后端硬校验仍在(fitness IS NOT NULL + status='candidate')作为第二道防线 - "paper.promote_candidate" + # 账户外生资金事件:改钱=改绩效口径,必须人点头(流水留痕是第二道防线)。 + # reset 是破坏性操作(删全部持仓行),后端另有 running-run 409 硬守门。 + - "paper.deposit_cash" + - "paper.reset_account" + deny: # 旧的"直接下单"路径全部禁——强制走 ADR-0012 plan/exec - "paper.submit_order" diff --git a/packages/orchestration/src/clients/paper.ts b/packages/orchestration/src/clients/paper.ts index d9110686..965ea9ca 100644 --- a/packages/orchestration/src/clients/paper.ts +++ b/packages/orchestration/src/clients/paper.ts @@ -487,12 +487,30 @@ export type AccountSnapshot = { /** cash + positions_value(含未实现盈亏)。 */ total_equity: number; realized_pnl: number; + /** + * 自最近一次重置以来的净外生入金(充值−提取,折 base)。 + * 真实收益 = total_equity − initial_cash − net_external_flows——不减它会把充值当成盈利。 + */ + net_external_flows: number; /** D-11:估值告警(FX 或最新价不可用 / 偏旧);非空时须原样转告用户。 */ fx_warnings: string[]; created_at: string; updated_at: string; }; +/** 外生资金事件流水一行(充值/提取/重置留痕;成交现金变动在 orders/closed_trades)。 */ +export type CashFlowRecord = { + id: number; + kind: "deposit" | "withdraw" | "reset"; + currency: string; + /** 带符号变更额(充值为正)。 */ + amount: number; + /** 变更后该币种桶余额(审计对账用)。 */ + balance_after: number; + note: string | null; + created_at: string; +}; + /** D-11 · live runner(issue #1)。 */ export type StrategyRunRecord = { id: string; @@ -834,6 +852,40 @@ export class PaperClient { return await this.http.get("/accounts/me"); } + /** 充值(外生资金事件,后端写流水留痕;不改 initial_cash)。 */ + async depositCash(params: { + amount: number; + currency?: string; + note?: string; + }): Promise { + return await this.http.post("/accounts/me/deposit", { + amount: params.amount, + currency: params.currency ?? null, + note: params.note ?? null, + }); + } + + /** + * 重置账户:删全部持仓 + 现金回 {base: initial_cash}。 + * 有 running run 时后端 409 ACCOUNT_HAS_RUNNING_RUNS;历史订单/复盘保留。 + */ + async resetAccount(params: { + initialCash?: number; + note?: string; + }): Promise { + return await this.http.post("/accounts/me/reset", { + initial_cash: params.initialCash ?? null, + note: params.note ?? null, + }); + } + + /** 外生资金流水(充值/提取/重置留痕),最近的在前。 */ + async listCashFlows(limit = 50): Promise { + return await this.http.get("/accounts/me/cash_flows", { + limit, + }); + } + // ──────────────────────────────────────────────────────────────────── // D-11 · live runner(issue #1) // ──────────────────────────────────────────────────────────────────── diff --git a/packages/orchestration/src/permissions/defaults.ts b/packages/orchestration/src/permissions/defaults.ts index 71e06cda..26a523cd 100644 --- a/packages/orchestration/src/permissions/defaults.ts +++ b/packages/orchestration/src/permissions/defaults.ts @@ -83,6 +83,11 @@ export const DEFAULT_PERMISSIONS: PermissionConfig = { // D-9 · 候选 → 正式策略(ADR-0018 / D-9.1b:askUserChoice 接通后改回 ask) // 后端硬校验仍在(fitness IS NOT NULL + status='candidate')作为第二道防线 "paper.promote_candidate", + + // 账户外生资金事件:改钱=改绩效口径,必须人点头(流水留痕是第二道防线)。 + // reset 是破坏性操作(删全部持仓行),后端另有 running-run 409 硬守门。 + "paper.deposit_cash", + "paper.reset_account", ], deny: [ diff --git a/packages/orchestration/src/tools/index.ts b/packages/orchestration/src/tools/index.ts index 5a96fd2d..a4f18236 100644 --- a/packages/orchestration/src/tools/index.ts +++ b/packages/orchestration/src/tools/index.ts @@ -20,16 +20,19 @@ import { paperCheckSensitivityTool, paperComposeStrategyTool, paperCvBacktestTool, + paperDepositCashTool, paperGetAccountTool, paperHealthTool, paperListArchetypesTool, paperListBacktestRunsTool, paperListBacktestTradesTool, + paperListCashFlowsTool, paperListOrdersTool, paperListPositionsTool, paperListStrategiesTool, paperListStrategyRunDecisionsTool, paperListStrategyRunsTool, + paperResetAccountTool, paperRunBacktestTool, paperStartStrategyTool, paperStopStrategyTool, @@ -122,6 +125,7 @@ export { paperCheckSensitivityTool, paperComposeStrategyTool, paperCvBacktestTool, + paperDepositCashTool, paperGetAccountTool, paperGetCandidateTool, paperHealthTool, @@ -129,12 +133,14 @@ export { paperListBacktestRunsTool, paperListBacktestTradesTool, paperListCandidatesTool, + paperListCashFlowsTool, paperListOrdersTool, paperListPositionsTool, paperListStrategiesTool, paperListStrategyRunDecisionsTool, paperListStrategyRunsTool, paperPromoteCandidateTool, + paperResetAccountTool, paperRunBacktestTool, paperStartStrategyTool, paperStopStrategyTool, @@ -286,6 +292,10 @@ export const orchestratorToolList = [ paperListOrdersTool, paperListPositionsTool, paperGetAccountTool, + // 账户外生资金事件:充值/重置(permission ask 弹气泡)+ 流水查询 + paperDepositCashTool, + paperResetAccountTool, + paperListCashFlowsTool, // D-9 类 Hermes 定时管理(让 agent 在对话里跑 scheduler) schedulerCreateJobTool, schedulerListJobsTool, diff --git a/packages/orchestration/src/tools/paper.ts b/packages/orchestration/src/tools/paper.ts index 654e34b1..30765710 100644 --- a/packages/orchestration/src/tools/paper.ts +++ b/packages/orchestration/src/tools/paper.ts @@ -768,13 +768,17 @@ export const paperGetAccountTool = createTool({ 返回(D-11): - cash / positions_value / total_equity 均已折算到 base_currency(默认 USD) - cash_balances 给出折算前的按币种原始桶(如 {"USD": 5000, "USDT": -1000}) - - fx_warnings:折算时 FX 不可用 / 偏旧的币种告警 + - positions_value 按**最新市价** mark-to-market(spot = qty×最新价含浮盈; + perp 贡献未实现盈亏),total_equity 随行情浮动 + - net_external_flows:自最近一次重置以来的净充值。**报告收益率必须减掉它**: + 真实收益 = total_equity − initial_cash − net_external_flows(充值 ≠ 盈利) + - fx_warnings:估值告警(FX 或最新价不可用 / 偏旧) 坑: - - 持仓估值用 avg_open_price 兜底(D-8b 不接实时 mark);实际权益略偏保守 - - **fx_warnings 非空时必须原样转告用户**——表示某些币种 FX 拿不到被排除出估值, - 或汇率偏旧,总权益可能不完整(金融时效硬约束) - - 默认初始 10000,首次下单时 lazy create + - **fx_warnings 非空时必须原样转告用户**——某些币种 FX 拿不到被排除出估值, + 或某持仓最新价拿不到按开仓均价兜底,总权益可能不完整(金融时效硬约束) + - 默认初始 10000,首次下单时 lazy create;用户要改资金 → paper.deposit_cash / + paper.reset_account `.trim(), inputSchema: z.object({}), execute: async (_input, ctx) => { @@ -784,6 +788,112 @@ export const paperGetAccountTool = createTool({ }, }); +// ──────────────────────────────────────────────────────────────────── +// 账户外生资金事件:充值 / 重置 / 流水 +// ──────────────────────────────────────────────────────────────────── + +export const paperDepositCashTool = createTool({ + id: "paper.deposit_cash", + description: ` + 给模拟盘账户充值(虚拟资金)。入指定币种桶(默认账户 base_currency), + 后端写资金流水留痕(审计可还原),**不改 initial_cash**——充值 ≠ 赚钱, + 总收益率分母不动。 + + 何时用: + - 用户明确说"给我的账户充 X / 加点钱 / 把可用资金加到 X" + - 账户现金不够想开新仓 / 起新 runner(先告知用户现状,用户确认充值再调) + + 何时不用: + - 用户想"重新开始 / 清空重来" → paper.reset_account(重置基准,充值不清持仓) + - 用户只是问余额 → paper.get_account + - **用户没有明确要求改资金时绝不要主动充值**——改钱=改绩效口径 + + 坑: + - permission ask:调用会弹气泡让用户确认,调前必须在对话里说清金额和币种 + - crypto 交易花的是 USDT 桶:给 crypto 加钱通常应传 currency="USDT" + (不传默认 base_currency 如 USD,靠 1:1 折算也能过购买力守门,但桶会更花) + - 已在跑的 runner 的 allocation 不会随充值变——新额度对新 run 生效 + `.trim(), + inputSchema: z.object({ + amount: z.number().positive().max(1e9).describe("充值金额(>0)"), + currency: z + .string() + .min(1) + .max(16) + .optional() + .describe("入账币种桶(如 USDT);省略 = 账户 base_currency"), + note: z.string().max(500).optional().describe("备注(落流水审计,建议写原因)"), + }), + execute: async (inputData, ctx) => { + const tc = ctx?.requestContext as ToolRequestContext | undefined; + const client = await getClient(tc); + return await client.depositCash(inputData); + }, +}); + +export const paperResetAccountTool = createTool({ + id: "paper.reset_account", + description: ` + 重置模拟盘账户到初始状态:**删全部持仓行** + 现金回 {base_currency: initial_cash} + (可传新基准金额)。orders / closed_trades / strategy_runs 历史全部保留(审计 + 不可抹),重置后绩效从新基准起算。 + + 何时用: + - 用户明确说"重置账户 / 清空重来 / 重新开始 / 把账户恢复到初始状态" + + 何时不用: + - 只想加钱 → paper.deposit_cash(重置会清持仓,加钱不会) + - 有 running 的 live runner → 先 paper.stop_strategy 停掉(否则后端 409 + ACCOUNT_HAS_RUNNING_RUNS——runner 下一根 bar 会把仓开回来) + - **用户没有明确要求重置时绝不要主动调**——这是破坏性操作(持仓直接消失) + + 坑: + - permission ask:调用会弹气泡确认;调前必须在对话里报告将被清掉的持仓 + (先 paper.list_positions 给用户看),等用户明确同意 + - 重置不可撤销(持仓行删除;但逐笔历史在 orders/closed_trades 可查) + - initialCash 省略 = 沿用当前基准(默认 10000) + `.trim(), + inputSchema: z.object({ + initialCash: z + .number() + .positive() + .max(1e9) + .optional() + .describe("新一轮初始资金(base_currency 计);省略 = 沿用当前 initial_cash"), + note: z.string().max(500).optional().describe("备注(落流水审计,建议写重置原因)"), + }), + execute: async (inputData, ctx) => { + const tc = ctx?.requestContext as ToolRequestContext | undefined; + const client = await getClient(tc); + return await client.resetAccount(inputData); + }, +}); + +export const paperListCashFlowsTool = createTool({ + id: "paper.list_cash_flows", + description: ` + 列账户外生资金流水(充值 / 提取 / 重置留痕),最近的在前。 + + 何时用: + - 用户问"我充过几次钱 / 什么时候重置过 / 资金变动记录" + - 解释总收益率口径时(初始 10000 + 充值 5000 的账户,权益 16000 ≠ 赚 60%) + + 何时不用: + - 查成交现金变动 → paper.list_orders(成交不在本流水里,两套口径互补) + + 返回:每行 { kind: deposit|withdraw|reset, currency, amount(带符号), + balance_after(变更后桶余额), note, created_at } + `.trim(), + inputSchema: z.object({ + limit: z.number().int().min(1).max(500).default(50).describe("最多返回条数"), + }), + execute: async (inputData, ctx) => { + const tc = ctx?.requestContext as ToolRequestContext | undefined; + const client = await getClient(tc); + return await client.listCashFlows(inputData.limit); + }, +}); + // ──────────────────────────────────────────────────────────────────── // D-11 · live runner(issue #1) // ──────────────────────────────────────────────────────────────────── @@ -810,7 +920,9 @@ export const paperStartStrategyTool = createTool({ 两个 run 会共享同一行持仓互相打架);换策略先 stop 旧 run - candidate 表不含 venue/symbol/timeframe,必须在这里指定 - allocation 是该 run 的资金额度(sizing 上限);省略时服务端取 - min(10000, 账户可用现金),账户可用 ≤0 会 422——先平仓/停 run 释放资金 + min(10000, 账户可用现金 − 其他 running run 已分配额度)。默认 1 万的账户起 + **第二个** runner 时未分配额度通常已是 0 → 422——要么显式传较小的 allocation + (并建议第一个也用显式额度),要么先 paper.deposit_cash 充值 - 机器自动审批下单(approved_by=system:live_runner),正当性靠"人先 promote + 人显式 start" `.trim(), inputSchema: z.object({ @@ -940,6 +1052,9 @@ export const paperTools = [ paperListOrdersTool, paperListPositionsTool, paperGetAccountTool, + paperDepositCashTool, + paperResetAccountTool, + paperListCashFlowsTool, paperComposeStrategyTool, paperListBacktestRunsTool, paperListBacktestTradesTool, From 8003cb053dcb124acbecfa4b8c45223d8d3eaffe Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 16:23:14 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(paper):=20reset=20=E7=BA=AA=E5=85=83?= =?UTF-8?q?=E4=B8=89=E5=8F=A3=E5=BE=84=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=B7=B2=E5=AE=9E=E7=8E=B0=E7=9B=88=E4=BA=8F?= =?UTF-8?q?/=E9=A3=8E=E6=8E=A7=E6=88=90=E4=BA=A4=E7=AA=97=E5=8F=A3/resume?= =?UTF-8?q?=20=E9=92=B1=E5=8C=85=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset = 绩效新纪元,三处此前不感知它的口径统一收口: 1. 快照 realized_pnl 改从 closed_trades(成交审计源)按最近一次 reset 起算: 此前从 positions 行汇总,reset 删行后快照凭空归零而 closed_trades 仍在, 两套"已实现盈亏"互相矛盾;现在单一口径,重置后从 0 起、新平仓正常累计。 2. 风控成交窗口按 reset epoch 收口(PostgresTradeRepository.refresh): 否则 lookback 窗口内的旧亏损会在重置后的"干净"账户重新触发 MaxDrawdown/LowProfit 锁。已存在的 risk_locks 行无 account 维度暂无法按户 清理,到期自然失效(结构性多租户项另行处理)。 3. live run resume 回灌净已实现盈亏(毛已实现 − 手续费,自 started_at)到 session 钱包(新 Portfolio.adjust_cash):此前重启后钱包从 allocation 满额 重建——亏损 run 一重启就"回血",盈利 run 赚到的额度凭空消失。 Co-Authored-By: Claude Fable 5 --- .../paper/src/inalpha_paper/api/orders.py | 30 ++++++---- .../src/inalpha_paper/engine/portfolio.py | 9 +++ .../execution/risk_rules/postgres_repo.py | 8 +++ .../paper/src/inalpha_paper/live_runner.py | 27 ++++++++- services/paper/src/inalpha_paper/schemas.py | 3 +- .../src/inalpha_paper/storage/accounts.py | 18 ++++++ .../inalpha_paper/storage/closed_trades.py | 28 +++++++++ .../tests/test_api_account_cash_flows.py | 58 +++++++++++++++++-- services/paper/tests/test_live_runner.py | 30 ++++++++++ 9 files changed, 195 insertions(+), 16 deletions(-) diff --git a/services/paper/src/inalpha_paper/api/orders.py b/services/paper/src/inalpha_paper/api/orders.py index d4ede7e7..a0f7b772 100644 --- a/services/paper/src/inalpha_paper/api/orders.py +++ b/services/paper/src/inalpha_paper/api/orders.py @@ -49,6 +49,7 @@ SubmitOrderResponse, ) from ..storage import accounts as accounts_store +from ..storage import closed_trades as closed_trades_store from ..storage import orders as orders_store from ..storage import positions as positions_store from ..storage import strategy_runs as runs_store @@ -344,28 +345,35 @@ async def get_my_account( acct = await accounts_store.get_or_create(db, account_id) base_currency = acct["base_currency"] - # include_flat=True:已平仓行(quantity=0)对 positions_value 贡献 0,但仍携带累计 - # realized_pnl——纳入才能让账户总已实现盈亏完整(CR:避免漏计已平仓持仓的 PnL)。 + # 持仓行只用于市值(mark-to-market);已实现盈亏改从 closed_trades 汇总(见下)。 pos_rows = await positions_store.list_by_account(db, account_id, include_flat=True) # 原始按币种桶 cash_balances: dict[str, Decimal] = { cur: Decimal(str(amt)) for cur, amt in (acct["cash_balances"] or {}).items() } - # realized_pnl 按计价货币分桶(NULL 行按 venue/symbol 兜底解析); - # 持仓市值在下方 try 里逐仓取 mark 后再分桶(需要 data client)。 - realized_pnl_by_ccy: dict[str, Decimal] = {} pos_ccys: set[str] = set() has_open_position = False for p in pos_rows: - ccy = p.get("currency") or resolve_currency( - p["venue"], p["symbol"], default=base_currency + pos_ccys.add( + p.get("currency") + or resolve_currency(p["venue"], p["symbol"], default=base_currency) ) - pos_ccys.add(ccy) if Decimal(p["quantity"]) != 0: has_open_position = True + + # realized_pnl 以**成交审计源**(closed_trades)为准,按最近一次 reset 起算: + # 此前从 positions 行汇总——reset 删行后快照凭空归零而 closed_trades 仍在, + # 两套"已实现盈亏"互相矛盾;统一到 closed_trades + reset epoch 一个口径。 + reset_ts = await accounts_store.last_reset_at(db, account_id) + realized_rows = await closed_trades_store.sum_realized_grouped( + db, account_id=account_id, since=reset_ts + ) + realized_pnl_by_ccy: dict[str, Decimal] = {} + for r in realized_rows: + ccy = resolve_currency(r["venue"], r["symbol"], default=base_currency) realized_pnl_by_ccy[ccy] = ( - realized_pnl_by_ccy.get(ccy, Decimal(0)) + Decimal(p["realized_pnl"]) + realized_pnl_by_ccy.get(ccy, Decimal(0)) + Decimal(str(r["realized"])) ) # 净外生入金(自最近一次 reset,按币种)——真实收益口径的第三项,防充值算成盈利 @@ -373,7 +381,9 @@ async def get_my_account( # DataClient 在两种情况下才开:FX 有非本地可解析币种,或有持仓要取 mark # (无持仓的单币种 / crypto-USD 账户保持零网络)。 - all_ccys = set(cash_balances) | pos_ccys | set(flows_by_ccy) + all_ccys = ( + set(cash_balances) | pos_ccys | set(flows_by_ccy) | set(realized_pnl_by_ccy) + ) # token 实际上必非空:get_current_user 依赖已保证 Bearer header 合法,否则先行 401; # 这里 token=None 分支是防御性的(理论不可达),保留以防未来调用方绕过 auth。 token = ( diff --git a/services/paper/src/inalpha_paper/engine/portfolio.py b/services/paper/src/inalpha_paper/engine/portfolio.py index a63bc364..d1fb80e5 100644 --- a/services/paper/src/inalpha_paper/engine/portfolio.py +++ b/services/paper/src/inalpha_paper/engine/portfolio.py @@ -200,6 +200,15 @@ def can_afford_sell( # 与其他持仓互斥:可用 = free_margin + 本仓当前 IM(同 can_afford_buy)。单仓时 == cash。 return prospective_margin + fee <= self.free_margin() + cur_im + def adjust_cash(self, delta: float) -> None: + """外生现金调整(不经 fill):live session resume 回灌历史净已实现盈亏用。 + + 重启后 session 钱包从 initial_cash(allocation)重建,若不把 run 此前的 + 已实现盈亏(−手续费)灌回,亏损 run 一重启钱包就回血满额、allocation 花费 + 记忆丢失。除本方法与 apply_funding 外,现金只应经 fill 变动。 + """ + self._cash += delta + def update_mark(self, instrument_id: InstrumentId, mark_price: float) -> None: """BacktestEngine 每根 bar 调一次,更新 mark-to-market 估值用的最新价。""" self._marks[instrument_id] = mark_price diff --git a/services/paper/src/inalpha_paper/execution/risk_rules/postgres_repo.py b/services/paper/src/inalpha_paper/execution/risk_rules/postgres_repo.py index 9196065f..efa29aef 100644 --- a/services/paper/src/inalpha_paper/execution/risk_rules/postgres_repo.py +++ b/services/paper/src/inalpha_paper/execution/risk_rules/postgres_repo.py @@ -62,7 +62,12 @@ async def refresh(self, now: datetime | None = None) -> int: """从 DB 拉最近 ``lookback_min`` 分钟内 closed_trades,覆盖 cache。返回行数。 调用方应周期性调(如 ClosedTradesWriter flush 后 trigger)。 + + **reset epoch 收口**:窗口起点不早于账户最近一次 reset——重置 = 绩效新纪元, + 否则 lookback 窗口内的旧亏损会在重置后的"干净"账户重新触发 + MaxDrawdown/LowProfit 锁(旧成交属上一轮口径,不该再参与风控判定)。 """ + from ...storage import accounts as accounts_store from ...storage import closed_trades as trades_store if now is None: @@ -70,6 +75,9 @@ async def refresh(self, now: datetime | None = None) -> int: close_after = now - timedelta(minutes=self._lookback_min) async with self._db_pool.connection() as conn: + last_reset = await accounts_store.last_reset_at(conn, self._account_id) + if last_reset is not None and last_reset > close_after: + close_after = last_reset rows = await trades_store.list_recent( conn, account_id=self._account_id, diff --git a/services/paper/src/inalpha_paper/live_runner.py b/services/paper/src/inalpha_paper/live_runner.py index 16f931f2..e61c31df 100644 --- a/services/paper/src/inalpha_paper/live_runner.py +++ b/services/paper/src/inalpha_paper/live_runner.py @@ -494,11 +494,36 @@ async def _build_session( async def _restore_position( self, session: LiveEngineSession, run: dict[str, Any] ) -> None: - """从 DB 读 run 的 (account, venue, symbol) 当前持仓,灌回 session(resume 续跑)。""" + """从 DB 读 run 的 (account, venue, symbol) 当前持仓,灌回 session(resume 续跑)。 + + 同时把 run 此前的**净已实现盈亏**(closed_trades 毛盈亏 − 手续费,自 + started_at,与 cumulative_pnl 展示同口径)灌回 session 钱包——否则重启后 + 钱包从 allocation 满额重建,亏损 run 一重启就"回血",allocation 花费记忆 + 丢失、run 级购买力失真(盈利同理:赚到的额度重启后凭空消失)。 + """ + started_at = run.get("started_at") async with get_conn() as conn: pos = await positions_store.get( conn, account_id=run["account_id"], venue=run["venue"], symbol=run["symbol"] ) + if started_at is not None: + realized = await closed_trades_store.sum_realized( + conn, account_id=run["account_id"], venue=run["venue"], + symbol=run["symbol"], since=started_at, + ) + fees = await orders_store.sum_fees( + conn, account_id=run["account_id"], venue=run["venue"], + symbol=run["symbol"], since=started_at, + ) + else: # 防御:无 started_at(理论只在测试构造 dict 时出现)→ 不回灌 + realized = fees = Decimal(0) + net_realized = float(realized - fees) + if net_realized: + session.portfolio.adjust_cash(net_realized) + _logger.info( + "live run %s: resume 回灌净已实现 %+.4f(毛 %s − fee %s)到 session 钱包", + run["id"], net_realized, realized, fees, + ) if pos is None: return qty = float(pos["quantity"]) diff --git a/services/paper/src/inalpha_paper/schemas.py b/services/paper/src/inalpha_paper/schemas.py index 6e051987..662c46c2 100644 --- a/services/paper/src/inalpha_paper/schemas.py +++ b/services/paper/src/inalpha_paper/schemas.py @@ -761,7 +761,8 @@ class AccountSnapshot(BaseModel): ) realized_pnl: float = Field( default=0.0, - description="所有持仓累计实现 PnL,按各自计价货币折算到 base_currency 后汇总", + description="自最近一次重置以来的累计实现 PnL(closed_trades 成交审计口径," + "毛盈亏不含手续费),按各计价货币折算到 base_currency 后汇总", ) net_external_flows: float = Field( default=0.0, diff --git a/services/paper/src/inalpha_paper/storage/accounts.py b/services/paper/src/inalpha_paper/storage/accounts.py index 70391438..d3928ef1 100644 --- a/services/paper/src/inalpha_paper/storage/accounts.py +++ b/services/paper/src/inalpha_paper/storage/accounts.py @@ -126,6 +126,24 @@ async def list_cash_flows( return list(rows) # type: ignore[arg-type] +async def last_reset_at( + conn: AsyncConnection, account_id: UUID +) -> Any | None: + """最近一次 reset 的时刻(datetime);从未 reset 返 None。 + + reset = 绩效新纪元:账户快照的 realized_pnl、风控规则的成交窗口都以它为起点 + (旧亏损不该再触发锁、旧盈亏不该混进新一轮口径)。 + """ + async with conn.cursor() as cur: + await cur.execute( + "SELECT MAX(created_at) AS ts FROM account_cash_flows " + "WHERE account_id = %s AND kind = 'reset'", + (str(account_id),), + ) + row = await cur.fetchone() + return row["ts"] if row else None # type: ignore[index] + + async def sum_external_flows_since_reset( conn: AsyncConnection, account_id: UUID ) -> dict[str, Decimal]: diff --git a/services/paper/src/inalpha_paper/storage/closed_trades.py b/services/paper/src/inalpha_paper/storage/closed_trades.py index 3b65c4e6..7bda20e3 100644 --- a/services/paper/src/inalpha_paper/storage/closed_trades.py +++ b/services/paper/src/inalpha_paper/storage/closed_trades.py @@ -148,6 +148,34 @@ async def sum_realized( return Decimal(str(row["realized"])) if row else Decimal(0) # type: ignore[index] +async def sum_realized_grouped( + conn: AsyncConnection, + *, + account_id: UUID, + since: datetime | None = None, +) -> list[dict[str, Any]]: + """账户已实现盈亏按 (venue, symbol) 分组合计(账户快照口径)。 + + ``since`` 传最近一次 reset 时刻:快照的 realized_pnl 以**成交审计源** + (closed_trades)为准并按 reset epoch 起算——此前从 positions 行汇总,reset + 删行后快照凭空归零而 closed_trades 仍在,两套"已实现盈亏"口径分叉。 + ``since=None`` = 全历史(从未 reset 的账户)。 + """ + sql = ( + "SELECT venue, symbol, COALESCE(SUM(close_profit_abs), 0) AS realized " + "FROM closed_trades WHERE account_id = %s" + ) + args: list[Any] = [str(account_id)] + if since is not None: + sql += " AND close_ts > %s" + args.append(since) + sql += " GROUP BY venue, symbol" + async with conn.cursor() as cur: + await cur.execute(sql, tuple(args)) + rows = await cur.fetchall() + return list(rows) # type: ignore[arg-type] + + async def count_by_account( conn: AsyncConnection, *, diff --git a/services/paper/tests/test_api_account_cash_flows.py b/services/paper/tests/test_api_account_cash_flows.py index 2649bc3c..eb07227c 100644 --- a/services/paper/tests/test_api_account_cash_flows.py +++ b/services/paper/tests/test_api_account_cash_flows.py @@ -86,15 +86,27 @@ def test_deposit_unknown_currency_rejected(client: TestClient) -> None: def test_reset_clears_positions_keeps_history(client: TestClient) -> None: - """重置:删持仓 + 现金回基准 + reset 流水;订单历史保留(审计不可抹)。""" + """重置:删持仓 + 现金回基准 + reset 流水;订单历史保留(审计不可抹)。 + + 已实现盈亏走 closed_trades + reset epoch 口径:重置前 500,重置后归零—— + 快照与成交审计源不再分叉(此前从 positions 行汇总,删行即凭空归零)。 + """ _, headers = _headers() - # 先买出一个持仓(USDT 桶变负) + # 买 0.1 @50000,平 0.05 @60000 → 已实现 +500(毛),持仓剩 0.05 r = client.post( "/orders/submit", headers=headers, json={"symbol": "BTC/USDT", "side": "BUY", "type": "MARKET", "quantity": 0.1, "ref_price": 50_000.0}, ) assert r.status_code == 200, r.json() + r = client.post( + "/orders/submit", headers=headers, + json={"symbol": "BTC/USDT", "side": "SELL", "type": "MARKET", + "quantity": 0.05, "ref_price": 60_000.0}, + ) + assert r.status_code == 200, r.json() + pre = client.get("/accounts/me", headers=headers).json() + assert pre["realized_pnl"] == pytest.approx(500.0) assert client.get("/positions", headers=headers).json() != [] r = client.post("/accounts/me/reset", headers=headers, json={}) @@ -107,14 +119,15 @@ def test_reset_clears_positions_keeps_history(client: TestClient) -> None: acct = client.get("/accounts/me", headers=headers).json() assert acct["cash_balances"] == {"USD": 10_000.0} # USDT 负桶被清 assert acct["positions_value"] == pytest.approx(0.0) - # 净外生入金按"最近一次 reset 之后"统计 → 重置即归零(新一轮口径) + # 新一轮口径:净外生入金与已实现盈亏都按"最近一次 reset 之后"统计 → 归零 assert acct["net_external_flows"] == pytest.approx(0.0) + assert acct["realized_pnl"] == pytest.approx(0.0) assert client.get("/positions", headers=headers).json() == [] # 历史订单仍在(审计):重置不抹交易流水 orders = client.get( "/orders", headers=headers, params={"symbol": "BTC/USDT"} ).json() - assert len(orders) == 1 + assert len(orders) == 2 async def test_reset_blocked_by_running_run( @@ -136,6 +149,43 @@ async def test_reset_blocked_by_running_run( assert r.json()["code"] == "ACCOUNT_HAS_RUNNING_RUNS" +async def test_trade_repo_window_clamped_by_reset( + client: TestClient, app_with_lifespan: Any +) -> None: + """风控成交窗口按 reset epoch 收口:旧亏损不再触发新一轮的行为型规则。 + + 重置前 lookback 窗口内有一笔亏损平仓 → repo.refresh 能看到;reset 后同一窗口 + refresh → 看不到(旧成交属上一轮口径,MaxDrawdown/LowProfit 不该再用它锁账户)。 + """ + from datetime import UTC, datetime + + from inalpha_shared import db as shared_db + + from inalpha_paper.execution.risk_rules.postgres_repo import ( + PostgresTradeRepository, + ) + from inalpha_paper.storage import closed_trades as closed_trades_store + + sub, headers = _headers("cfrepo") + account_id = account_id_from_sub(sub) + now = datetime.now(UTC) + async with get_conn() as conn: + await closed_trades_store.insert_close( + conn, account_id=account_id, venue="binance", symbol="BTC/USDT", + side="long", open_ts=now, close_ts=now, + open_price=100.0, close_price=90.0, quantity=1.0, + close_profit_pct=-10.0, close_profit_abs=-10.0, + exit_reason="stop_loss", + ) + assert shared_db._pool is not None + repo = PostgresTradeRepository(account_id, shared_db._pool, lookback_min=1440) + assert await repo.refresh() == 1 # 重置前:亏损在窗口内 + + r = client.post("/accounts/me/reset", headers=headers, json={}) + assert r.status_code == 200, r.json() + assert await repo.refresh() == 0 # 重置后:窗口起点被 reset epoch 收口 + + def test_perp_cross_position_margin_aggregated(client: TestClient) -> None: """perp 跨仓保证金聚合:多仓合计 IM 不得超钱包(单笔各自过闸的洞已堵)。 diff --git a/services/paper/tests/test_live_runner.py b/services/paper/tests/test_live_runner.py index 5712b164..c54b1670 100644 --- a/services/paper/tests/test_live_runner.py +++ b/services/paper/tests/test_live_runner.py @@ -1012,6 +1012,36 @@ async def test_restore_position_from_db_brings_session_to_position( assert pos.quantity == 2.0 +async def test_restore_backfills_net_realized_to_session_wallet( + app_with_lifespan: Any, +) -> None: + """resume 把 run 净已实现盈亏灌回 session 钱包——重启不"回血"也不丢盈利额度。 + + 买 1 @50000(fee 50)→ 全平 @60000(fee 60):毛已实现 10000 − 费 110 = 9890。 + 新 session resume 后钱包应为 _TEST_CASH + 9890(持仓已 flat,无成本占用), + 而不是回到 _TEST_CASH 满额(allocation 花费/盈利记忆丢失)。 + """ + manager = LiveRunnerManager(risk_guard_factory=None, settings=get_paper_settings()) + account_id = uuid4() + run = await _insert_run(account_id) + await manager._process_bar( + _make_session(), run, _bar(1_700_000_000_000_000_000, close=50_000.0) + ) + await manager._process_bar( + _sell_session(1.0), run, _bar(1_700_000_003_600_000_000, close=60_000.0) + ) + + session = _make_session() + # 测试 bar 是 2023 年时间戳,而 run.started_at=NOW();回灌按 started_at 过滤, + # 这里改成早于 bar 的时刻模拟真实时序(实际运行中 bar 恒晚于 started_at)。 + run["started_at"] = datetime(2020, 1, 1, tzinfo=UTC) + await manager._restore_position(session, run) + + assert session.portfolio.cash == pytest.approx(_TEST_CASH + 9_890.0) + pos = session.portfolio.position(_INSTRUMENT) + assert pos is None or pos.is_flat # 已全平,不重建持仓 + + async def test_convert_run_pnl_zero_short_circuits_no_network() -> None: """total_quote=0 → 直接返 Decimal(0),不打 /fx(非 USD 币种也不需网络)。""" manager = LiveRunnerManager(risk_guard_factory=None, settings=get_paper_settings()) From b1b7a41eccd8efc77949e3b2ab50be486a61cb00 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 17:43:28 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(paper):=20CR=20#131=20medium=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E9=94=81=E5=86=85=E9=9B=B6?= =?UTF-8?q?=20HTTP=20+=20=E8=80=81=E4=BB=93=E4=BF=9D=E8=AF=81=E9=87=91?= =?UTF-8?q?=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处 auto-review 指出的修复: 1. 持锁期间零 HTTP(medium):spot BUY 购买力守门的 FX 汇率预取从事务内移到 事务**外**(LOCK 之前),锁内复用 offline_copy(零网络)。data 慢时不会占住 DB 连接与账户行锁——模拟盘低并发也要设这个下限。 追加:BaseCurrencyConverter.base property 供锁内核对 base 一致性。 2. sum_other_margin_used 漏 currency IS NULL 老仓(medium):0009 迁移前的持仓 行 currency=NULL,SQL 精确匹配 currency=%s 会漏掉它们的保证金;改为 Python 侧用 currency_resolver 兜底解析,与账户快照读取层同约定。 Co-Authored-By: Claude Fable 5 --- .../paper/src/inalpha_paper/api/orders.py | 58 ++++++++++++------- services/paper/src/inalpha_paper/fx.py | 5 ++ .../src/inalpha_paper/storage/positions.py | 20 +++++-- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/services/paper/src/inalpha_paper/api/orders.py b/services/paper/src/inalpha_paper/api/orders.py index a0f7b772..3067c1e9 100644 --- a/services/paper/src/inalpha_paper/api/orders.py +++ b/services/paper/src/inalpha_paper/api/orders.py @@ -123,6 +123,33 @@ async def post_submit_order( fee_rate=req.fee_rate, ) + # spot BUY:在事务**外**预取 FX 汇率(持锁期间不能做 HTTP——否则 data 慢时整条 + # DB 连接锁住,阻塞同账户所有并发下单/充值;预取后锁内用 offline_copy 零网络)。 + spot_buy_converter: BaseCurrencyConverter | None = None + spot_buy_order_ccy: str | None = None + if req.trading_mode != "perp" and req.side == "BUY": + spot_buy_order_ccy = resolve_currency(req.venue, req.symbol) + base_ccy = "USD" # 兜底,锁内再从真实 acct 拿 + fx_token = ( + authorization.removeprefix("Bearer ").strip() + if authorization and authorization.startswith("Bearer ") + else None + ) + # 币种集未知(未锁无法知道真实 cash_balances),保守预取已解析的计价币。 + # 锁内真实桶多于预取币种时由 offline_copy 排除(fail-closed),不静默猜。 + fx_client = ( + DataClient(settings.data_service_url, fx_token) + if fx_token and fx_needs_network([spot_buy_order_ccy], base_ccy) + else None + ) + try: + _prefetch = BaseCurrencyConverter(base_ccy, fx_client) + _ = await _prefetch.rate(spot_buy_order_ccy) + spot_buy_converter = _prefetch.offline_copy() + finally: + if fx_client is not None: + await fx_client.close() + # 落盘 + 持仓 + 现金(事务) async with db.transaction(): # **恒锁账户行,统一全局锁序 accounts → positions**: @@ -171,32 +198,23 @@ async def post_submit_order( # 各币种现金桶按 FX 折算成 base 总可用现金,notional+fee(折 base)超过可用×0.99 # 即拒——桶允许为负(= 账户内隐式借计价货币,如 USD 户买 USDT 对),但**总折算 # 现金不允许被买穿**。账户行已在上方 FOR UPDATE,与扣款同事务防 TOCTOU。 - # FX:USD 稳定币本地 1:1 零网络;其余币种在锁内调 data /fx(模拟盘低并发可接 - # 受);拿不到汇率的桶排除出可用现金、订单计价货币折不了则直接拒(fail-closed)。 + # FX 已在事务**外**预取(offline_copy,持锁期间零 HTTP)——data 慢时不会占住 + # DB 连接与账户行锁。锁内出现预取未覆盖的币种桶按排除处理(fail-closed)。 if req.trading_mode != "perp" and req.side == "BUY": balances = { cur: Decimal(str(amt)) for cur, amt in (acct.get("cash_balances") or {}).items() } - order_ccy = resolve_currency(req.venue, req.symbol) base_ccy = acct["base_currency"] - fx_token = ( - authorization.removeprefix("Bearer ").strip() - if authorization and authorization.startswith("Bearer ") - else None - ) - fx_client = ( - DataClient(settings.data_service_url, fx_token) - if fx_token and fx_needs_network({*balances, order_ccy}, base_ccy) - else None - ) - try: - converter = BaseCurrencyConverter(base_ccy, fx_client) - available = await convert_cash_balances(converter, balances) - order_ccy_rate = await converter.rate(order_ccy) - finally: - if fx_client is not None: - await fx_client.close() + order_ccy = spot_buy_order_ccy or resolve_currency(req.venue, req.symbol) + # 锁内复用预取 converter:核对 base 一致(缓存命中,零 HTTP),不一致则重建 + # (理论只在并发中途账户 base 被改的极端情形,防御性处理)。 + lock_converter = spot_buy_converter.offline_copy() if spot_buy_converter is not None else None + if lock_converter is not None and lock_converter.base != base_ccy: + lock_converter = BaseCurrencyConverter(base_ccy, None) + converter = lock_converter or BaseCurrencyConverter(base_ccy, None) + available = await convert_cash_balances(converter, balances) + order_ccy_rate = await converter.rate(order_ccy) if violates_spot_buying_power( side=req.side, quantity=req.quantity, diff --git a/services/paper/src/inalpha_paper/fx.py b/services/paper/src/inalpha_paper/fx.py index bbc2b1cd..8ee3afa3 100644 --- a/services/paper/src/inalpha_paper/fx.py +++ b/services/paper/src/inalpha_paper/fx.py @@ -82,6 +82,11 @@ async def convert(self, amount: Decimal, currency: str) -> Decimal | None: r = await self.rate(currency) return None if r is None else amount * r + @property + def base(self) -> str: + """折算目标货币(构造时定);调用方核对预取 converter 与锁内账户 base 一致用。""" + return self._base + def offline_copy(self) -> BaseCurrencyConverter: """复制一个**不打网络**的 converter(带走已缓存汇率)。 diff --git a/services/paper/src/inalpha_paper/storage/positions.py b/services/paper/src/inalpha_paper/storage/positions.py index fa41747d..ba800500 100644 --- a/services/paper/src/inalpha_paper/storage/positions.py +++ b/services/paper/src/inalpha_paper/storage/positions.py @@ -318,17 +318,27 @@ async def sum_other_margin_used( ``margin_used`` 由每笔 fill 后的保证金重算维护,是现成权威值;排除本 (venue, symbol)——本仓按"成交后目标仓 IM"在调用方另算,不能重复计。 + 币种匹配在 Python 侧做:``currency IS NULL`` 的老行(多币种迁移前建仓、此后 + 无成交)按 (venue, symbol) 兜底解析——与账户快照读取层同约定;纯 SQL + ``currency = %s`` 会漏掉老仓保证金,聚合守门放过实际超钱包的加仓。 读不加锁,与钱包读同级(模拟盘 TOCTOU 容忍度一致)。 """ + from ..execution.currency_resolver import resolve_currency + async with conn.cursor() as cur: await cur.execute( - "SELECT COALESCE(SUM(margin_used), 0) AS total FROM positions " - "WHERE account_id = %s AND currency = %s AND quantity <> 0 " + "SELECT venue, symbol, currency, margin_used FROM positions " + "WHERE account_id = %s AND quantity <> 0 AND margin_used <> 0 " "AND NOT (venue = %s AND symbol = %s)", - (str(account_id), currency, exclude_venue, exclude_symbol), + (str(account_id), exclude_venue, exclude_symbol), ) - row = await cur.fetchone() - return Decimal(str(row["total"])) if row else Decimal(0) # type: ignore[index] + rows = await cur.fetchall() + total = Decimal(0) + for r in rows: + ccy = r["currency"] or resolve_currency(r["venue"], r["symbol"]) + if ccy == currency: + total += Decimal(str(r["margin_used"])) + return total async def delete_by_account(conn: AsyncConnection, account_id: UUID) -> int: From c03dea50913c227d957d8a086fccde42f168ff51 Mon Sep 17 00:00:00 2001 From: Miro Date: Fri, 3 Jul 2026 16:11:11 +0800 Subject: [PATCH 11/11] =?UTF-8?q?fix(paper):=20=E4=BF=AE=E5=A4=8D=20migrat?= =?UTF-8?q?ion=20=E9=93=BE=E2=80=94=E2=80=940026/0027=20=E7=BB=A7=E6=89=BF?= =?UTF-8?q?=20main=20=E4=B8=8A=200025?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0026_strategy_run_allocation 继承 0025_backtest_runs_account_id - 0027_account_cash_flows 继承 0026_strategy_run_allocation - 旧 0025/0026 文件已删除(git rm) Co-Authored-By: Claude --- .github/workflows/glm-review.yml | 191 +++++++++++++++ .github/workflows/glm.yml | 142 +++++++++++ .../dashboard/src/app/api/auth/login/route.ts | 60 +++++ .../src/app/api/auth/logout/route.ts | 10 + .../src/app/api/auth/session/route.ts | 14 ++ apps/dashboard/src/app/login/page.tsx | 23 ++ .../src/components/auth/LoginForm.tsx | 154 ++++++++++++ .../src/components/chat/ChatErrorBanner.tsx | 35 +++ .../src/components/chat/ChatHistoryPanel.tsx | 82 +++++++ .../src/components/chat/ChatInput.tsx | 118 +++++++++ .../src/components/chat/ChatMessage.tsx | 132 ++++++++++ .../src/components/chat/ChatMessageList.tsx | 88 +++++++ .../src/components/chat/ChatStreamdown.tsx | 120 ++++++++++ .../src/components/chat/ChatToolChip.tsx | 132 ++++++++++ .../chat/chat-streamdown-security.ts | 36 +++ .../src/components/chat/tool-states.ts | 134 +++++++++++ .../src/components/shell/AccountControl.tsx | 78 ++++++ apps/dashboard/src/lib/mastra.test.ts | 45 ++++ apps/dashboard/src/lib/session.ts | 99 ++++++++ apps/dashboard/vitest.config.ts | 12 + infra/migrations/versions/0024_users.py | 57 +++++ .../versions/0025_backtest_runs_account_id.py | 45 ++++ .../versions/0026_strategy_run_allocation.py | 43 ++++ .../versions/0027_account_cash_flows.py | 48 ++++ .../mastra/agents/instructions/divination.ts | 34 +++ .../src/mastra/agents/instructions/index.ts | 100 ++++++++ .../mastra/agents/instructions/language.ts | 30 +++ .../src/mastra/agents/instructions/market.ts | 142 +++++++++++ .../mastra/agents/instructions/pipeline.ts | 226 ++++++++++++++++++ .../mastra/agents/instructions/strategy.ts | 160 +++++++++++++ .../src/mastra/agents/instructions/style.ts | 82 +++++++ .../agents/instructions/tool-catalog.ts | 170 +++++++++++++ packages/orchestration/src/shared/schemas.ts | 173 ++++++++++++++ .../src/tools/research-parallel.ts | 179 ++++++++++++++ packages/orchestration/tests/auth.test.ts | 37 +++ .../tests/shared-schemas.test.ts | 122 ++++++++++ services/paper/scripts/create_user.py | 147 ++++++++++++ services/paper/src/inalpha_paper/api/auth.py | 127 ++++++++++ services/paper/tests/test_auth_login.py | 116 +++++++++ 39 files changed, 3743 insertions(+) create mode 100644 .github/workflows/glm-review.yml create mode 100644 .github/workflows/glm.yml create mode 100644 apps/dashboard/src/app/api/auth/login/route.ts create mode 100644 apps/dashboard/src/app/api/auth/logout/route.ts create mode 100644 apps/dashboard/src/app/api/auth/session/route.ts create mode 100644 apps/dashboard/src/app/login/page.tsx create mode 100644 apps/dashboard/src/components/auth/LoginForm.tsx create mode 100644 apps/dashboard/src/components/chat/ChatErrorBanner.tsx create mode 100644 apps/dashboard/src/components/chat/ChatHistoryPanel.tsx create mode 100644 apps/dashboard/src/components/chat/ChatInput.tsx create mode 100644 apps/dashboard/src/components/chat/ChatMessage.tsx create mode 100644 apps/dashboard/src/components/chat/ChatMessageList.tsx create mode 100644 apps/dashboard/src/components/chat/ChatStreamdown.tsx create mode 100644 apps/dashboard/src/components/chat/ChatToolChip.tsx create mode 100644 apps/dashboard/src/components/chat/chat-streamdown-security.ts create mode 100644 apps/dashboard/src/components/chat/tool-states.ts create mode 100644 apps/dashboard/src/components/shell/AccountControl.tsx create mode 100644 apps/dashboard/src/lib/mastra.test.ts create mode 100644 apps/dashboard/src/lib/session.ts create mode 100644 apps/dashboard/vitest.config.ts create mode 100644 infra/migrations/versions/0024_users.py create mode 100644 infra/migrations/versions/0025_backtest_runs_account_id.py create mode 100644 infra/migrations/versions/0026_strategy_run_allocation.py create mode 100644 infra/migrations/versions/0027_account_cash_flows.py create mode 100644 packages/orchestration/src/mastra/agents/instructions/divination.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/index.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/language.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/market.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/pipeline.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/strategy.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/style.ts create mode 100644 packages/orchestration/src/mastra/agents/instructions/tool-catalog.ts create mode 100644 packages/orchestration/src/shared/schemas.ts create mode 100644 packages/orchestration/src/tools/research-parallel.ts create mode 100644 packages/orchestration/tests/auth.test.ts create mode 100644 packages/orchestration/tests/shared-schemas.test.ts create mode 100644 services/paper/scripts/create_user.py create mode 100644 services/paper/src/inalpha_paper/api/auth.py create mode 100644 services/paper/tests/test_auth_login.py diff --git a/.github/workflows/glm-review.yml b/.github/workflows/glm-review.yml new file mode 100644 index 00000000..f7ad52a9 --- /dev/null +++ b/.github/workflows/glm-review.yml @@ -0,0 +1,191 @@ +name: GLM PR Review + +# PR 打开 / reopen / 每次 push 新 commit(synchronize)时自动 review diff。 +# 防邮件洪水靠三道闸而非「少触发」:① concurrency + cancel-in-progress 让同一 PR +# 新 push 立刻取消上一轮;② review step 只维护**一条 sticky 总结评论** +# (gh pr comment --edit-last 原地覆盖,GitHub 对编辑评论不发邮件,仅首条发一封); +# ③ 不逐行贴 inline 评论。需对历史 commit 重审可去 Actions 页跑 workflow_dispatch。 +# 非阻塞设计:即使 GLM 出错(API 超时、token 过期等), +# 本 workflow 不会阻止 PR 合并。review 评论是锦上添花,不是门禁。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: 手动指定 PR 编号(留空 = 用 workflow_dispatch 的当前分支) + required: false + +# 同一 PR 新 push 立刻取消上一次 review,避免重复评论 +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + review: + name: GLM-5.2 · auto-review PR + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch PR diff + id: diff + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + gh pr diff "$PR_NUMBER" --color never > /tmp/pr_diff.txt + echo "diff_lines=$(wc -l < /tmp/pr_diff.txt)" >> $GITHUB_OUTPUT + # diff 太长截断(GLM 上下文窗口虽大,但 token 和成本也要考虑) + if [ "$(wc -c < /tmp/pr_diff.txt)" -gt 200000 ]; then + head -c 200000 /tmp/pr_diff.txt > /tmp/pr_diff_truncated.txt + echo -e "\n\n[注意:diff 过大已截断至 200KB,完整 diff 请到 PR Files 页查看]" >> /tmp/pr_diff_truncated.txt + mv /tmp/pr_diff_truncated.txt /tmp/pr_diff.txt + fi + + - name: Fetch PR metadata + id: meta + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + echo "title<> $GITHUB_OUTPUT + gh pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName --jq '"\(.title) | \(.headRefName) → \(.baseRefName)"' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Call Zhipu GLM-5.2 for review + id: review + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + PR_TITLE: ${{ steps.meta.outputs.title }} + run: | + DIFF_CONTENT=$(cat /tmp/pr_diff.txt | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))') + + PROMPT=$(cat << 'END_PROMPT' +你是一个资深代码审查者。请审查以下 PR diff。 + +## 审查要求 +1. 先一句话总结这个 PR 的目的 +2. 逐维度评估(命中才提,不硬凑): + - **正确性**:边界条件、空值、off-by-one、错误假设、异常路径没处理 + - **设计/架构**:职责放错层、越过模块边界、重复造轮子 + - **契约/兼容**:改了公共接口/schema/API 是否破坏现有调用方 + - **错误处理**:失败是被静默吞掉还是显式处理 + - **资源/性能**:无界增长、N+1、循环无上限 + - **安全**:注入、越权、密钥泄漏 +3. **severity 阈值**:只提 ≥ medium 的问题;nit/风格跳过 +4. **误报闸**:必须能说出具体失败场景,说不出就不提 +5. **不重复 lint**:ruff/tsc/mypy 已能抓的不要再提 +6. **格式要求**:用 JSON 输出,每条带 severity( critical|major|medium ) + file + line + summary + failure_scenario + +如果没有任何 medium 以上问题,只回复一个 JSON: {"summary": "LGTM,没发现问题", "findings": []} + +其他情况回复 JSON: +{ + "summary": "一句话总结", + "findings": [ + {"severity": "major", "file": "path/to/file.py", "line": 42, "summary": "描述", "failure_scenario": "什么输入/时序下出问题"}, + ... + ] +} +END_PROMPT + + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '''$(echo "$PROMPT" | python3 -c 'import sys; print(sys.stdin.read().replace(chr(39), chr(39)+chr(39)+chr(39)))')'''}, + {'role': 'user', 'content': '## PR 标题\n$PR_TITLE\n\n## Diff\n$DIFF_CONTENT'} + ], + 'temperature': 0.1, + 'max_tokens': 4096 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + echo "result='GLM API 调用失败(HTTP $HTTP_CODE),请检查 ZHIPUAI_API_KEY 和网络连接。'" > /tmp/review_body.txt + exit 0 + fi + + # 提取 content + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/review_result.json + + # 生成评论正文 + python3 -c " +import json, sys +try: + with open('/tmp/review_result.json') as f: + data = json.load(f) +except json.JSONDecodeError: + # 不是 JSON 也接受(模型可能直接输出自然语言) + with open('/tmp/review_result.json') as f: + text = f.read() + with open('/tmp/review_body.txt', 'w') as out: + out.write(text) + sys.exit(0) + +lines = [] +lines.append('## 🤖 GLM-5.2 PR Review') +lines.append('') +lines.append(data.get('summary', '')) +lines.append('') + +findings = data.get('findings', []) +if not findings: + lines.append('✅ 未发现 medium 以上问题。') +else: + # 按 severity 排序 + severity_order = {'critical': 0, 'major': 1, 'medium': 2} + findings.sort(key=lambda x: severity_order.get(x.get('severity','medium'), 99)) + + for f in findings: + sev = f.get('severity', 'medium') + label = {'critical': '🔴', 'major': '🟠', 'medium': '🟡'}.get(sev, '🟡') + file_line = f.get('file', '?') + if f.get('line'): + file_line += f':{f[\"line\"]}' + lines.append(f'{label} **[{sev.upper()}]** {file_line}') + lines.append(f' - {f.get(\"summary\", \"\")}') + if f.get('failure_scenario'): + lines.append(f' - *失败场景:{f[\"failure_scenario\"]}*') + lines.append('') + +with open('/tmp/review_body.txt', 'w') as out: + out.write('\n'.join(lines)) +" + + - name: Post/Update sticky comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + REVIEW_BODY=$(cat /tmp/review_body.txt) + # 加标记行方便辨识 + MARKER="" + SHA=$(git rev-parse --short HEAD) + FULL_BODY="${MARKER}\nReview base: ${SHA}\n\n${REVIEW_BODY}" + gh pr comment "$PR_NUMBER" --edit-last --create-if-none --body "$(echo -e "$FULL_BODY")" + + - name: Log review status + if: always() + run: | + echo "GLM review completed (non-blocking)" \ No newline at end of file diff --git a/.github/workflows/glm.yml b/.github/workflows/glm.yml new file mode 100644 index 00000000..fcdf7f69 --- /dev/null +++ b/.github/workflows/glm.yml @@ -0,0 +1,142 @@ +name: GLM @mention 互动 + +# 在 PR/issue/review comment 里 @glm 触发 GLM-5.2 对话。 +# 本 workflow 不阻塞 CI/CD,失败不影响 PR 合并。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +jobs: + glm: + name: GLM-5.2 · @glm 触发 + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@glm')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@glm') || contains(github.event.issue.title, '@glm'))) + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Collect context + id: context + env: + GH_TOKEN: ${{ github.token }} + run: | + case "${{ github.event_name }}" in + issue_comment) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + gh issue view "$ISSUE_NUM" --json title,body --jq '"### \(.title)\n\n\(.body // "无正文")"' >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + pull_request_review_comment) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review Comment" >> $GITHUB_OUTPUT + echo "File: ${{ github.event.comment.path }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + pull_request_review) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.review.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + issues) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "### 标题" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.title }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### 正文" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + esac + + - name: Call Zhipu GLM-5.2 + id: call + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + CONTEXT: ${{ steps.context.outputs.context }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + # 用 python3 构造请求体,避免 bash JSON 转义坑 + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +ctx = os.environ.get('CONTEXT', '') +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '你是 Inalpha 仓库的 AI 助手。用户通过 @glm 触发你的回复。请用中文回答。'}, + {'role': 'user', 'content': ctx} + ], + 'temperature': 0.7, + 'max_tokens': 2048 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + exit 0 + fi + + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/glm_reply.txt + + - name: Post reply + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + REPLY=$(cat /tmp/glm_reply.txt) + if [ "$TYPE" = "issue" ]; then + gh issue comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + else + gh pr comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + fi \ No newline at end of file diff --git a/apps/dashboard/src/app/api/auth/login/route.ts b/apps/dashboard/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..80fcc1af --- /dev/null +++ b/apps/dashboard/src/app/api/auth/login/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; + +import { BackendError, backendFetch } from "@/lib/backend"; +import { + SESSION_COOKIE, + SESSION_COOKIE_OPTS, + SESSION_TTL_SEC, + createSessionToken, +} from "@/lib/session"; + +/** + * 登录:校验凭据 → 落 session cookie。 + * + * dashboard 无 DB 凭据,把邮箱 / 密码反代到内网 paper `/auth/login` 校验;成功后用 + * `JWT_SECRET` 签 httpOnly session cookie。密码只透传一次,不落任何日志。 + */ +export async function POST(req: Request): Promise { + let email: unknown; + let password: unknown; + try { + ({ email, password } = await req.json()); + } catch { + return NextResponse.json({ error: "请求体格式错误" }, { status: 400 }); + } + if (typeof email !== "string" || typeof password !== "string" || !email || !password) { + return NextResponse.json({ error: "缺少邮箱或密码" }, { status: 400 }); + } + + try { + const user = await backendFetch<{ subject: string; email: string; roles: string[] }>( + "paper", + "/auth/login", + { auth: false, method: "POST", body: { email, password } }, + ); + const token = await createSessionToken({ + subject: user.subject, + email: user.email, + roles: user.roles ?? [], + }); + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, token, { + ...SESSION_COOKIE_OPTS, + maxAge: SESSION_TTL_SEC, + }); + return res; + } catch (err) { + if (err instanceof BackendError && err.status === 401) { + return NextResponse.json({ error: "邮箱或密码不正确" }, { status: 401 }); + } + // 透传 paper 的失败节流(429),否则会被误报成"登录服务不可用"(502), + // 用户看不到"尝试过于频繁"的真实原因。 + if (err instanceof BackendError && err.status === 429) { + return NextResponse.json( + { error: "尝试过于频繁,请稍后再试" }, + { status: 429 }, + ); + } + return NextResponse.json({ error: "登录服务暂不可用,请稍后重试" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/app/api/auth/logout/route.ts b/apps/dashboard/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..c14cdd8d --- /dev/null +++ b/apps/dashboard/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +import { SESSION_COOKIE, SESSION_COOKIE_OPTS } from "@/lib/session"; + +/** 登出:清 session cookie。前端随后跳 /login。 */ +export async function POST(): Promise { + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", { ...SESSION_COOKIE_OPTS, maxAge: 0 }); + return res; +} diff --git a/apps/dashboard/src/app/api/auth/session/route.ts b/apps/dashboard/src/app/api/auth/session/route.ts new file mode 100644 index 00000000..9b9ce461 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/session/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { readSession } from "@/lib/session"; + +/** + * 当前登录用户(供侧栏显示 email + 登出按钮判存在)。未登录 / 未启用登录 → `{ user: null }`。 + * 不返回任何凭据。 + */ +export async function GET(): Promise { + const session = await readSession(); + return NextResponse.json({ + user: session ? { email: session.email, subject: session.subject } : null, + }); +} diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx new file mode 100644 index 00000000..6dc68c35 --- /dev/null +++ b/apps/dashboard/src/app/login/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { LoginForm } from "@/components/auth/LoginForm"; + +/** + * 登录页。刻意放在 `[locale]` 外壳之外 —— 不套控制台侧栏 / 对话栏 / 活动日志, + * 避免未登录时这些组件挂载后打 401。middleware 未登录时重定向到这里。 + */ +export const metadata = { + title: "Sign in · Inalpha", + robots: { index: false, follow: false }, +}; + +export default function LoginPage() { + return ( +
+ {/* useSearchParams 需要 Suspense 边界。 */} + + + +
+ ); +} diff --git a/apps/dashboard/src/components/auth/LoginForm.tsx b/apps/dashboard/src/components/auth/LoginForm.tsx new file mode 100644 index 00000000..80d36aac --- /dev/null +++ b/apps/dashboard/src/components/auth/LoginForm.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +/** + * 登录表单。登录页在 `[locale]` 外壳之外(不套控制台侧栏 / 对话栏 / intl provider), + * 故文案在此按 `navigator.language` 做最小中英切换,不依赖 next-intl。 + */ + +const STRINGS = { + en: { + title: "Operator Console", + subtitle: "Sign in to continue", + email: "Email", + password: "Password", + submit: "Sign in", + submitting: "Signing in…", + invalid: "Incorrect email or password", + rateLimited: "Too many attempts, try again later", + unavailable: "Login service unavailable, try again later", + }, + zh: { + title: "操作控制台", + subtitle: "登录以继续", + email: "邮箱", + password: "密码", + submit: "登录", + submitting: "登录中…", + invalid: "邮箱或密码不正确", + rateLimited: "尝试过于频繁,请稍后再试", + unavailable: "登录服务暂不可用,请稍后重试", + }, +}; + +function pickLang(): "en" | "zh" { + if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("zh")) { + return "zh"; + } + return "en"; +} + +export function LoginForm() { + const router = useRouter(); + const params = useSearchParams(); + const t = STRINGS[pickLang()]; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (res.ok) { + const from = params.get("from"); + // 只接受站内相对路径,防开放重定向。 + const dest = from && from.startsWith("/") && !from.startsWith("//") ? from : "/"; + router.replace(dest); + router.refresh(); + return; + } + setError( + res.status === 401 + ? t.invalid + : res.status === 429 + ? t.rateLimited + : t.unavailable, + ); + } catch { + setError(t.unavailable); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Inalpha +
+
Inalpha
+
+ {t.title} +
+
+
+ +

{t.subtitle}

+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatErrorBanner.tsx b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx new file mode 100644 index 00000000..5b297cb8 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { TriangleAlert, X } from "lucide-react"; + +/** + * Agent 错误横幅。 + * + * 上游 LLM 报错 / 流中断时在对话栏顶部显示红字提示, + * 用于区分"agent 在思考"和"真的出错了"。 + */ +export function ChatErrorBanner({ + error, + onDismiss, + dismissLabel, +}: { + error: string; + onDismiss: () => void; + dismissLabel: string; +}) { + return ( +
+ + {error} + +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx new file mode 100644 index 00000000..2500ad04 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { cn } from "@/lib/cn"; + +/** 历史会话摘要。 */ +export interface ThreadSummary { + id: string; + title: string | null; + updatedAt: string; +} + +/** + * 历史会话下拉面板。 + * + * 纯展示组件,状态由父组件(ChatThread)管理。 + */ +export function ChatHistoryPanel({ + open, + threads, + historyError, + currentThreadId, + sourceDownLabel, + untitledLabel, + onSwitch, + onReload, +}: { + open: boolean; + threads: ThreadSummary[] | null; + historyError: boolean; + currentThreadId: string; + sourceDownLabel: string; + untitledLabel: string; + onSwitch: (threadId: string) => void; + onReload: (threadId: string) => void; +}) { + const t = useTranslations("chat"); + + if (!open) return null; + + return ( +
+ {threads === null ? ( +

+ {t("loadingHistory")} +

+ ) : threads.length === 0 ? ( +

+ {t("historyEmpty")} +

+ ) : ( + threads.map((th) => ( + + )) + )} + {historyError && ( +

+ {sourceDownLabel} +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatInput.tsx b/apps/dashboard/src/components/chat/ChatInput.tsx new file mode 100644 index 00000000..f5265c74 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatInput.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { MapPin, SendHorizontal, Square, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { type KeyboardEvent, useCallback, useEffect, useRef } from "react"; + +import { cn } from "@/lib/cn"; + +/** + * 对话输入区域:输入框 + 发送/停止按钮 + 页面上下文胶囊。 + * + * 所有状态由父组件(ChatThread)管理,通过 props 传入。 + * + * 聚焦:`open` 由父组件传入——对话栏由 `translate-x` 滑入而非条件卸载, + * 所以"打开时聚焦输入框"必须由 open prop 变化驱动(组件挂载 effect 补不上)。 + */ +export function ChatInput({ + draft, + isLoading, + open, + contextAttached, + contextKind, + contextId, + onDraftChange, + onSubmit, + onStop, + onContextDismiss, +}: { + draft: string; + isLoading: boolean; + open: boolean; + contextAttached: boolean; + contextKind: string; + contextId?: string; + onDraftChange: (v: string) => void; + onSubmit: () => void; + onStop: () => void; + onContextDismiss: () => void; +}) { + const t = useTranslations("chat"); + const textareaRef = useRef(null); + + // 打开对话栏时聚焦输入框(Phase 3 拆分后从 ChatThread 迁移进来)。 + useEffect(() => { + if (open) textareaRef.current?.focus(); + }, [open]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }, + [onSubmit], + ); + + return ( +
+ {contextAttached && ( +
+ + + {t(`context.kind.${contextKind}`)} + + {contextId && ( + + {contextId.slice(0, 8)} + + )} + +
+ )} +
+