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:
eezd committed 2026-10-15 09:24:58 +08:00
1 parent 438d6a00d9
commit 20fd5a98b2
8 files changed
+384 -5

No files matched your search

@@ -0,0 +1,41 @@
"""trading account
Revision ID: a7d3e9c41f02
Revises: 5c2e8f1a7b3d
Create Date: 2026-10-15 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a7d3e9c41f02'
down_revision: Union[str, Sequence[str], None] = '5c2e8f1a7b3d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table('trading_account',
sa.Column('id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('api_key_encrypted', sa.LargeBinary(), nullable=True),
sa.Column('api_secret_encrypted', sa.LargeBinary(), nullable=True),
sa.Column('api_key_hint', sa.String(length=8), nullable=True),
sa.Column('running', sa.Boolean(), nullable=False),
sa.Column('running_changed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('provider_id', sa.Integer(), nullable=True),
sa.Column('reasoning_effort', sa.String(length=8), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint('id = 1', name='trading_account_single_row'),
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('trading_account')
+93 -3
View File
@@ -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",
]
+24
View File
@@ -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.
+83
View File
@@ -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")
+34
View File
@@ -63,3 +63,37 @@ export function testProvider(id: number): Promise<TestResult> {
// 模型接口读超时 600s 且 SDK 会重试 2 次,前端放宽等待时间
return http.post(`/settings/providers/${id}/test`, null, { timeout: 1900000 })
}
export interface Exchange {
configured: boolean
api_key_hint: string | null
running: boolean
updated_at: string | null
}
export interface ExchangeTestResult {
ok: boolean
elapsed_ms: number
available_usdt: string | null
wallet_usdt: string | null
/** 双向持仓模式;引擎使用单向持仓,启动时会切换 */
dual_side_position: boolean | null
error: string | null
}
export function getExchange(): Promise<Exchange> {
return http.get("/settings/exchange")
}
export function putExchange(body: { api_key: string, api_secret: string }): Promise<Exchange> {
return http.put("/settings/exchange", body)
}
export function deleteExchange(): Promise<Exchange> {
return http.delete("/settings/exchange")
}
export function testExchange(): Promise<ExchangeTestResult> {
// 后端串行请求两个接口,单次超时 10s
return http.post("/settings/exchange/test", null, { timeout: 30000 })
}
+107 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { Provider, ProviderForm } from "@/apis/settings"
import { createProvider, deleteProvider, EFFORTS, listProviders, patchProvider, testProvider } from "@/apis/settings"
import type { Exchange, Provider, ProviderForm } from "@/apis/settings"
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
import { formatTime } from "@/utils/text"
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
@@ -76,6 +76,57 @@ async function test(p: Provider) {
}
load()
// ---------- 币安模拟盘 ----------
const exchange = ref<Exchange>()
const exchangeDialogVisible = ref(false)
const exchangeForm = ref({ api_key: "", api_secret: "" })
const exchangeSaving = ref(false)
const exchangeTesting = ref(false)
async function loadExchange() {
exchange.value = await getExchange()
}
function openExchangeEdit() {
exchangeForm.value = { api_key: "", api_secret: "" }
exchangeDialogVisible.value = true
}
async function saveExchange() {
exchangeSaving.value = true
try {
exchange.value = await putExchange(exchangeForm.value)
exchangeDialogVisible.value = false
ElMessage.success("已保存")
} finally {
exchangeSaving.value = false
}
}
async function removeExchange() {
await ElMessageBox.confirm("删除模拟盘 API key?", "删除确认", { type: "warning" })
exchange.value = await deleteExchange()
ElMessage.success("已删除")
}
async function runExchangeTest() {
exchangeTesting.value = true
try {
const r = await testExchange()
if (r.ok) {
const mode = r.dual_side_position ? "双向持仓(开始交易时会切换为单向)" : "单向持仓"
ElMessage.success({ message: `连接成功,用时 ${r.elapsed_ms}ms;可用 ${r.available_usdt} USDT,钱包余额 ${r.wallet_usdt} USDT;${mode}`, duration: 8000 })
} else {
ElMessage.error({ message: `连接失败(${r.elapsed_ms}ms):${r.error}`, duration: 8000 })
}
} finally {
exchangeTesting.value = false
}
}
loadExchange()
</script>
<template>
@@ -193,5 +244,59 @@ load()
</el-button>
</template>
</el-dialog>
<div class="mt-4 flex items-center justify-between">
<div>
<span class="text-lg font-bold">币安模拟盘</span>
<span class="ml-3 text-xs text-[var(--el-text-color-secondary)]">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="exchange?.running" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
交易中(停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
<el-dialog v-model="exchangeDialogVisible" title="币安模拟盘 API key" width="560px">
<el-form :model="exchangeForm" label-width="100px">
<el-form-item label="API Key" required>
<el-input v-model="exchangeForm.api_key" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item label="Secret Key" required>
<el-input v-model="exchangeForm.api_secret" type="password" show-password autocomplete="new-password" />
<div class="text-xs text-[var(--el-text-color-secondary)]">
两项都要填;加密保存,之后不再显示
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="exchangeDialogVisible = false">
取消
</el-button>
<el-button type="primary" :loading="exchangeSaving" :disabled="!exchangeForm.api_key.trim() || !exchangeForm.api_secret.trim()" @click="saveExchange">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>