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
@@ -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')
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""环境变量页:模型接入配置。api_key 只写不读,接口只返回末 4 位"""
|
"""环境变量页:模型接入配置、币安模拟盘 key。密钥只写不读,接口只返回末 4 位"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,9 +12,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from alphapulse.ai import llm
|
from alphapulse.ai import llm
|
||||||
from alphapulse.core.auth import require_admin
|
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.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)])
|
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ Protocol = Literal["openai", "anthropic"]
|
|||||||
# 从页面粘贴时容易带上首尾空格,模型名带空格会被上游当成另一个模型(404)
|
# 从页面粘贴时容易带上首尾空格,模型名带空格会被上游当成另一个模型(404)
|
||||||
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64)]
|
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)]
|
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):
|
class ProviderOut(BaseModel):
|
||||||
@@ -157,3 +159,91 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
|
|||||||
except llm.LlmError as e:
|
except llm.LlmError as e:
|
||||||
return TestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=str(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])
|
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.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
|
||||||
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
|
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
|
||||||
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
|
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
|
||||||
|
from alphapulse.models.trading import TradingAccount
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AiProvider",
|
"AiProvider",
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"SkillNode",
|
"SkillNode",
|
||||||
"SkillVersion",
|
"SkillVersion",
|
||||||
"Source",
|
"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")
|
||||||
@@ -63,3 +63,37 @@ export function testProvider(id: number): Promise<TestResult> {
|
|||||||
// 模型接口读超时 600s 且 SDK 会重试 2 次,前端放宽等待时间
|
// 模型接口读超时 600s 且 SDK 会重试 2 次,前端放宽等待时间
|
||||||
return http.post(`/settings/providers/${id}/test`, null, { timeout: 1900000 })
|
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 })
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Provider, ProviderForm } from "@/apis/settings"
|
import type { Exchange, Provider, ProviderForm } from "@/apis/settings"
|
||||||
import { createProvider, deleteProvider, EFFORTS, listProviders, patchProvider, testProvider } from "@/apis/settings"
|
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
|
||||||
import { formatTime } from "@/utils/text"
|
import { formatTime } from "@/utils/text"
|
||||||
|
|
||||||
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
|
const PROTOCOL_LABELS = { openai: "OpenAI 格式(/v1/chat/completions)", anthropic: "Claude 格式(/v1/messages)" } as const
|
||||||
@@ -76,6 +76,57 @@ async function test(p: Provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
load()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -193,5 +244,59 @@ load()
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
Reference in new issue
Block a user