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:
1 parent
7536c9c5de
commit
78ca4422af
16 files changed
+1465
-151
No files matched your search
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user