Add demo trading page, trader snapshot service and trading tables
Add trading_symbols, trading_analyses, trading_orders and trading_events. The new trader service publishes account, position and open-order snapshots of the Binance demo account to Redis every 3s. The /trading page shows engine status, start/stop, balances, positions, the symbol list with angle selection, open orders and the event timeline.
This commit is contained in:
1 parent
154fe2b687
commit
005943a54c
12 files changed
+1289
-3
No files matched your search
@@ -36,6 +36,18 @@ class ExchangeError(Exception):
|
||||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""模拟盘公开接口(exchangeInfo 等),不需要 key"""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as http:
|
||||
try:
|
||||
resp = await http.get(path, params=params)
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, f"无法连接币安模拟盘:{e!r}") from e
|
||||
if resp.status_code >= 400:
|
||||
raise ExchangeError(resp.status_code, None, resp.text[:200])
|
||||
return resp.json()
|
||||
|
||||
|
||||
class DemoClient:
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.secret = api_secret.encode()
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""模拟盘交易进程:定时把账户、持仓、挂单快照写入 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 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
|
||||
|
||||
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+$")
|
||||
|
||||
|
||||
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 snapshot_loop() -> None:
|
||||
snapshot = Snapshot()
|
||||
while True:
|
||||
try:
|
||||
await snapshot.publish()
|
||||
except Exception:
|
||||
logger.exception("发布快照失败,稍后重试")
|
||||
await asyncio.sleep(SNAPSHOT_SECONDS)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 分析器和执行器在后续里程碑加入同一个 TaskGroup
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(snapshot_loop())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
asyncio.run(main())
|
||||
Reference in new issue
Block a user