Add demo trading analyst with plan validation and sizing

The analyst prompts the model with trading rules, published skills,
account sizing inputs and prefetched market data, exposes read-only
tools plus submit_trade_plan, and validates and sizes the submitted plan
in pure functions covered by pytest. Manual analyses run as dry runs
while trading is stopped; the trading page shows the latest plan and an
analysis detail drawer.
This commit is contained in:
eezd committed 2026-10-15 10:40:29 +08:00
1 parent 7536c9c5de
commit 78ca4422af
16 files changed
+1465 -151

No files matched your search

+47 -128
View File
@@ -1,156 +1,75 @@
"""模拟盘交易进程:定时把账户、持仓、挂单快照写入 Redis,供页面读取。
"""模拟盘交易进程:同步账户快照、调度分析。
用法:python -m alphapulse.trading.engine
"""
import asyncio
import json
import logging
import re
import time
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy import select, update
from alphapulse.core.crypto import decrypt
from alphapulse.core.db import SessionLocal
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.models import TradingAccount, TradingSymbol
from alphapulse.trading.client import DemoClient, ExchangeError
from alphapulse.models import TradingAnalysis, TradingSymbol
from alphapulse.trading.analyst import run_analysis
from alphapulse.trading.snapshot import snapshot_loop
logger = logging.getLogger("alphapulse.trading.engine")
# 进程挂掉后快照自动过期,页面据此显示「引擎离线」
STATUS_KEY = f"{KEY_PREFIX}trader:status"
STATUS_TTL_SECONDS = 15
SNAPSHOT_SECONDS = 3
# 杠杆和保证金模式很少变,按币缓存
SYMBOL_CONFIG_SECONDS = 60
# 所有币合计每天(北京时间)最多自动分析的次数
DAILY_ANALYSIS_LIMIT = 60
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
ANALYSIS_POLL_SECONDS = 5
# 模型调用耗时长,并发过多会撞上游限流
MAX_CONCURRENT_ANALYSES = 2
class Snapshot:
def __init__(self) -> None:
self.client: DemoClient | None = None
self.key_ciphertext: bytes | None = None
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
self.data: dict | None = None
self.synced_at: datetime | None = None
self.symbol_config: dict[str, tuple[float, dict]] = {}
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
ciphertext = account.api_key_encrypted if account else None
if ciphertext != self.key_ciphertext:
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
if self.client is not None:
await self.client.http.aclose()
self.client = None
self.data = self.synced_at = None
self.symbol_config.clear()
self.key_ciphertext = ciphertext
if account is not None and ciphertext is not None:
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
return self.client
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
cached = self.symbol_config.get(symbol)
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
self.symbol_config[symbol] = cached
return cached[1]
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
account = await client.request("GET", "/fapi/v3/account")
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
orders, algo_orders = [], []
for symbol in watched:
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
return {
"account": {
"wallet_balance": account["totalWalletBalance"],
"margin_balance": account["totalMarginBalance"],
"available_balance": account["availableBalance"],
"unrealized_pnl": account["totalUnrealizedProfit"],
},
"positions": [
{
"symbol": p["symbol"],
"side": "LONG" if float(p["positionAmt"]) > 0 else "SHORT",
"amount": p["positionAmt"].lstrip("-"),
"entry_price": p["entryPrice"],
"break_even_price": p["breakEvenPrice"],
"mark_price": p["markPrice"],
"unrealized_pnl": p["unRealizedProfit"],
"liquidation_price": p["liquidationPrice"],
"notional": p["notional"].lstrip("-"),
"margin": p["isolatedMargin"] if float(p["isolatedMargin"]) else p["initialMargin"],
**await self.leverage_and_margin(client, p["symbol"]),
}
for p in positions
],
"orders": [
{
"symbol": o["symbol"], "client_id": o["clientOrderId"], "side": o["side"], "type": o["type"],
"price": o["price"], "quantity": o["origQty"], "filled": o["executedQty"], "reduce_only": o["reduceOnly"],
"time": o["time"],
}
for o in orders
],
"algo_orders": [
{
"symbol": o["symbol"], "client_id": o["clientAlgoId"], "side": o["side"], "type": o["orderType"],
"trigger_price": o["triggerPrice"], "quantity": o["quantity"], "close_position": o["closePosition"],
"reduce_only": o["reduceOnly"], "time": o["createTime"],
}
for o in algo_orders
],
}
async def publish(self) -> None:
async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
error = None
client = await self.ensure_client(account)
if client is None:
error = "还没有填写模拟盘 key"
else:
try:
self.data = await self.fetch(client, symbols)
self.synced_at = datetime.now(UTC)
except ExchangeError as e:
logger.warning("同步模拟盘失败:%s", e)
error = str(e)
status = {
"updated_at": datetime.now(UTC).isoformat(),
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
"error": error,
**(self.data or {"account": None, "positions": [], "orders": [], "algo_orders": []}),
}
await redis.set(STATUS_KEY, json.dumps(status), ex=STATUS_TTL_SECONDS)
async def fail_interrupted() -> None:
"""上次进程退出时还在进行的分析不会再有结果"""
async with SessionLocal() as session:
await session.execute(
update(TradingAnalysis).where(TradingAnalysis.status == "running")
.values(status="failed", error="引擎重启,分析中断", finished_at=datetime.now(UTC))
)
await session.commit()
async def snapshot_loop() -> None:
snapshot = Snapshot()
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
async with SessionLocal() as session:
symbols = (await session.scalars(
select(TradingSymbol)
.where(TradingSymbol.analysis_requested_at <= datetime.now(UTC), TradingSymbol.symbol.not_in(busy))
.order_by(TradingSymbol.analysis_requested_at).limit(limit)
)).all()
claimed = [(s.symbol, list(s.analysis_reasons)) for s in symbols]
for s in symbols:
s.analysis_requested_at = None
s.analysis_reasons = []
await session.commit()
return claimed
async def analysis_loop() -> None:
await fail_interrupted()
tasks: dict[str, asyncio.Task] = {}
while True:
try:
await snapshot.publish()
for symbol, task in list(tasks.items()):
if task.done():
del tasks[symbol]
if not task.cancelled() and task.exception():
logger.error("%s 分析任务异常退出", symbol, exc_info=task.exception())
free = MAX_CONCURRENT_ANALYSES - len(tasks)
if free > 0:
for symbol, reasons in await claim_requests(free, set(tasks)):
tasks[symbol] = asyncio.create_task(run_analysis(symbol, reasons))
except Exception:
logger.exception("发布快照失败,稍后重试")
await asyncio.sleep(SNAPSHOT_SECONDS)
logger.exception("调度分析出错,稍后重试")
await asyncio.sleep(ANALYSIS_POLL_SECONDS)
async def main() -> None:
# 分析器和执行器在后续里程碑加入同一个 TaskGroup
async with asyncio.TaskGroup() as tg:
tg.create_task(snapshot_loop())
tg.create_task(analysis_loop())
if __name__ == "__main__":