Support selectable demo exchanges and configurable analysis limits

Persist manual requests until exchange confirmation, preserve stop protection, and reconcile fills before settling plans. Tighten model stream, crawler, skill, research accounting, and frontend race handling, with regression coverage. Keep queued analyses pending until a fresh account snapshot is available.
This commit is contained in:
eezd committed 2026-10-20 18:45:00 +08:00
1 parent fcaedfb358
commit 285b3952d7
33 files changed
+1890 -325

No files matched your search

+77 -29
View File
@@ -1,21 +1,24 @@
"""环境变量页:模型接入配置、币安模拟盘 key。密钥只写不读,接口只返回末 4 位"""
"""环境变量页:模型接入配置、交易所模拟盘 key。密钥只写不读,接口只返回末 4 位"""
import time
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, HttpUrl, StringConstraints
from pydantic import BaseModel, Field, HttpUrl, StringConstraints, model_validator
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai import llm
from alphapulse.core.auth import require_admin
from alphapulse.core.crypto import decrypt, encrypt
from alphapulse.core.crypto import encrypt
from alphapulse.core.db import get_session
from alphapulse.models import AiProvider, TradingAccount, TradingSymbol
from alphapulse.trading.client import DemoClient, ExchangeError
from alphapulse.core.redis import redis
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingCredential, TradingSymbol
from alphapulse.trading.client import ExchangeError, create_client
from alphapulse.trading.snapshot import STATUS_KEY
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
@@ -161,9 +164,14 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
return TestResult(ok=True, elapsed_ms=round((time.monotonic() - started) * 1000), reply=completion.text[:200])
# ---------- 币安模拟盘 ----------
# ---------- 模拟盘 ----------
ExchangeName = Literal["binance", "bybit"]
class ExchangeOut(BaseModel):
exchange: ExchangeName
configured_exchanges: list[ExchangeName]
configured: bool
api_key_hint: str | None
running: bool
@@ -172,8 +180,17 @@ class ExchangeOut(BaseModel):
class ExchangeIn(BaseModel):
# key 和 secret 成对生效,修改时两个都要填
api_key: ApiCredential
api_secret: ApiCredential
exchange: ExchangeName | None = None
api_key: ApiCredential | None = None
api_secret: ApiCredential | None = None
@model_validator(mode="after")
def paired_credentials(self):
if (self.api_key is None) != (self.api_secret is None):
raise ValueError("API Key 和 Secret Key 必须同时填写")
if self.exchange is None and self.api_key is None:
raise ValueError("请选择交易所或填写凭证")
return self
class ExchangeTestResult(BaseModel):
@@ -191,19 +208,35 @@ async def any_running(session: AsyncSession) -> bool:
async def exchange_out(session: AsyncSession, account: TradingAccount | None) -> ExchangeOut:
if account is None:
return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None)
exchange = account.exchange if account else "binance"
credential = await session.get(TradingCredential, exchange)
configured = list(await session.scalars(select(TradingCredential.exchange).where(TradingCredential.api_key_encrypted.is_not(None))))
return ExchangeOut(
configured=account.api_key_encrypted is not None, api_key_hint=account.api_key_hint,
running=await any_running(session), updated_at=account.updated_at,
exchange=exchange, configured_exchanges=configured,
configured=bool(credential and credential.api_key_encrypted), api_key_hint=credential.api_key_hint if credential else None,
running=await any_running(session), updated_at=credential.updated_at if credential else None,
)
async def editable_account(session: AsyncSession) -> TradingAccount:
account = await session.get(TradingAccount, 1)
if await any_running(session):
raise HTTPException(status.HTTP_409_CONFLICT, "有币种正在交易,请先全部停止再修改 key")
return account or TradingAccount(id=1)
raise HTTPException(status.HTTP_409_CONFLICT, "有币种正在交易,请先全部停止再修改交易所或 key")
if await session.scalar(select(func.count()).where(TradingAnalysis.status == "running")):
raise HTTPException(status.HTTP_409_CONFLICT, "有分析正在进行,请等待完成后再修改交易所或 key")
return account or TradingAccount(id=1, exchange="binance")
async def ensure_no_positions(session: AsyncSession, exchange: str) -> None:
credential = await session.get(TradingCredential, exchange)
if credential and credential.api_key_encrypted:
try:
async with create_client(credential) as client:
positions = await client.positions()
except ExchangeError as e:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"无法确认当前交易所持仓,请稍后再试:{e}") from None
if any(Decimal(p["positionAmt"]) != 0 for p in positions):
raise HTTPException(status.HTTP_409_CONFLICT, "当前模拟账户仍有持仓,请先平仓再切换交易所或修改 key")
@router.get("/exchange")
@@ -214,40 +247,55 @@ async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -
@router.put("/exchange")
async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
account = await editable_account(session)
account.api_key_encrypted = encrypt(body.api_key)
account.api_secret_encrypted = encrypt(body.api_secret)
account.api_key_hint = body.api_key[-4:]
await ensure_no_positions(session, account.exchange)
exchange = body.exchange or account.exchange
credential = None
if body.api_key is not None:
credential = await session.get(TradingCredential, exchange) or TradingCredential(exchange=exchange)
credential.api_key_encrypted = encrypt(body.api_key)
credential.api_secret_encrypted = encrypt(body.api_secret)
credential.api_key_hint = body.api_key[-4:]
session.add(credential)
account.exchange = exchange
session.add(account)
# 停止状态下仍可能排有手动分析;切换后不能把旧请求交给另一家交易所。
await session.execute(update(TradingSymbol).values(analysis_requested_at=None, analysis_reasons=[]))
await session.commit()
await session.refresh(account)
if credential is not None:
await session.refresh(credential)
await redis.delete(STATUS_KEY)
return await exchange_out(session, account)
@router.delete("/exchange")
async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
account = await editable_account(session)
account.api_key_encrypted = account.api_secret_encrypted = account.api_key_hint = None
session.add(account)
await ensure_no_positions(session, account.exchange)
credential = await session.get(TradingCredential, account.exchange)
if credential:
credential.api_key_encrypted = credential.api_secret_encrypted = credential.api_key_hint = None
await session.commit()
await session.refresh(account)
if credential is not None:
await session.refresh(credential)
await redis.delete(STATUS_KEY)
return await exchange_out(session, account)
@router.post("/exchange/test")
async def test_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeTestResult:
account = await session.get(TradingAccount, 1)
if account is None or account.api_key_encrypted is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key")
credential = await session.get(TradingCredential, account.exchange) if account else None
if credential is None or credential.api_key_encrypted is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写当前交易所的模拟盘 key")
started = time.monotonic()
try:
async with DemoClient(decrypt(account.api_key_encrypted), decrypt(account.api_secret_encrypted)) as client:
info = await client.request("GET", "/fapi/v3/account")
mode = await client.request("GET", "/fapi/v1/positionSide/dual")
async with create_client(credential) as client:
info = await client.account()
dual = await client.position_mode()
except ExchangeError as e:
hint = "(key 须在币安模拟交易里创建,实盘 key 不能用;或未开启合约权限、服务器 IP 不在白名单)" if e.code == -2015 else ""
hint = "(请在所选交易所的模拟交易账户创建 key,确认合约权限及 IP 白名单;Bybit Testnet 和实盘 key 均不可用)" if e.code in (-2015, 10003, 10005, 10010) else ""
return ExchangeTestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=f"{e}{hint}")
return ExchangeTestResult(
ok=True, elapsed_ms=round((time.monotonic() - started) * 1000),
available_usdt=info["availableBalance"], wallet_usdt=info["totalWalletBalance"],
dual_side_position=mode["dualSidePosition"],
available_usdt=info["availableBalance"], wallet_usdt=info["totalWalletBalance"], dual_side_position=dual,
)