Add Binance demo trading key settings and signed demo client
Store demo API key/secret encrypted in a single-row trading_account table, expose get/put/delete/test endpoints under /api/settings/exchange, and add a settings card to enter and test the key. The client is hard-wired to demo-fapi.binance.com.
This commit is contained in:
1 parent
438d6a00d9
commit
20fd5a98b2
8 files changed
+384
-5
No files matched your search
@@ -1,4 +1,4 @@
|
||||
"""环境变量页:模型接入配置。api_key 只写不读,接口只返回末 4 位"""
|
||||
"""环境变量页:模型接入配置、币安模拟盘 key。密钥只写不读,接口只返回末 4 位"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -12,9 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.ai import llm
|
||||
from alphapulse.core.auth import require_admin
|
||||
from alphapulse.core.crypto import encrypt
|
||||
from alphapulse.core.crypto import decrypt, encrypt
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.models import AiProvider
|
||||
from alphapulse.models import AiProvider, TradingAccount
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
|
||||
|
||||
@@ -22,6 +23,7 @@ Protocol = Literal["openai", "anthropic"]
|
||||
# 从页面粘贴时容易带上首尾空格,模型名带空格会被上游当成另一个模型(404)
|
||||
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64)]
|
||||
Model = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)]
|
||||
ApiCredential = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)]
|
||||
|
||||
|
||||
class ProviderOut(BaseModel):
|
||||
@@ -157,3 +159,91 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
|
||||
except llm.LlmError as e:
|
||||
return TestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=str(e))
|
||||
return TestResult(ok=True, elapsed_ms=round((time.monotonic() - started) * 1000), reply=completion.text[:200])
|
||||
|
||||
|
||||
# ---------- 币安模拟盘 ----------
|
||||
|
||||
class ExchangeOut(BaseModel):
|
||||
configured: bool
|
||||
api_key_hint: str | None
|
||||
running: bool
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class ExchangeIn(BaseModel):
|
||||
# key 和 secret 成对生效,修改时两个都要填
|
||||
api_key: ApiCredential
|
||||
api_secret: ApiCredential
|
||||
|
||||
|
||||
class ExchangeTestResult(BaseModel):
|
||||
ok: bool
|
||||
elapsed_ms: int
|
||||
available_usdt: str | None = None
|
||||
wallet_usdt: str | None = None
|
||||
# 双向持仓模式;引擎使用单向持仓,启动时会切换
|
||||
dual_side_position: bool | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def exchange_out(account: TradingAccount | None) -> ExchangeOut:
|
||||
if account is None:
|
||||
return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None)
|
||||
return ExchangeOut(
|
||||
configured=account.api_key_encrypted is not None, api_key_hint=account.api_key_hint,
|
||||
running=account.running, updated_at=account.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def editable_account(session: AsyncSession) -> TradingAccount:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
if account is not None and account.running:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "模拟盘正在交易,请先停止再修改 key")
|
||||
return account or TradingAccount(id=1)
|
||||
|
||||
|
||||
@router.get("/exchange")
|
||||
async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
|
||||
return exchange_out(await session.get(TradingAccount, 1))
|
||||
|
||||
|
||||
@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:]
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
return exchange_out(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 session.commit()
|
||||
await session.refresh(account)
|
||||
return exchange_out(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")
|
||||
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")
|
||||
except ExchangeError as e:
|
||||
hint = "(key 无效、未开启合约交易权限,或限制了 IP)" if e.code == -2015 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"],
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from alphapulse.core.db import Base
|
||||
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
|
||||
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
|
||||
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
|
||||
from alphapulse.models.trading import TradingAccount
|
||||
|
||||
__all__ = [
|
||||
"AiProvider",
|
||||
@@ -21,4 +22,5 @@ __all__ = [
|
||||
"SkillNode",
|
||||
"SkillVersion",
|
||||
"Source",
|
||||
"TradingAccount",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, LargeBinary, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from alphapulse.core.db import Base
|
||||
|
||||
|
||||
class TradingAccount(Base):
|
||||
"""币安模拟盘账户:单行(id=1)。key 只以密文存储;running 是引擎启停的唯一来源"""
|
||||
|
||||
__tablename__ = "trading_account"
|
||||
__table_args__ = (CheckConstraint("id = 1", name="trading_account_single_row"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||
api_key_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary)
|
||||
api_secret_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary)
|
||||
api_key_hint: Mapped[str | None] = mapped_column(String(8))
|
||||
running: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
running_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# 交易分析用的模型配置;为空时用默认配置
|
||||
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
Whitespace-only changes.
@@ -0,0 +1,83 @@
|
||||
"""币安 U 本位合约模拟盘(Demo Trading)签名客户端
|
||||
|
||||
只连模拟盘:地址写死,没有切换实盘的参数,避免误下真单。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = "https://demo-fapi.binance.com"
|
||||
RECV_WINDOW_MS = 5000
|
||||
TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
class ExchangeError(Exception):
|
||||
"""交易所或网络错误;code 为币安错误码(如 -2021),网络层错误时 status、code 为空"""
|
||||
|
||||
def __init__(self, status: int | None, code: int | None, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
# 只有限流、服务端错误、-1001(内部断连)可以重试;业务错误重试也不会成功
|
||||
return self.status in (418, 429) or (self.status or 0) >= 500 or self.code == -1001
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.status is None:
|
||||
return self.message
|
||||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||||
|
||||
|
||||
class DemoClient:
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.secret = api_secret.encode()
|
||||
self.http = httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS, headers={"X-MBX-APIKEY": api_key})
|
||||
# 本地时钟与服务器的偏差(毫秒),遇到 -1021 时校准
|
||||
self.time_offset = 0
|
||||
|
||||
async def __aenter__(self) -> "DemoClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.http.aclose()
|
||||
|
||||
async def sync_time(self) -> None:
|
||||
server_time = (await self.request("GET", "/fapi/v1/time", signed=False))["serverTime"]
|
||||
self.time_offset = server_time - int(time.time() * 1000)
|
||||
|
||||
async def request(self, method: str, path: str, params: dict[str, Any] | None = None, signed: bool = True) -> Any:
|
||||
"""不自动重试:下单类请求重发可能重复成交,由调用方按 clientOrderId 查询后决定"""
|
||||
for attempt in range(2):
|
||||
# 币安要求布尔参数为小写字符串
|
||||
query = {k: str(v).lower() if isinstance(v, bool) else v for k, v in (params or {}).items() if v is not None}
|
||||
if signed:
|
||||
query["timestamp"] = int(time.time() * 1000) + self.time_offset
|
||||
query["recvWindow"] = RECV_WINDOW_MS
|
||||
encoded = urlencode(query)
|
||||
if signed:
|
||||
encoded += "&signature=" + hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||||
try:
|
||||
resp = await self.http.request(method, f"{path}?{encoded}" if encoded else path)
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, f"无法连接币安模拟盘:{e!r}") from e
|
||||
if resp.status_code < 400:
|
||||
return resp.json()
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
code = body.get("code") if isinstance(body, dict) else None
|
||||
# 时间戳超出接收窗口时请求未被受理,校准时钟后重发是安全的
|
||||
if code == -1021 and attempt == 0:
|
||||
await self.sync_time()
|
||||
continue
|
||||
raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200])
|
||||
raise AssertionError("unreachable")
|
||||
Reference in new issue
Block a user