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.
302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""环境变量页:模型接入配置、交易所模拟盘 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, 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 encrypt
|
|
from alphapulse.core.db import get_session
|
|
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)])
|
|
|
|
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):
|
|
id: int
|
|
name: str
|
|
protocol: Protocol
|
|
base_url: str
|
|
api_key_hint: str
|
|
model: str
|
|
max_tokens: int
|
|
context_window: int
|
|
reasoning_effort: llm.Effort | None
|
|
is_default: bool
|
|
updated_at: datetime
|
|
|
|
|
|
class ProviderIn(BaseModel):
|
|
name: Name
|
|
protocol: Protocol
|
|
base_url: HttpUrl
|
|
api_key: Annotated[str, Field(min_length=1)]
|
|
model: Model
|
|
max_tokens: Annotated[int, Field(ge=256, le=128000)] = 8192
|
|
context_window: Annotated[int, Field(ge=4096, le=2000000)] = 200000
|
|
reasoning_effort: llm.Effort | None = None
|
|
is_default: bool = False
|
|
|
|
|
|
class ProviderPatch(BaseModel):
|
|
name: Name | None = None
|
|
protocol: Protocol | None = None
|
|
base_url: HttpUrl | None = None
|
|
# 为空表示不修改
|
|
api_key: str | None = None
|
|
model: Model | None = None
|
|
max_tokens: Annotated[int | None, Field(ge=256, le=128000)] = None
|
|
context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None
|
|
# 显式传 null 表示不传思考强度
|
|
reasoning_effort: llm.Effort | None = None
|
|
is_default: bool | None = None
|
|
|
|
|
|
class TestResult(BaseModel):
|
|
ok: bool
|
|
elapsed_ms: int
|
|
reply: str | None = None
|
|
error: str | None = None
|
|
|
|
|
|
def key_fields(api_key: str) -> dict:
|
|
api_key = api_key.strip()
|
|
return {"api_key_encrypted": encrypt(api_key), "api_key_hint": api_key[-4:]}
|
|
|
|
|
|
async def get_provider(session: AsyncSession, provider_id: int) -> AiProvider:
|
|
provider = await session.get(AiProvider, provider_id)
|
|
if provider is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型配置不存在")
|
|
return provider
|
|
|
|
|
|
async def commit_unique(session: AsyncSession) -> None:
|
|
try:
|
|
await session.commit()
|
|
except IntegrityError:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "名称已存在") from None
|
|
|
|
|
|
@router.get("/providers")
|
|
async def list_providers(session: Annotated[AsyncSession, Depends(get_session)]) -> list[ProviderOut]:
|
|
rows = await session.scalars(select(AiProvider).order_by(AiProvider.id))
|
|
return [ProviderOut.model_validate(p, from_attributes=True) for p in rows]
|
|
|
|
|
|
@router.post("/providers", status_code=status.HTTP_201_CREATED)
|
|
async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
|
|
# 第一套配置自动成为默认
|
|
is_default = body.is_default or await session.scalar(select(AiProvider.id).limit(1)) is None
|
|
if is_default:
|
|
await session.execute(update(AiProvider).values(is_default=False))
|
|
provider = AiProvider(
|
|
**body.model_dump(exclude={"api_key", "base_url", "is_default"}),
|
|
base_url=str(body.base_url), is_default=is_default, **key_fields(body.api_key),
|
|
)
|
|
session.add(provider)
|
|
await commit_unique(session)
|
|
return ProviderOut.model_validate(provider, from_attributes=True)
|
|
|
|
|
|
@router.patch("/providers/{provider_id}")
|
|
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
|
|
provider = await get_provider(session, provider_id)
|
|
values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"})
|
|
# reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略
|
|
for field, value in values.items():
|
|
if value is not None or field == "reasoning_effort":
|
|
setattr(provider, field, value)
|
|
if body.base_url is not None:
|
|
provider.base_url = str(body.base_url)
|
|
if body.api_key and body.api_key.strip():
|
|
for field, value in key_fields(body.api_key).items():
|
|
setattr(provider, field, value)
|
|
if body.is_default:
|
|
await session.execute(update(AiProvider).where(AiProvider.id != provider_id).values(is_default=False))
|
|
provider.is_default = True
|
|
await commit_unique(session)
|
|
await session.refresh(provider)
|
|
return ProviderOut.model_validate(provider, from_attributes=True)
|
|
|
|
|
|
@router.delete("/providers/{provider_id}")
|
|
async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
|
provider = await get_provider(session, provider_id)
|
|
await session.delete(provider)
|
|
await session.flush()
|
|
if provider.is_default:
|
|
# 删掉默认配置后,让最早的一套成为默认,避免 Chat 没有可用配置
|
|
first = await session.scalar(select(AiProvider).order_by(AiProvider.id).limit(1))
|
|
if first:
|
|
first.is_default = True
|
|
await session.commit()
|
|
|
|
|
|
@router.post("/providers/{provider_id}/test")
|
|
async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> TestResult:
|
|
provider = await get_provider(session, provider_id)
|
|
started = time.monotonic()
|
|
try:
|
|
completion = None
|
|
async for event in llm.stream(provider, "只回复 OK。", [{"role": "user", "text": "ping"}], [], provider.reasoning_effort):
|
|
if isinstance(event, llm.Completion):
|
|
completion = event
|
|
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])
|
|
|
|
|
|
# ---------- 模拟盘 ----------
|
|
|
|
ExchangeName = Literal["binance", "bybit"]
|
|
|
|
|
|
class ExchangeOut(BaseModel):
|
|
exchange: ExchangeName
|
|
configured_exchanges: list[ExchangeName]
|
|
configured: bool
|
|
api_key_hint: str | None
|
|
running: bool
|
|
updated_at: datetime | None
|
|
|
|
|
|
class ExchangeIn(BaseModel):
|
|
# key 和 secret 成对生效,修改时两个都要填
|
|
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):
|
|
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
|
|
|
|
|
|
async def any_running(session: AsyncSession) -> bool:
|
|
return bool(await session.scalar(select(func.count()).where(TradingSymbol.running)))
|
|
|
|
|
|
async def exchange_out(session: AsyncSession, account: TradingAccount | None) -> ExchangeOut:
|
|
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(
|
|
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")
|
|
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")
|
|
async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut:
|
|
return await exchange_out(session, 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)
|
|
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()
|
|
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)
|
|
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()
|
|
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)
|
|
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 create_client(credential) as client:
|
|
info = await client.account()
|
|
dual = await client.position_mode()
|
|
except ExchangeError as e:
|
|
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=dual,
|
|
)
|