diff --git a/backend/alembic/versions/20261015_a7d3e9c41f02_trading_account.py b/backend/alembic/versions/20261015_a7d3e9c41f02_trading_account.py new file mode 100644 index 0000000..b81e4a6 --- /dev/null +++ b/backend/alembic/versions/20261015_a7d3e9c41f02_trading_account.py @@ -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') diff --git a/backend/src/alphapulse/api/settings.py b/backend/src/alphapulse/api/settings.py index 836d558..a2ce67d 100644 --- a/backend/src/alphapulse/api/settings.py +++ b/backend/src/alphapulse/api/settings.py @@ -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"], + ) diff --git a/backend/src/alphapulse/models/__init__.py b/backend/src/alphapulse/models/__init__.py index 1106e16..854b95b 100644 --- a/backend/src/alphapulse/models/__init__.py +++ b/backend/src/alphapulse/models/__init__.py @@ -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", ] diff --git a/backend/src/alphapulse/models/trading.py b/backend/src/alphapulse/models/trading.py new file mode 100644 index 0000000..68ee0d9 --- /dev/null +++ b/backend/src/alphapulse/models/trading.py @@ -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()) diff --git a/backend/src/alphapulse/trading/__init__.py b/backend/src/alphapulse/trading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/alphapulse/trading/client.py b/backend/src/alphapulse/trading/client.py new file mode 100644 index 0000000..ea3ec23 --- /dev/null +++ b/backend/src/alphapulse/trading/client.py @@ -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") diff --git a/web/src/apis/settings.ts b/web/src/apis/settings.ts index bf61e9e..2224471 100644 --- a/web/src/apis/settings.ts +++ b/web/src/apis/settings.ts @@ -63,3 +63,37 @@ export function testProvider(id: number): Promise { // 模型接口读超时 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 { + return http.get("/settings/exchange") +} + +export function putExchange(body: { api_key: string, api_secret: string }): Promise { + return http.put("/settings/exchange", body) +} + +export function deleteExchange(): Promise { + return http.delete("/settings/exchange") +} + +export function testExchange(): Promise { + // 后端串行请求两个接口,单次超时 10s + return http.post("/settings/exchange/test", null, { timeout: 30000 }) +} diff --git a/web/src/pages/settings/index.vue b/web/src/pages/settings/index.vue index 242aba6..49ac3ec 100644 --- a/web/src/pages/settings/index.vue +++ b/web/src/pages/settings/index.vue @@ -1,6 +1,6 @@ + +
+
+ 币安模拟盘 + 只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限 +
+
+ + 测试连接 + + + {{ exchange?.configured ? "修改 key" : "填写 key" }} + + + 删除 + +
+
+ + + {{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }} + + + + 交易中(停止后才能修改 key) + + 未运行 + + + {{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }} + + + + + + + + + + +
+ 两项都要填;加密保存,之后不再显示 +
+
+
+ +