Support selectable demo exchanges and configurable analysis limits
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.
This commit is contained in:
1 parent
fcaedfb358
commit
285b3952d7
33 files changed
+1890
-325
No files matched your search
@@ -0,0 +1,67 @@
|
||||
"""模拟盘支持按交易所隔离凭证、计划、订单和成交。
|
||||
|
||||
Revision ID: b8d1f4a92c60
|
||||
Revises: c5e8a1b3d207
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "b8d1f4a92c60"
|
||||
down_revision = "c5e8a1b3d207"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("trading_account", sa.Column("exchange", sa.String(16), server_default="binance", nullable=False))
|
||||
op.create_table(
|
||||
"trading_credentials",
|
||||
sa.Column("exchange", sa.String(16), primary_key=True),
|
||||
sa.Column("api_key_encrypted", sa.LargeBinary()),
|
||||
sa.Column("api_secret_encrypted", sa.LargeBinary()),
|
||||
sa.Column("api_key_hint", sa.String(8)),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.execute("""INSERT INTO trading_credentials
|
||||
(exchange, api_key_encrypted, api_secret_encrypted, api_key_hint, updated_at)
|
||||
SELECT 'binance', api_key_encrypted, api_secret_encrypted, api_key_hint, updated_at FROM trading_account""")
|
||||
for column in ("api_key_encrypted", "api_secret_encrypted", "api_key_hint"):
|
||||
op.drop_column("trading_account", column)
|
||||
for table in ("trading_analyses", "trading_orders", "trading_events", "trading_fills"):
|
||||
op.add_column(table, sa.Column("exchange", sa.String(16), server_default="binance", nullable=False))
|
||||
if table != "trading_fills":
|
||||
op.create_index(f"ix_{table}_exchange", table, ["exchange"])
|
||||
op.alter_column("trading_orders", "exchange_id", type_=sa.String(64), existing_type=sa.String(32))
|
||||
for column in ("trade_id", "order_id"):
|
||||
op.alter_column("trading_fills", column, type_=sa.String(64), existing_type=sa.BigInteger(), postgresql_using=f"{column}::text")
|
||||
op.drop_constraint("trading_fills_pkey", "trading_fills", type_="primary")
|
||||
op.create_primary_key("trading_fills_pkey", "trading_fills", ["exchange", "symbol", "trade_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Bybit ID 无法转回整数;已有 Bybit 数据时明确拒绝回退,避免丢失历史。
|
||||
op.execute("""DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM trading_fills WHERE exchange != 'binance')
|
||||
OR EXISTS (SELECT 1 FROM trading_orders WHERE exchange != 'binance')
|
||||
OR EXISTS (SELECT 1 FROM trading_analyses WHERE exchange != 'binance')
|
||||
OR EXISTS (SELECT 1 FROM trading_credentials WHERE exchange != 'binance') THEN
|
||||
RAISE EXCEPTION '已有其他交易所数据,不能回退模拟盘迁移';
|
||||
END IF;
|
||||
END $$""")
|
||||
op.drop_constraint("trading_fills_pkey", "trading_fills", type_="primary")
|
||||
for column in ("trade_id", "order_id"):
|
||||
op.alter_column("trading_fills", column, type_=sa.BigInteger(), postgresql_using=f"{column}::bigint")
|
||||
op.create_primary_key("trading_fills_pkey", "trading_fills", ["symbol", "trade_id"])
|
||||
op.alter_column("trading_orders", "exchange_id", type_=sa.String(32))
|
||||
for table in ("trading_analyses", "trading_orders", "trading_events", "trading_fills"):
|
||||
if table != "trading_fills":
|
||||
op.drop_index(f"ix_{table}_exchange", table_name=table)
|
||||
op.drop_column(table, "exchange")
|
||||
for column, kind in (("api_key_encrypted", sa.LargeBinary()), ("api_secret_encrypted", sa.LargeBinary()), ("api_key_hint", sa.String(8))):
|
||||
op.add_column("trading_account", sa.Column(column, kind))
|
||||
op.execute("""UPDATE trading_account SET api_key_encrypted = c.api_key_encrypted,
|
||||
api_secret_encrypted = c.api_secret_encrypted, api_key_hint = c.api_key_hint
|
||||
FROM trading_credentials c WHERE c.exchange = 'binance'""")
|
||||
op.drop_table("trading_credentials")
|
||||
op.drop_column("trading_account", "exchange")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""每日分析上限改为用户配置。
|
||||
|
||||
Revision ID: d2a4c6e8f103
|
||||
Revises: b8d1f4a92c60
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "d2a4c6e8f103"
|
||||
down_revision = "b8d1f4a92c60"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("trading_account", sa.Column("daily_analysis_limit", sa.Integer(), nullable=False, server_default="60"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("trading_account", "daily_analysis_limit")
|
||||
@@ -27,8 +27,7 @@ TIMEOUT_SECONDS = 600
|
||||
Effort = Literal["low", "medium", "high", "xhigh", "max"]
|
||||
# SDK 自带重试只针对连接错误、429 和 5xx
|
||||
MAX_RETRIES = 2
|
||||
# 流内错误(上游过载等)和流式读取中的网络中断 SDK 不重试;连接失败 SDK 重试后仍可能失败(上游短暂不可达)。
|
||||
# 这些都由这里再重试,且只在还没有输出内容时重试
|
||||
# 只重试 SDK 不处理的流内错误和读取中断,且只在还没有输出内容时重试。
|
||||
STREAM_RETRIES = 3
|
||||
STREAM_RETRY_DELAY_SECONDS = 2
|
||||
# 输出被截断时回给模型的工具结果:参数不完整,不能执行
|
||||
@@ -109,11 +108,11 @@ def _in_stream_error(e: Exception) -> bool:
|
||||
|
||||
|
||||
def _retryable(e: Exception) -> bool:
|
||||
"""流内错误事件、连接失败、读取中断可重试;超时不重试(单次已等 TIMEOUT_SECONDS)"""
|
||||
"""建连重试归 SDK;这里只处理流内错误与读取中断,超时不重试。"""
|
||||
if isinstance(e, (openai.APITimeoutError, anthropic.APITimeoutError, httpx.TimeoutException, httpx2.TimeoutException)):
|
||||
return False
|
||||
return _in_stream_error(e) or isinstance(
|
||||
e, (openai.APIConnectionError, anthropic.APIConnectionError, httpx.TransportError, httpx2.TransportError),
|
||||
e, (httpx.TransportError, httpx2.TransportError),
|
||||
)
|
||||
|
||||
|
||||
@@ -169,6 +168,7 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
|
||||
result = Completion()
|
||||
# 按 index 拼接分片到达的工具调用
|
||||
calls: dict[int, dict] = {}
|
||||
finished = False
|
||||
async with client:
|
||||
chunks = await client.chat.completions.create(
|
||||
model=provider.model, messages=_openai_messages(system, messages), max_tokens=provider.max_tokens,
|
||||
@@ -179,7 +179,14 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
|
||||
result.usage = {"input_tokens": chunk.usage.prompt_tokens, "output_tokens": chunk.usage.completion_tokens}
|
||||
if not chunk.choices:
|
||||
continue
|
||||
if chunk.choices[0].finish_reason == "length":
|
||||
reason = chunk.choices[0].finish_reason
|
||||
if reason == "content_filter":
|
||||
raise LlmError("模型输出被内容过滤器中止")
|
||||
if reason is not None:
|
||||
if reason not in ("stop", "tool_calls", "length"):
|
||||
raise LlmError(f"模型返回了不支持的结束原因:{reason}")
|
||||
finished = True
|
||||
if reason == "length":
|
||||
result.truncated = True
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
@@ -193,6 +200,8 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
|
||||
call["name"] += tc.function.name
|
||||
if tc.function and tc.function.arguments:
|
||||
call["arguments"] += tc.function.arguments
|
||||
if not finished:
|
||||
raise LlmError("模型连接提前结束,未收到完整响应的结束标志")
|
||||
result.tool_calls = [_parse_call(c["id"], c["name"], c["arguments"], result.truncated) for _, c in sorted(calls.items())]
|
||||
yield result
|
||||
|
||||
@@ -238,6 +247,7 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
|
||||
result = Completion()
|
||||
blocks: dict[int, dict] = {}
|
||||
thinking: dict[int, dict] = {}
|
||||
finished = False
|
||||
async with client:
|
||||
events = await client.messages.create(
|
||||
model=provider.model, system=system, messages=_anthropic_messages(messages), max_tokens=provider.max_tokens,
|
||||
@@ -267,6 +277,10 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
|
||||
elif event.type == "message_delta":
|
||||
result.usage["output_tokens"] = event.usage.output_tokens
|
||||
result.truncated = event.delta.stop_reason == "max_tokens"
|
||||
elif event.type == "message_stop":
|
||||
finished = True
|
||||
if not finished:
|
||||
raise LlmError("模型连接提前结束,未收到完整响应的结束标志")
|
||||
result.tool_calls = [_parse_call(b["id"], b["name"], b["arguments"], result.truncated) for _, b in sorted(blocks.items())]
|
||||
result.thinking_blocks = [b for _, b in sorted(thinking.items())]
|
||||
yield result
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Literal
|
||||
@@ -26,10 +27,11 @@ router = APIRouter(prefix="/api/chat", tags=["chat"], dependencies=[Depends(requ
|
||||
MAX_TOOL_ROUNDS = 8
|
||||
# 发给前端展示的工具结果长度
|
||||
TOOL_PREVIEW_CHARS = 2000
|
||||
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,工具未执行"}, ensure_ascii=False)
|
||||
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,未完成的工具结果尚未确认,请核对后再重试"}, ensure_ascii=False)
|
||||
|
||||
# api 只有一个进程:同一时间只允许一轮生成,Reset 也要等生成结束
|
||||
generating = asyncio.Lock()
|
||||
logger = logging.getLogger("alphapulse.api.chat")
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
@@ -180,35 +182,50 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
|
||||
truncated_message = f"模型输出达到最大输出上限({provider.max_tokens} token)被截断,可在「环境变量」页调大「最大输出」"
|
||||
if completion.truncated and not completion.tool_calls:
|
||||
assistant["error"] = truncated_message
|
||||
await save_messages(ChatMessage(session_id=current.id, role="assistant", content=assistant, provider_id=provider.id, usage=completion.usage))
|
||||
messages.append({"role": "assistant", **assistant})
|
||||
yield event("usage", completion.usage)
|
||||
if not completion.tool_calls:
|
||||
if completion.truncated:
|
||||
yield event("error", {"message": truncated_message})
|
||||
break
|
||||
|
||||
pending = list(completion.tool_calls)
|
||||
assistant_saved = False
|
||||
try:
|
||||
# 消息提交和本地进度必须一起完成,避免取消恰好发生在提交之后而留下孤立调用。
|
||||
with anyio.CancelScope(shield=True):
|
||||
await save_messages(ChatMessage(session_id=current.id, role="assistant", content=assistant, provider_id=provider.id, usage=completion.usage))
|
||||
assistant_saved = True
|
||||
messages.append({"role": "assistant", **assistant})
|
||||
yield event("usage", completion.usage)
|
||||
if not completion.tool_calls:
|
||||
if completion.truncated:
|
||||
yield event("error", {"message": truncated_message})
|
||||
break
|
||||
while pending:
|
||||
call = pending[0]
|
||||
yield event("tool_call", {"id": call["id"], "name": call["name"], "arguments": call["arguments"]})
|
||||
# 截断时不执行,把原因告诉模型,让它精简后重试
|
||||
outcome = ToolOutcome(llm.TRUNCATED_RESULT) if completion.truncated else await run_tool(call["name"], call["arguments"])
|
||||
result = {"tool_call_id": call["id"], "name": call["name"], "result": outcome.result}
|
||||
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
|
||||
pending.pop(0)
|
||||
with anyio.CancelScope(shield=True):
|
||||
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
|
||||
pending.pop(0)
|
||||
messages.append({"role": "tool", **result})
|
||||
yield event("tool_result", {"id": call["id"], "name": call["name"], "preview": outcome.result[:TOOL_PREVIEW_CHARS]})
|
||||
if outcome.event:
|
||||
yield event(*outcome.event)
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
# 每个工具调用都要有结果,否则下一轮发给模型的历史不合法
|
||||
await save_shielded(*(
|
||||
ChatMessage(session_id=current.id, role="tool", content={"tool_call_id": c["id"], "name": c["name"], "result": STOPPED_RESULT})
|
||||
for c in pending
|
||||
))
|
||||
if assistant_saved and pending:
|
||||
await save_shielded(*(
|
||||
ChatMessage(session_id=current.id, role="tool", content={"tool_call_id": c["id"], "name": c["name"], "result": STOPPED_RESULT})
|
||||
for c in pending
|
||||
))
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Chat 工具轮次失败")
|
||||
if assistant_saved and pending:
|
||||
failed = json.dumps({"error": "工具轮次异常中止,执行结果未确认;后续工具未执行"}, ensure_ascii=False)
|
||||
await save_shielded(*(
|
||||
ChatMessage(session_id=current.id, role="tool", content={"tool_call_id": c["id"], "name": c["name"], "result": failed})
|
||||
for c in pending
|
||||
))
|
||||
yield event("error", {"message": "工具轮次执行失败,请查看服务日志"})
|
||||
return
|
||||
yield event("done", {})
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
"""环境变量页:模型接入配置、币安模拟盘 key。密钥只写不读,接口只返回末 4 位"""
|
||||
"""环境变量页:模型接入配置、交易所模拟盘 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
|
||||
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 decrypt, encrypt
|
||||
from alphapulse.core.crypto import encrypt
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.models import AiProvider, TradingAccount, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
||||
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)])
|
||||
|
||||
@@ -161,9 +164,14 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
|
||||
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
|
||||
@@ -172,8 +180,17 @@ class ExchangeOut(BaseModel):
|
||||
|
||||
class ExchangeIn(BaseModel):
|
||||
# key 和 secret 成对生效,修改时两个都要填
|
||||
api_key: ApiCredential
|
||||
api_secret: ApiCredential
|
||||
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):
|
||||
@@ -191,19 +208,35 @@ async def any_running(session: AsyncSession) -> bool:
|
||||
|
||||
|
||||
async def exchange_out(session: AsyncSession, account: TradingAccount | None) -> ExchangeOut:
|
||||
if account is None:
|
||||
return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None)
|
||||
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(
|
||||
configured=account.api_key_encrypted is not None, api_key_hint=account.api_key_hint,
|
||||
running=await any_running(session), updated_at=account.updated_at,
|
||||
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")
|
||||
return account or TradingAccount(id=1)
|
||||
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")
|
||||
@@ -214,40 +247,55 @@ async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -
|
||||
@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:]
|
||||
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()
|
||||
await session.refresh(account)
|
||||
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)
|
||||
account.api_key_encrypted = account.api_secret_encrypted = account.api_key_hint = None
|
||||
session.add(account)
|
||||
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()
|
||||
await session.refresh(account)
|
||||
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)
|
||||
if account is None or account.api_key_encrypted is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key")
|
||||
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 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")
|
||||
async with create_client(credential) as client:
|
||||
info = await client.account()
|
||||
dual = await client.position_mode()
|
||||
except ExchangeError as e:
|
||||
hint = "(key 须在币安模拟交易里创建,实盘 key 不能用;或未开启合约权限、服务器 IP 不在白名单)" if e.code == -2015 else ""
|
||||
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=mode["dualSidePosition"],
|
||||
available_usdt=info["availableBalance"], wallet_usdt=info["totalWalletBalance"], dual_side_position=dual,
|
||||
)
|
||||
@@ -116,8 +116,8 @@ async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession,
|
||||
node = await get_node(session, node_id)
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if "/" in name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "名称不能包含 /")
|
||||
if not name or "/" in name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "名称不能为空,也不能包含 /")
|
||||
duplicate = await session.scalar(select(SkillNode.id).where(
|
||||
SkillNode.parent_id.is_not_distinct_from(node.parent_id), SkillNode.name == name, SkillNode.id != node_id
|
||||
))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import BigInteger, cast, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -17,23 +17,17 @@ from alphapulse.api.market import call_binance, client as market_client
|
||||
from alphapulse.core.auth import require_admin
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import AiProvider, NewsItem, NewsSignal, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, public_get
|
||||
from alphapulse.trading.executor import MANUAL_ACTION_KEY
|
||||
from alphapulse.trading.news_signals import FORWARD_START, SYMBOLS as SIGNAL_SYMBOLS, news_text
|
||||
from alphapulse.models import AiProvider, NewsItem, NewsSignal, TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, ExchangeError, active_exchange, public_get
|
||||
from alphapulse.trading.news_signals import FORWARD_START, SYMBOLS as SIGNAL_SYMBOLS, is_forward_sample, news_text
|
||||
from alphapulse.trading.positions import build_positions
|
||||
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT
|
||||
from alphapulse.trading.signal_stats import Signal, summarize
|
||||
from alphapulse.trading.snapshot import read_snapshot
|
||||
from alphapulse.trading.snapshot import SnapshotError, read_snapshot, require_fresh_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
|
||||
|
||||
SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
|
||||
SYMBOLS_CACHE_SECONDS = 3600
|
||||
# 删除前要求快照足够新,否则无法确认没有仓位和挂单
|
||||
SNAPSHOT_FRESH_SECONDS = 30
|
||||
# 平仓、清除挂单的请求等执行器取走的时限:引擎没及时处理就作废,不在之后意外执行
|
||||
MANUAL_ACTION_TTL_SECONDS = 30
|
||||
# 执行器正在管理的计划状态
|
||||
LIVE_PLAN_STATUSES = ("pending", "open", "closing")
|
||||
|
||||
@@ -46,6 +40,7 @@ class TodayUsage(BaseModel):
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
exchange: Literal["binance", "bybit"]
|
||||
configured: bool
|
||||
provider_id: int | None
|
||||
reasoning_effort: str | None
|
||||
@@ -57,6 +52,7 @@ class StatusOut(BaseModel):
|
||||
class ConfigIn(BaseModel):
|
||||
provider_id: int | None = None
|
||||
reasoning_effort: llm.Effort | None = None
|
||||
daily_analysis_limit: Annotated[int, Field(strict=True, ge=1, le=2147483647)] = 60
|
||||
|
||||
|
||||
class SymbolOption(BaseModel):
|
||||
@@ -89,6 +85,7 @@ class SymbolIn(BaseModel):
|
||||
|
||||
|
||||
class AnalysisOut(BaseModel):
|
||||
exchange: str
|
||||
id: int
|
||||
symbol: str
|
||||
reasons: list[str]
|
||||
@@ -140,6 +137,7 @@ class SymbolPlanOut(BaseModel):
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
exchange: str
|
||||
id: int
|
||||
created_at: datetime
|
||||
symbol: str | None
|
||||
@@ -213,7 +211,7 @@ class NewsSignalsOut(BaseModel):
|
||||
|
||||
|
||||
async def get_account(session: AsyncSession) -> TradingAccount:
|
||||
return await session.get(TradingAccount, 1) or TradingAccount(id=1)
|
||||
return await session.get(TradingAccount, 1) or TradingAccount(id=1, exchange="binance", daily_analysis_limit=60)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -227,10 +225,12 @@ async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) ->
|
||||
func.coalesce(func.sum(TradingAnalysis.usage["output_tokens"].as_integer()), 0),
|
||||
).where(TradingAnalysis.started_at >= today)
|
||||
)).one()
|
||||
credential = await session.get(TradingCredential, account.exchange)
|
||||
return StatusOut(
|
||||
configured=account.api_key_encrypted is not None, provider_id=account.provider_id, reasoning_effort=account.reasoning_effort,
|
||||
exchange=account.exchange, configured=bool(credential and credential.api_key_encrypted),
|
||||
provider_id=account.provider_id, reasoning_effort=account.reasoning_effort,
|
||||
snapshot=await read_snapshot(),
|
||||
today=TodayUsage(analyses=count, limit=DAILY_ANALYSIS_LIMIT, input_tokens=input_tokens, output_tokens=output_tokens),
|
||||
today=TodayUsage(analyses=count, limit=account.daily_analysis_limit, input_tokens=input_tokens, output_tokens=output_tokens),
|
||||
)
|
||||
|
||||
|
||||
@@ -247,9 +247,9 @@ async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(
|
||||
return await get_status(session)
|
||||
|
||||
|
||||
async def symbol_options() -> list[dict]:
|
||||
async def symbol_options(exchange: str) -> list[dict]:
|
||||
"""可交易的币:模拟盘和实盘都在交易的 USDT 永续,只含加密货币(排除美股、商品等 TradFi)"""
|
||||
if cached := await redis.get(SYMBOLS_CACHE_KEY):
|
||||
if cached := await redis.get(f"{SYMBOLS_CACHE_KEY}:{exchange}"):
|
||||
return json.loads(cached)
|
||||
|
||||
def crypto_perpetuals(info: dict) -> dict[str, str]:
|
||||
@@ -260,23 +260,27 @@ async def symbol_options() -> list[dict]:
|
||||
}
|
||||
|
||||
try:
|
||||
demo = crypto_perpetuals(await public_get("/fapi/v1/exchangeInfo"))
|
||||
if exchange == "bybit":
|
||||
from alphapulse.trading.bybit import instruments
|
||||
demo = {symbol: row["baseCoin"] for symbol, row in (await instruments()).items()}
|
||||
else:
|
||||
demo = crypto_perpetuals(await public_get("/fapi/v1/exchangeInfo"))
|
||||
except ExchangeError as e:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(e)) from None
|
||||
# 分析用实盘行情,执行在模拟盘,两边都要有
|
||||
mainnet = crypto_perpetuals(await call_binance(market_client.get("um", "exchange_info")))
|
||||
options = [{"symbol": s, "base": b} for s, b in sorted(demo.items()) if s in mainnet]
|
||||
await redis.set(SYMBOLS_CACHE_KEY, json.dumps(options), ex=SYMBOLS_CACHE_SECONDS)
|
||||
await redis.set(f"{SYMBOLS_CACHE_KEY}:{exchange}", json.dumps(options), ex=SYMBOLS_CACHE_SECONDS)
|
||||
return options
|
||||
|
||||
|
||||
@router.get("/symbol-options")
|
||||
async def list_symbol_options() -> list[SymbolOption]:
|
||||
return [SymbolOption.model_validate(o) for o in await symbol_options()]
|
||||
async def list_symbol_options(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOption]:
|
||||
return [SymbolOption.model_validate(o) for o in await symbol_options(await active_exchange(session))]
|
||||
|
||||
|
||||
async def current_equity() -> Decimal | None:
|
||||
"""账户权益,取自 trader 的快照;进程离线或未同步到币安时为空"""
|
||||
"""账户权益,取自 trader 的快照;进程离线或未同步到交易所时为空"""
|
||||
snapshot = await read_snapshot()
|
||||
return Decimal(snapshot["account"]["margin_balance"]) if snapshot and snapshot["account"] else None
|
||||
|
||||
@@ -289,14 +293,18 @@ async def get_symbol(session: AsyncSession, symbol: str) -> TradingSymbol:
|
||||
|
||||
|
||||
async def set_running(session: AsyncSession, s: TradingSymbol, running: bool) -> None:
|
||||
if running and (await get_account(session)).api_key_encrypted is None:
|
||||
exchange = await active_exchange(session)
|
||||
credential = await session.get(TradingCredential, exchange)
|
||||
if running and (credential is None or credential.api_key_encrypted is None):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key,请先到环境变量页填写")
|
||||
if running and s.capital_usdt is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{s.symbol} 还没有设置交易资金")
|
||||
if running and not any(o["symbol"] == s.symbol for o in await symbol_options(exchange)):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{s.symbol} 不受当前模拟盘支持")
|
||||
if s.running != running:
|
||||
s.running = running
|
||||
s.running_changed_at = datetime.now(LOCAL_TZ)
|
||||
session.add(TradingEvent(
|
||||
session.add(TradingEvent(exchange=await active_exchange(session),
|
||||
symbol=s.symbol, kind="started" if running else "stopped",
|
||||
message="开始行情交易" if running else "停止行情交易:不再分析,撤销未成交的入场单;已有持仓的止损、止盈等程序规则继续执行",
|
||||
))
|
||||
@@ -331,7 +339,7 @@ async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[Asy
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST,
|
||||
f"各币交易资金合计不能超过账户权益 {equity:.2f} USDT(其他币已分配 {others:.2f},最多还能设 {max(equity - others, 0):.2f})")
|
||||
s.capital_usdt = capital
|
||||
session.add(TradingEvent(symbol=symbol, kind="capital_changed",
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="capital_changed",
|
||||
message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金"))
|
||||
await session.commit()
|
||||
|
||||
@@ -340,9 +348,9 @@ async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[Asy
|
||||
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
|
||||
symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all()
|
||||
equity = await current_equity()
|
||||
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.status == "running"))).all())
|
||||
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.status == "running"))).all())
|
||||
plans = {p.symbol: p for p in await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id)
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id)
|
||||
)}
|
||||
return [
|
||||
SymbolOut(
|
||||
@@ -360,10 +368,10 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -
|
||||
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
|
||||
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
||||
symbol = body.symbol.strip().upper()
|
||||
if not any(o["symbol"] == symbol for o in await symbol_options()):
|
||||
if not any(o["symbol"] == symbol for o in await symbol_options(await active_exchange(session))):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
|
||||
session.add(TradingSymbol(symbol=symbol))
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message="加入交易列表"))
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="symbol_added", message="加入交易列表"))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
@@ -371,12 +379,10 @@ async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(ge
|
||||
|
||||
|
||||
async def fresh_snapshot(symbol: str, action: str) -> dict:
|
||||
"""引擎在线且刚和币安同步过的快照"""
|
||||
snapshot = await read_snapshot()
|
||||
synced_at = snapshot and snapshot["synced_at"] and datetime.fromisoformat(snapshot["synced_at"])
|
||||
if not synced_at or datetime.now(LOCAL_TZ) - synced_at > timedelta(seconds=SNAPSHOT_FRESH_SECONDS):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单(引擎离线或连接币安失败),暂时不能{action}")
|
||||
return snapshot
|
||||
try:
|
||||
return require_fresh_snapshot(await read_snapshot())
|
||||
except SnapshotError as e:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单,暂时不能{action}:{e}") from e
|
||||
|
||||
|
||||
async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol:
|
||||
@@ -385,7 +391,7 @@ async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> Tradin
|
||||
if s is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
|
||||
busy = await session.scalar(select(func.count()).where(
|
||||
TradingAnalysis.symbol == symbol,
|
||||
TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == symbol,
|
||||
or_(TradingAnalysis.status == "running", TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES))),
|
||||
))
|
||||
if busy:
|
||||
@@ -403,7 +409,7 @@ async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(ge
|
||||
"""只移出列表;分析、订单流水和时间线保留"""
|
||||
s = await ensure_idle(session, symbol, "删除")
|
||||
await session.delete(s)
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_removed", message="移出交易列表"))
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="symbol_removed", message="移出交易列表"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -416,7 +422,9 @@ async def manual_action(symbol: str, action: Literal["close", "cancel"], session
|
||||
if action == "close":
|
||||
# 不停止的话,平仓后会重新分析,可能马上又开仓
|
||||
await set_running(session, s, False)
|
||||
await redis.set(MANUAL_ACTION_KEY.format(symbol), action, ex=MANUAL_ACTION_TTL_SECONDS)
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), symbol=symbol, kind="manual_requested", level="warn",
|
||||
message=f"已请求{label},等待交易所确认", data={"action": action}))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
@@ -427,7 +435,7 @@ async def list_events(
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> list[EventOut]:
|
||||
"""按 id 倒序分页;指定 symbol 时同时返回账户级事件(启停等)"""
|
||||
query = select(TradingEvent).order_by(TradingEvent.id.desc()).limit(limit)
|
||||
query = select(TradingEvent).where(TradingEvent.exchange == await active_exchange(session)).order_by(TradingEvent.id.desc()).limit(limit)
|
||||
if symbol:
|
||||
query = query.where(or_(TradingEvent.symbol == symbol, TradingEvent.symbol.is_(None)))
|
||||
if before:
|
||||
@@ -446,13 +454,15 @@ async def list_position_history(
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> PositionHistoryPage:
|
||||
"""由币安成交记录还原的已平仓仓位,按平仓时间倒序;start/end 按平仓时间筛选。
|
||||
"""由当前交易所成交记录还原的已平仓仓位,按平仓时间倒序;start/end 按平仓时间筛选。
|
||||
个人账户成交量小,每次全量还原后在内存里筛选、统计、分页"""
|
||||
fills = list(await session.scalars(select(TradingFill).order_by(TradingFill.symbol, TradingFill.time, TradingFill.trade_id)))
|
||||
exchange = await active_exchange(session)
|
||||
trade_order = cast(TradingFill.trade_id, BigInteger) if exchange == "binance" else TradingFill.trade_id
|
||||
fills = list(await session.scalars(select(TradingFill).where(TradingFill.exchange == exchange).order_by(TradingFill.symbol, TradingFill.time, trade_order)))
|
||||
# 程序下的普通单记录了 orderId;条件单(止损)触发后的订单号没有记录,靠同一仓位里的其他订单关联
|
||||
order_plans = dict((await session.execute(
|
||||
select(TradingOrder.exchange_id, TradingOrder.analysis_id)
|
||||
.where(TradingOrder.is_algo.is_(False), TradingOrder.exchange_id.is_not(None), TradingOrder.analysis_id.is_not(None))
|
||||
.where(TradingOrder.exchange == await active_exchange(session), TradingOrder.is_algo.is_(False), TradingOrder.exchange_id.is_not(None), TradingOrder.analysis_id.is_not(None))
|
||||
)).tuples().all())
|
||||
plans = {a.id: a for a in await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.id.in_(set(order_plans.values())))
|
||||
@@ -484,7 +494,7 @@ async def list_position_history(
|
||||
@router.get("/positions/history/symbols")
|
||||
async def list_position_history_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[str]:
|
||||
"""有过成交的币,供历史仓位筛选"""
|
||||
return list(await session.scalars(select(TradingFill.symbol).distinct().order_by(TradingFill.symbol)))
|
||||
return list(await session.scalars(select(TradingFill.symbol).where(TradingFill.exchange == await active_exchange(session)).distinct().order_by(TradingFill.symbol)))
|
||||
|
||||
|
||||
@router.get("/news-signals")
|
||||
@@ -494,6 +504,8 @@ async def list_news_signals(session: Annotated[AsyncSession, Depends(get_session
|
||||
select(NewsSignal, NewsItem.published_at, NewsItem.source_code, news_text())
|
||||
.join(NewsItem, NewsItem.id == NewsSignal.news_id).order_by(NewsItem.published_at)
|
||||
)).all()
|
||||
# 旧标注保留在库里,但事后标注不能混入前向记录或统计。
|
||||
rows = [r for r in rows if is_forward_sample(r[1], r[0].labeled_at)]
|
||||
filled = [Signal(published_at, s.category, s.direction, s.prices) for s, published_at, _, _ in rows if s.prices]
|
||||
horizons = ("1h", "4h")
|
||||
|
||||
@@ -519,7 +531,7 @@ async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends
|
||||
s = await get_symbol(session, symbol)
|
||||
if s.capital_usdt is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 还没有设置交易资金")
|
||||
running = await session.scalar(select(func.count()).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "running"))
|
||||
running = await session.scalar(select(func.count()).where(TradingAnalysis.symbol == symbol, TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.status == "running"))
|
||||
if running or s.analysis_requested_at is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中")
|
||||
s.analysis_requested_at = datetime.now(LOCAL_TZ)
|
||||
@@ -533,7 +545,7 @@ async def list_analyses(
|
||||
symbol: str | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 10,
|
||||
) -> list[AnalysisOut]:
|
||||
query = select(TradingAnalysis).order_by(TradingAnalysis.id.desc()).limit(limit)
|
||||
query = select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session)).order_by(TradingAnalysis.id.desc()).limit(limit)
|
||||
if symbol:
|
||||
query = query.where(TradingAnalysis.symbol == symbol)
|
||||
return [AnalysisOut.model_validate(a, from_attributes=True) for a in await session.scalars(query)]
|
||||
@@ -550,7 +562,7 @@ async def get_analysis(analysis_id: int, session: Annotated[AsyncSession, Depend
|
||||
@router.get("/symbols/{symbol}/plan")
|
||||
async def get_symbol_plan(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> SymbolPlanOut:
|
||||
plan = await session.scalar(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES)))
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("proposed", *LIVE_PLAN_STATUSES)))
|
||||
.order_by(TradingAnalysis.id.desc()).limit(1)
|
||||
)
|
||||
if plan is None:
|
||||
|
||||
@@ -111,6 +111,7 @@ class NewsSpider(RedisSpider):
|
||||
crawler.signals.connect(spider._on_opened, signal=signals.spider_opened)
|
||||
crawler.signals.connect(spider._on_idle, signal=signals.spider_idle)
|
||||
crawler.signals.connect(spider._on_error, signal=signals.spider_error)
|
||||
crawler.signals.connect(spider._on_item_error, signal=signals.item_error)
|
||||
crawler.signals.connect(spider._on_closed, signal=signals.spider_closed)
|
||||
return spider
|
||||
|
||||
@@ -182,6 +183,10 @@ class NewsSpider(RedisSpider):
|
||||
if self.run:
|
||||
self.run.errors.append(f"解析异常 {response.url}:{failure.value!r}")
|
||||
|
||||
def _on_item_error(self, failure: Failure) -> None:
|
||||
if self.run:
|
||||
self.run.errors.append(f"新闻处理或落库失败:{failure.value!r}")
|
||||
|
||||
async def _on_closed(self) -> None:
|
||||
self.logger.info("采集进程退出")
|
||||
await self._flush_logs()
|
||||
|
||||
@@ -2,7 +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 NewsSignal, TradingAccount, TradingAnalysis, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
from alphapulse.models.trading import NewsSignal, TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
|
||||
__all__ = [
|
||||
"AiProvider",
|
||||
@@ -25,6 +25,7 @@ __all__ = [
|
||||
"Source",
|
||||
"TradingAccount",
|
||||
"TradingAnalysis",
|
||||
"TradingCredential",
|
||||
"TradingEvent",
|
||||
"TradingFill",
|
||||
"TradingOrder",
|
||||
|
||||
@@ -9,18 +9,29 @@ from alphapulse.core.db import Base
|
||||
|
||||
|
||||
class TradingAccount(Base):
|
||||
"""币安模拟盘账户:单行(id=1)。key 只以密文存储;启停和交易资金按币种设置,见 TradingSymbol"""
|
||||
"""模拟盘全局设置:单行(id=1),凭证按交易所单独保存"""
|
||||
|
||||
__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))
|
||||
exchange: Mapped[str] = mapped_column(String(16), default="binance", server_default="binance")
|
||||
# 交易分析用的模型配置;为空时用默认配置
|
||||
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
|
||||
daily_analysis_limit: Mapped[int] = mapped_column(Integer, default=60, server_default="60")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TradingCredential(Base):
|
||||
"""每家模拟盘独立保存凭证,切换交易所不覆盖另一家的 key"""
|
||||
|
||||
__tablename__ = "trading_credentials"
|
||||
|
||||
exchange: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
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))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -43,6 +54,8 @@ class TradingAnalysis(Base):
|
||||
"""一次模型分析;action=plan 的行同时是一个交易计划,plan_* 等字段记录其生命周期"""
|
||||
|
||||
__tablename__ = "trading_analyses"
|
||||
exchange: Mapped[str] = mapped_column(String(16), index=True, default="binance", server_default="binance")
|
||||
|
||||
__table_args__ = (Index("ix_trading_analyses_symbol_started", "symbol", "started_at"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -84,6 +97,8 @@ class TradingOrder(Base):
|
||||
|
||||
__tablename__ = "trading_orders"
|
||||
|
||||
exchange: Mapped[str] = mapped_column(String(16), index=True, default="binance", server_default="binance")
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
analysis_id: Mapped[int | None] = mapped_column(ForeignKey("trading_analyses.id"), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32), index=True)
|
||||
@@ -102,7 +117,7 @@ class TradingOrder(Base):
|
||||
# submitting / new / partially_filled / filled / canceled / expired / rejected / finished / failed
|
||||
status: Mapped[str] = mapped_column(String(16), default="submitting")
|
||||
# orderId 或 algoId
|
||||
exchange_id: Mapped[str | None] = mapped_column(String(32))
|
||||
exchange_id: Mapped[str | None] = mapped_column(String(64))
|
||||
filled_qty: Mapped[Decimal] = mapped_column(Numeric, default=0)
|
||||
avg_price: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -114,6 +129,8 @@ class TradingEvent(Base):
|
||||
"""只追加的时间线"""
|
||||
|
||||
__tablename__ = "trading_events"
|
||||
exchange: Mapped[str] = mapped_column(String(16), index=True, default="binance", server_default="binance")
|
||||
|
||||
__table_args__ = (Index("ix_trading_events_symbol_id", "symbol", "id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
@@ -129,14 +146,16 @@ class TradingEvent(Base):
|
||||
|
||||
|
||||
class TradingFill(Base):
|
||||
"""币安成交记录(userTrades),历史仓位由它还原;包括程序外的手动成交。trade id 只在单个 symbol 内唯一"""
|
||||
"""模拟盘成交记录,历史仓位由它还原;包括手动成交,按交易所和币种隔离 ID"""
|
||||
|
||||
__tablename__ = "trading_fills"
|
||||
exchange: Mapped[str] = mapped_column(String(16), primary_key=True, default="binance", server_default="binance")
|
||||
|
||||
__table_args__ = (Index("ix_trading_fills_symbol_time", "symbol", "time"),)
|
||||
|
||||
symbol: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
trade_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
order_id: Mapped[int] = mapped_column(BigInteger)
|
||||
trade_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
order_id: Mapped[str] = mapped_column(String(64))
|
||||
# BUY / SELL;账户为单向持仓,买为正、卖为负即可还原仓位
|
||||
side: Mapped[str] = mapped_column(String(4))
|
||||
price: Mapped[Decimal] = mapped_column(Numeric)
|
||||
|
||||
@@ -20,10 +20,11 @@ from alphapulse.api.market import client as market_client
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading import plan as rules
|
||||
from alphapulse.trading.client import ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.client import ExchangeError, active_exchange, mark_price, symbol_rules
|
||||
from alphapulse.trading.executor import CLOSE_REASON_LABELS
|
||||
from alphapulse.trading.plan import PlanContext, PlanError, TradePlan, atr, check_setup, parse_bars
|
||||
from alphapulse.trading.snapshot import read_snapshot
|
||||
from alphapulse.trading.schedule import daily_limit_reached
|
||||
from alphapulse.trading.snapshot import SnapshotError, read_snapshot, require_fresh_snapshot
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.analyst")
|
||||
|
||||
@@ -93,6 +94,7 @@ class AnalysisError(Exception):
|
||||
|
||||
@dataclass
|
||||
class Prepared:
|
||||
exchange: str
|
||||
provider: AiProvider
|
||||
effort: str | None
|
||||
dry_run: bool
|
||||
@@ -159,20 +161,21 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
if provider is None:
|
||||
raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加")
|
||||
session.expunge(provider)
|
||||
exchange = account.exchange
|
||||
recent = (await session.scalars(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id)
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id)
|
||||
.order_by(TradingAnalysis.id.desc()).limit(RECENT_DECISIONS)
|
||||
)).all()
|
||||
trades = (await session.scalars(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.dry_run.is_(False),
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.dry_run.is_(False),
|
||||
TradingAnalysis.first_fill_at >= datetime.now(UTC) - timedelta(hours=RECENT_TRADES_HOURS))
|
||||
.order_by(TradingAnalysis.first_fill_at.desc())
|
||||
)).all()
|
||||
current = await session.scalar(
|
||||
select(TradingAnalysis)
|
||||
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open", "closing")), TradingAnalysis.dry_run.is_(False))
|
||||
.where(TradingAnalysis.exchange == exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.plan_status.in_(("pending", "open", "closing")), TradingAnalysis.dry_run.is_(False))
|
||||
.order_by(TradingAnalysis.id.desc()).limit(1)
|
||||
)
|
||||
fills = [
|
||||
@@ -186,14 +189,17 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
capital_usdt = target.capital_usdt
|
||||
effort = account.reasoning_effort or provider.reasoning_effort
|
||||
|
||||
snapshot = await read_snapshot()
|
||||
if not snapshot or not snapshot["account"]:
|
||||
raise AnalysisError("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位")
|
||||
try:
|
||||
snapshot = require_fresh_snapshot(await read_snapshot())
|
||||
except SnapshotError as e:
|
||||
raise AnalysisError(f"无法计算仓位:{e}") from e
|
||||
if snapshot["exchange"] != exchange:
|
||||
raise AnalysisError("分析期间切换了交易所,请重新分析")
|
||||
equity = Decimal(snapshot["account"]["margin_balance"])
|
||||
capital = min(capital_usdt, equity)
|
||||
position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None)
|
||||
symbol_rules_ = await symbol_rules(symbol)
|
||||
mark = await mark_price(symbol)
|
||||
symbol_rules_ = await symbol_rules(symbol, exchange)
|
||||
mark = await mark_price(symbol, exchange)
|
||||
context = PlanContext(
|
||||
mark_price=mark, capital=capital, rules=symbol_rules_, atr=await recent_atr(symbol),
|
||||
has_plan=current is not None, position_side=position_side(snapshot, symbol),
|
||||
@@ -212,7 +218,8 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
f"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT)};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})",
|
||||
f"- 名义仓位上限:{capital * rules.MAX_LEVERAGE:.2f} USDT({rules.MAX_LEVERAGE} 倍该币交易资金)",
|
||||
f"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
|
||||
f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)",
|
||||
f"- 执行交易所:{exchange} 模拟盘;行情研究数据来自币安实盘,跨交易所价格可能有差异",
|
||||
f"- 模拟盘标记价:{mark}(下单校验以此为准)",
|
||||
f"- 最近已收盘 5m K 线的 ATR14:{context.atr:.{max(0, -symbol_rules_.tick_size.as_tuple().exponent)}f}(程序按它计算止损)",
|
||||
"",
|
||||
"## 当前持仓与挂单(模拟盘)",
|
||||
@@ -238,7 +245,7 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared
|
||||
"## 预取的行情(实盘数据,已取,不要用相同参数重复调用)",
|
||||
await prefetch(symbol),
|
||||
]
|
||||
return Prepared(provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
|
||||
return Prepared(exchange=exchange, provider=provider, effort=effort, dry_run=dry_run, system=system, user_text="\n".join(lines), context=context)
|
||||
|
||||
|
||||
async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.ToolSpec]) -> llm.Completion:
|
||||
@@ -250,7 +257,8 @@ async def complete(prepared: Prepared, messages: list[dict], tools: list[llm.Too
|
||||
|
||||
async def add_event(symbol: str, analysis_id: int, kind: str, message: str, level: str = "info", data: dict | None = None) -> None:
|
||||
async with SessionLocal() as session:
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind=kind, level=level, message=message, data=data))
|
||||
analysis = await session.get(TradingAnalysis, analysis_id)
|
||||
session.add(TradingEvent(exchange=analysis.exchange, symbol=symbol, analysis_id=analysis_id, kind=kind, level=level, message=message, data=data))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -272,7 +280,7 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
|
||||
except ValidationError as e:
|
||||
return dump({"error": "参数无效", "details": e.errors(include_url=False)}), None
|
||||
# 分析可能持续几分钟,按提交时的标记价、持仓和 ATR 校验
|
||||
prepared.context.mark_price = await mark_price(symbol)
|
||||
prepared.context.mark_price = await mark_price(symbol, prepared.exchange)
|
||||
prepared.context.atr = await recent_atr(symbol)
|
||||
snapshot = await read_snapshot()
|
||||
if snapshot:
|
||||
@@ -288,8 +296,13 @@ async def submit(prepared: Prepared, symbol: str, analysis_id: int, arguments: d
|
||||
async def run_analysis(symbol: str, reasons: list[str]) -> None:
|
||||
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
|
||||
async with SessionLocal() as session:
|
||||
# 串行核验额度并占用名额,防止并发分析或已排队请求越过新上限。
|
||||
await session.scalar(select(TradingAccount).where(TradingAccount.id == 1).with_for_update())
|
||||
if reasons != ["手动分析"] and await daily_limit_reached(session):
|
||||
await session.commit()
|
||||
return
|
||||
target = await session.get(TradingSymbol, symbol)
|
||||
analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (target and target.running), status="running")
|
||||
analysis = TradingAnalysis(exchange=await active_exchange(session), symbol=symbol, reasons=reasons, dry_run=not (target and target.running), status="running")
|
||||
session.add(analysis)
|
||||
await session.commit()
|
||||
analysis_id, dry_run = analysis.id, analysis.dry_run
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Bybit USDT 永续模拟盘适配;交易地址固定为 Demo Trading,不能切换实盘。"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from alphapulse.trading.client import EXCHANGE_INFO_SECONDS, OWN_CLIENT_ID, RECV_WINDOW_MS, TIMEOUT_SECONDS, ExchangeError, step_decimal
|
||||
from alphapulse.trading.plan import OrderSpec, SymbolRules
|
||||
|
||||
BASE_URL = "https://api-demo.bybit.com"
|
||||
STATUSES = {
|
||||
"New": "NEW", "PartiallyFilled": "PARTIALLY_FILLED", "Filled": "FILLED",
|
||||
"Cancelled": "CANCELED", "PartiallyFilledCanceled": "CANCELED", "Rejected": "REJECTED",
|
||||
"Untriggered": "NEW", "Triggered": "NEW", "Deactivated": "CANCELED",
|
||||
}
|
||||
|
||||
|
||||
def response_result(response: httpx.Response) -> dict:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
raise ExchangeError(response.status_code, None, "Bybit 返回了无法解析的响应") from None
|
||||
code = body.get("retCode")
|
||||
if response.status_code >= 400 or code != 0:
|
||||
raise ExchangeError(response.status_code, code, body.get("retMsg", "Bybit 请求失败"))
|
||||
return body["result"]
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict | None = None) -> dict:
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as http:
|
||||
try:
|
||||
response = await http.get(path, params=params)
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, "无法连接 Bybit 模拟盘") from e
|
||||
return response_result(response)
|
||||
|
||||
|
||||
def normalize_order(row: dict, is_algo: bool = False) -> dict:
|
||||
status = STATUSES[row["orderStatus"]]
|
||||
result = {
|
||||
"symbol": row["symbol"], "orderId": row["orderId"], "clientOrderId": row["orderLinkId"] if OWN_CLIENT_ID.fullmatch(row["orderLinkId"]) else row["orderId"],
|
||||
"side": row["side"].upper(), "type": row["orderType"].upper(), "status": status,
|
||||
"price": row["price"] or "0", "origQty": row["qty"], "executedQty": row["cumExecQty"],
|
||||
"avgPrice": row["avgPrice"] or "0", "rejectReason": row.get("rejectReason"), "reduceOnly": row["reduceOnly"], "time": int(row["createdTime"]),
|
||||
}
|
||||
if is_algo:
|
||||
stop_type = row["stopOrderType"]
|
||||
if stop_type in ("StopLoss", "PartialStopLoss"):
|
||||
order_type = "STOP"
|
||||
elif stop_type in ("TakeProfit", "PartialTakeProfit"):
|
||||
order_type = "TAKE_PROFIT"
|
||||
elif stop_type == "Stop":
|
||||
order_type = "STOP" if (row["side"] == "Sell") == (row["triggerDirection"] == 2) else "TAKE_PROFIT"
|
||||
elif stop_type == "TrailingStop":
|
||||
order_type = "TRAILING_STOP"
|
||||
else:
|
||||
raise ExchangeError(None, None, f"Bybit 返回了不支持的条件单类型:{stop_type}")
|
||||
if row["orderType"] == "Market":
|
||||
order_type += "_MARKET"
|
||||
result |= {
|
||||
"algoId": row["orderId"], "clientAlgoId": result["clientOrderId"],
|
||||
"algoStatus": {"NEW": "NEW", "PARTIALLY_FILLED": "TRIGGERING", "FILLED": "FINISHED"}.get(status, status),
|
||||
"actualOrderId": row["orderId"] if Decimal(row["cumExecQty"]) > 0 else None,
|
||||
"orderType": order_type,
|
||||
"triggerPrice": row["triggerPrice"], "quantity": row["qty"],
|
||||
"closePosition": row["qty"] == "0" and row["reduceOnly"] and row["closeOnTrigger"], "createTime": int(row["createdTime"]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def normalize_trade(row: dict) -> dict:
|
||||
return {
|
||||
"symbol": row["symbol"], "id": row["tradeId"], "orderId": row["orderId"], "side": row["side"].upper(),
|
||||
"price": row["tradePrice"], "qty": row["qty"], "realizedPnl": row["cashFlow"],
|
||||
"commission": row["fee"], "commissionAsset": row["currency"], "time": int(row["transactionTime"]),
|
||||
}
|
||||
|
||||
|
||||
class BybitDemoClient:
|
||||
exchange = "bybit"
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.api_key = api_key
|
||||
self.secret = api_secret.encode()
|
||||
self.http = httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS)
|
||||
self.time_offset = 0
|
||||
|
||||
async def __aenter__(self) -> "BybitDemoClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
await self.http.aclose()
|
||||
|
||||
async def request(self, method: str, path: str, params: dict[str, Any] | None = None) -> dict:
|
||||
# 只重发明确未受理的时间戳错误;超时的写请求由订单流水查询确认。
|
||||
for attempt in range(2):
|
||||
values = {k: v for k, v in (params or {}).items() if v is not None}
|
||||
payload = urlencode(values) if method == "GET" else json.dumps(values, separators=(",", ":"))
|
||||
timestamp = str(int(time.time() * 1000) + self.time_offset)
|
||||
signature = hmac.new(self.secret, f"{timestamp}{self.api_key}{RECV_WINDOW_MS}{payload}".encode(), hashlib.sha256).hexdigest()
|
||||
headers = {"X-BAPI-API-KEY": self.api_key, "X-BAPI-SIGN": signature,
|
||||
"X-BAPI-TIMESTAMP": timestamp, "X-BAPI-RECV-WINDOW": str(RECV_WINDOW_MS)}
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await self.http.get(f"{path}?{payload}" if payload else path, headers=headers)
|
||||
else:
|
||||
response = await self.http.request(method, path, content=payload, headers={**headers, "Content-Type": "application/json"})
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, "无法连接 Bybit 模拟盘,请查询订单状态确认执行结果") from e
|
||||
try:
|
||||
return response_result(response)
|
||||
except ExchangeError as e:
|
||||
if e.code != 10002 or attempt:
|
||||
message = e.message.replace(self.api_key, "[已隐藏]").replace(self.secret.decode(), "[已隐藏]")
|
||||
raise ExchangeError(e.status, e.code, message) from None
|
||||
started = int(time.time() * 1000)
|
||||
server = await public_get("/v5/market/time")
|
||||
self.time_offset = int(server["timeNano"]) // 1_000_000 - (started + int(time.time() * 1000)) // 2
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
async def pages(self, path: str, params: dict) -> list[dict]:
|
||||
rows, cursor = [], None
|
||||
while True:
|
||||
result = await self.request("GET", path, {**params, "cursor": cursor, "limit": 50})
|
||||
rows.extend(result["list"])
|
||||
next_cursor = result.get("nextPageCursor")
|
||||
if not next_cursor:
|
||||
return rows
|
||||
if next_cursor == cursor:
|
||||
raise ExchangeError(None, None, "Bybit 分页游标没有推进,无法确认完整结果")
|
||||
cursor = next_cursor
|
||||
|
||||
async def account(self) -> dict:
|
||||
result = await self.request("GET", "/v5/account/wallet-balance", {"accountType": "UNIFIED", "coin": "USDT"})
|
||||
coins = result["list"][0]["coin"]
|
||||
coin = next((c for c in coins if c["coin"] == "USDT"), None)
|
||||
if coin is None:
|
||||
raise ExchangeError(None, None, "Bybit 模拟盘没有 USDT 余额,请先在模拟账户领取资金")
|
||||
# 统一账户的总额以 USD 计价且逐仓时为空;策略只能使用 USDT 自身的余额和保证金。
|
||||
available = (Decimal(coin["walletBalance"]) - Decimal(coin["totalPositionIM"] or "0")
|
||||
- Decimal(coin["totalOrderIM"] or "0") - Decimal(coin["locked"] or "0") - Decimal(coin["bonus"] or "0"))
|
||||
return {"totalWalletBalance": coin["walletBalance"], "totalMarginBalance": coin["equity"],
|
||||
"availableBalance": format(available, "f"), "totalUnrealizedProfit": coin["unrealisedPnl"]}
|
||||
|
||||
async def raw_positions(self, symbol: str | None = None) -> list[dict]:
|
||||
return await self.pages("/v5/position/list", {"category": "linear", "settleCoin": "USDT", "symbol": symbol})
|
||||
|
||||
async def positions(self) -> list[dict]:
|
||||
info = await self.request("GET", "/v5/account/info")
|
||||
isolated = info["marginMode"] == "ISOLATED_MARGIN"
|
||||
rows = await self.raw_positions()
|
||||
if any(p["positionIdx"] != 0 and Decimal(p["size"]) != 0 for p in rows):
|
||||
raise ExchangeError(None, None, "Bybit 有双向持仓,请先在模拟盘平仓并切换为单向持仓")
|
||||
return [{
|
||||
"symbol": p["symbol"], "positionAmt": ("-" if p["side"] == "Sell" else "") + p["size"],
|
||||
"entryPrice": p["avgPrice"] or "0", "breakEvenPrice": p["breakEvenPrice"] or p["avgPrice"] or "0",
|
||||
"markPrice": p["markPrice"], "unRealizedProfit": p["unrealisedPnl"], "liquidationPrice": p["liqPrice"] or "0",
|
||||
"notional": p["positionValue"], "isolatedMargin": p["positionIM"] if isolated else "0",
|
||||
"initialMargin": p["positionIM"],
|
||||
} for p in rows if Decimal(p["size"]) != 0]
|
||||
|
||||
async def position_mode(self) -> bool:
|
||||
return any(p["positionIdx"] != 0 for p in await self.raw_positions())
|
||||
|
||||
async def symbol_config(self, symbol: str) -> dict:
|
||||
info = await self.request("GET", "/v5/account/info")
|
||||
rows = await self.raw_positions(symbol)
|
||||
return {"leverage": int(Decimal(rows[0]["leverage"] or "0")) if rows else 0,
|
||||
"marginType": "ISOLATED" if info["marginMode"] == "ISOLATED_MARGIN" else "CROSSED"}
|
||||
|
||||
async def ensure_setup(self, symbol: str) -> None:
|
||||
rows = await self.raw_positions(symbol)
|
||||
if any(p["positionIdx"] != 0 for p in rows):
|
||||
await self.request("POST", "/v5/position/switch-mode", {"category": "linear", "symbol": symbol, "mode": 0})
|
||||
config = await self.symbol_config(symbol)
|
||||
if config["marginType"] != "ISOLATED":
|
||||
await self.request("POST", "/v5/account/set-margin-mode", {"setMarginMode": "ISOLATED_MARGIN"})
|
||||
if config["leverage"] != 3:
|
||||
try:
|
||||
await self.request("POST", "/v5/position/set-leverage", {"category": "linear", "symbol": symbol,
|
||||
"buyLeverage": "3", "sellLeverage": "3"})
|
||||
except ExchangeError as e:
|
||||
if e.code != 110043:
|
||||
raise
|
||||
|
||||
async def open_orders(self, symbol: str, is_algo: bool = False) -> list[dict]:
|
||||
rows = await self.pages("/v5/order/realtime", {"category": "linear", "symbol": symbol, "openOnly": 0})
|
||||
return [normalize_order(o, is_algo) for o in rows if bool(o.get("triggerPrice") and Decimal(o["triggerPrice"])) == is_algo]
|
||||
|
||||
async def order(self, symbol: str, client_id: str, is_algo: bool = False) -> dict:
|
||||
params = {"category": "linear", "symbol": symbol, **({"orderId": client_id} if not OWN_CLIENT_ID.fullmatch(client_id) else {"orderLinkId": client_id})}
|
||||
rows = (await self.request("GET", "/v5/order/realtime", params))["list"]
|
||||
if not rows:
|
||||
rows = (await self.request("GET", "/v5/order/history", params))["list"]
|
||||
if not rows:
|
||||
raise ExchangeError(200, 110001, "Bybit 查无此单")
|
||||
return normalize_order(rows[0], is_algo)
|
||||
|
||||
async def actual_order(self, symbol: str, order_id: str) -> dict:
|
||||
return await self.order(symbol, order_id)
|
||||
|
||||
async def place_order(self, symbol: str, client_id: str, spec: OrderSpec, reduce_only: bool) -> dict:
|
||||
params = {"category": "linear", "symbol": symbol, "side": spec.side.title(),
|
||||
"orderType": "Limit" if spec.type == "LIMIT" else "Market", "qty": format(spec.quantity, "f"),
|
||||
"orderLinkId": client_id, "positionIdx": 0, "reduceOnly": reduce_only}
|
||||
if spec.type == "LIMIT":
|
||||
params |= {"price": format(spec.price, "f"), "timeInForce": "GTC"}
|
||||
if spec.type == "STOP_MARKET":
|
||||
params |= {"triggerPrice": format(spec.trigger_price, "f"), "triggerDirection": 1 if spec.side == "BUY" else 2,
|
||||
"triggerBy": "MarkPrice", "closeOnTrigger": True}
|
||||
result = await self.request("POST", "/v5/order/create", params)
|
||||
# ACK 仅表示受理;保持 submitting,下一轮查询实际状态后才允许依赖止损的入场。
|
||||
return {"algoId": result["orderId"], "orderId": result["orderId"], "status": "SUBMITTING"}
|
||||
|
||||
async def cancel_order(self, symbol: str, client_id: str, is_algo: bool = False) -> None:
|
||||
params = {"category": "linear", "symbol": symbol,
|
||||
**({"orderId": client_id} if not OWN_CLIENT_ID.fullmatch(client_id) else {"orderLinkId": client_id})}
|
||||
try:
|
||||
await self.request("POST", "/v5/order/cancel", params)
|
||||
except ExchangeError as e:
|
||||
if e.code != 110001:
|
||||
raise
|
||||
|
||||
async def trades(self, symbol: str | None, since_ms: int) -> list[dict]:
|
||||
# 资金流水提供逐笔已实现盈亏;execution/list 没有该字段,不能伪造为 0。
|
||||
now_ms = int(time.time() * 1000)
|
||||
rows = []
|
||||
while since_ms <= now_ms:
|
||||
end = min(now_ms, since_ms + 7 * 86400_000 - 1)
|
||||
page = await self.pages("/v5/account/transaction-log", {
|
||||
"accountType": "UNIFIED", "category": "linear", "currency": "USDT", "type": "TRADE",
|
||||
"startTime": since_ms, "endTime": end,
|
||||
})
|
||||
rows.extend(normalize_trade(t) for t in page if t["symbol"].endswith("USDT") and (symbol is None or t["symbol"] == symbol))
|
||||
since_ms = end + 1
|
||||
return sorted(rows, key=lambda t: (t["time"], t["id"]))
|
||||
|
||||
|
||||
_instruments: tuple[float, dict[str, dict]] | None = None
|
||||
|
||||
|
||||
async def instruments() -> dict[str, dict]:
|
||||
global _instruments
|
||||
if _instruments is None or time.monotonic() - _instruments[0] > EXCHANGE_INFO_SECONDS:
|
||||
parsed, cursor = {}, None
|
||||
while True:
|
||||
result = await public_get("/v5/market/instruments-info", {"category": "linear", "limit": 1000, **({"cursor": cursor} if cursor else {})})
|
||||
parsed.update({s["symbol"]: s for s in result["list"] if s["contractType"] == "LinearPerpetual" and s["settleCoin"] == "USDT" and s["status"] == "Trading"})
|
||||
next_cursor = result.get("nextPageCursor")
|
||||
if not next_cursor:
|
||||
break
|
||||
if next_cursor == cursor:
|
||||
raise ExchangeError(None, None, "Bybit 交易规则分页游标没有推进")
|
||||
cursor = next_cursor
|
||||
_instruments = time.monotonic(), parsed
|
||||
return _instruments[1]
|
||||
|
||||
|
||||
async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
info = (await instruments()).get(symbol)
|
||||
if info is None:
|
||||
raise ExchangeError(None, None, f"Bybit 模拟盘没有交易对 {symbol}")
|
||||
return SymbolRules(tick_size=step_decimal(info["priceFilter"]["tickSize"]),
|
||||
step_size=step_decimal(info["lotSizeFilter"]["qtyStep"]),
|
||||
min_notional=Decimal(info["lotSizeFilter"]["minNotionalValue"]))
|
||||
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from alphapulse.trading.plan import SymbolRules
|
||||
from alphapulse.trading.plan import OrderSpec, SymbolRules
|
||||
|
||||
BASE_URL = "https://demo-fapi.binance.com"
|
||||
RECV_WINDOW_MS = 5000
|
||||
@@ -24,7 +24,7 @@ OWN_CLIENT_ID = re.compile(r"^ap\d+$")
|
||||
|
||||
|
||||
class ExchangeError(Exception):
|
||||
"""交易所或网络错误;code 为币安错误码(如 -2021),网络层错误时 status、code 为空"""
|
||||
"""交易所或网络错误;code 保留交易所原始错误码,网络错误时 status、code 为空"""
|
||||
|
||||
def __init__(self, status: int | None, code: int | None, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
@@ -34,13 +34,13 @@ class ExchangeError(Exception):
|
||||
|
||||
@property
|
||||
def retryable(self) -> bool:
|
||||
# 只有限流、服务端错误、-1001(内部断连)可以重试;业务错误重试也不会成功
|
||||
return self.status in (418, 429) or (self.status or 0) >= 500 or self.code == -1001
|
||||
# 超时只允许查询确认状态,不代表可以直接重发下单;业务拒绝不能重试
|
||||
return self.status in (408, 418, 429) or (self.status or 0) >= 500 or self.code in (-1001, -1007, 10000, 10006, 10016, 10019)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.status is None:
|
||||
return self.message
|
||||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||||
return f"交易所返回 {self.status}({self.code}):{self.message}"
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
@@ -56,6 +56,8 @@ async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
|
||||
|
||||
class DemoClient:
|
||||
exchange = "binance"
|
||||
|
||||
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})
|
||||
@@ -101,6 +103,88 @@ class DemoClient:
|
||||
raise ExchangeError(resp.status_code, code, body.get("msg", resp.text[:200]) if isinstance(body, dict) else resp.text[:200])
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
async def account(self) -> dict:
|
||||
return await self.request("GET", "/fapi/v3/account")
|
||||
|
||||
async def positions(self) -> list[dict]:
|
||||
return await self.request("GET", "/fapi/v3/positionRisk")
|
||||
|
||||
async def position_mode(self) -> bool:
|
||||
return (await self.request("GET", "/fapi/v1/positionSide/dual"))["dualSidePosition"]
|
||||
|
||||
async def symbol_config(self, symbol: str) -> dict:
|
||||
return (await self.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
|
||||
async def ensure_setup(self, symbol: str) -> None:
|
||||
if await self.position_mode():
|
||||
await self.request("POST", "/fapi/v1/positionSide/dual", {"dualSidePosition": False})
|
||||
config = await self.symbol_config(symbol)
|
||||
if config["marginType"] != "ISOLATED":
|
||||
await self.request("POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"})
|
||||
if config["leverage"] != 3:
|
||||
await self.request("POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": 3})
|
||||
|
||||
async def open_orders(self, symbol: str, is_algo: bool = False) -> list[dict]:
|
||||
return await self.request("GET", "/fapi/v1/openAlgoOrders" if is_algo else "/fapi/v1/openOrders", {"symbol": symbol})
|
||||
|
||||
async def order(self, symbol: str, client_id: str, is_algo: bool = False) -> dict:
|
||||
if is_algo:
|
||||
return await self.request("GET", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
return await self.request("GET", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
|
||||
|
||||
async def actual_order(self, symbol: str, order_id: str) -> dict:
|
||||
return await self.request("GET", "/fapi/v1/order", {"symbol": symbol, "orderId": order_id})
|
||||
|
||||
async def place_order(self, symbol: str, client_id: str, spec: OrderSpec, reduce_only: bool) -> dict:
|
||||
if spec.type == "STOP_MARKET":
|
||||
return await self.request("POST", "/fapi/v1/algoOrder", {
|
||||
"algoType": "CONDITIONAL", "symbol": symbol, "side": spec.side, "type": "STOP_MARKET",
|
||||
"triggerPrice": format(spec.trigger_price, "f"), "quantity": format(spec.quantity, "f"),
|
||||
"reduceOnly": True, "workingType": "MARK_PRICE", "clientAlgoId": client_id,
|
||||
})
|
||||
params = {"symbol": symbol, "side": spec.side, "type": spec.type, "quantity": format(spec.quantity, "f"),
|
||||
"reduceOnly": reduce_only or None, "newClientOrderId": client_id}
|
||||
if spec.type == "LIMIT":
|
||||
params |= {"price": format(spec.price, "f"), "timeInForce": "GTC"}
|
||||
return await self.request("POST", "/fapi/v1/order", params)
|
||||
|
||||
async def cancel_order(self, symbol: str, client_id: str, is_algo: bool = False) -> None:
|
||||
if is_algo:
|
||||
await self.request("DELETE", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
else:
|
||||
await self.request("DELETE", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
|
||||
|
||||
async def trades(self, symbol: str, since_ms: int) -> list[dict]:
|
||||
rows = []
|
||||
params = {"symbol": symbol, "startTime": since_ms, "limit": 1000}
|
||||
while True:
|
||||
page = await self.request("GET", "/fapi/v1/userTrades", params)
|
||||
rows.extend(page)
|
||||
if len(page) < 1000:
|
||||
return rows
|
||||
params = {"symbol": symbol, "fromId": page[-1]["id"] + 1, "limit": 1000}
|
||||
|
||||
|
||||
def credential_key(credential) -> tuple | None:
|
||||
if credential is None or credential.api_key_encrypted is None:
|
||||
return None
|
||||
return credential.exchange, credential.api_key_encrypted, credential.api_secret_encrypted
|
||||
|
||||
|
||||
def create_client(credential):
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.trading.bybit import BybitDemoClient
|
||||
|
||||
client_type = {"binance": DemoClient, "bybit": BybitDemoClient}[credential.exchange]
|
||||
return client_type(decrypt(credential.api_key_encrypted), decrypt(credential.api_secret_encrypted))
|
||||
|
||||
|
||||
async def active_exchange(session) -> str:
|
||||
from alphapulse.models import TradingAccount
|
||||
|
||||
account = await session.get(TradingAccount, 1)
|
||||
return account.exchange if account else "binance"
|
||||
|
||||
|
||||
_exchange_info: tuple[float, dict[str, SymbolRules]] | None = None
|
||||
|
||||
@@ -111,8 +195,11 @@ def step_decimal(raw: str) -> Decimal:
|
||||
return value.quantize(Decimal(1)) if value.as_tuple().exponent > 0 else value
|
||||
|
||||
|
||||
async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
async def symbol_rules(symbol: str, exchange: str = "binance") -> SymbolRules:
|
||||
"""模拟盘的价格精度、数量精度、最小下单金额,按小时缓存"""
|
||||
if exchange == "bybit":
|
||||
from alphapulse.trading.bybit import symbol_rules as bybit_rules
|
||||
return await bybit_rules(symbol)
|
||||
global _exchange_info
|
||||
if _exchange_info is None or time.monotonic() - _exchange_info[0] > EXCHANGE_INFO_SECONDS:
|
||||
info = await public_get("/fapi/v1/exchangeInfo")
|
||||
@@ -130,5 +217,9 @@ async def symbol_rules(symbol: str) -> SymbolRules:
|
||||
return _exchange_info[1][symbol]
|
||||
|
||||
|
||||
async def mark_price(symbol: str) -> Decimal:
|
||||
async def mark_price(symbol: str, exchange: str = "binance") -> Decimal:
|
||||
if exchange == "bybit":
|
||||
from alphapulse.trading.bybit import public_get as bybit_get
|
||||
result = await bybit_get("/v5/market/tickers", {"category": "linear", "symbol": symbol})
|
||||
return Decimal(result["list"][0]["markPrice"])
|
||||
return Decimal((await public_get("/fapi/v1/premiumIndex", {"symbol": symbol}))["markPrice"])
|
||||
@@ -15,7 +15,7 @@ from alphapulse.trading.analyst import run_analysis
|
||||
from alphapulse.trading.executor import executor_loop
|
||||
from alphapulse.trading.news_signals import news_signal_loop
|
||||
from alphapulse.trading.schedule import routine_requests
|
||||
from alphapulse.trading.snapshot import snapshot_loop
|
||||
from alphapulse.trading.snapshot import SnapshotError, read_snapshot, require_fresh_snapshot, snapshot_loop
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.engine")
|
||||
|
||||
@@ -36,6 +36,11 @@ async def fail_interrupted() -> None:
|
||||
|
||||
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
|
||||
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
|
||||
try:
|
||||
require_fresh_snapshot(await read_snapshot())
|
||||
except SnapshotError:
|
||||
# 领取后就会创建分析记录;账户未就绪时保留请求,避免空耗次数和分析间隔。
|
||||
return []
|
||||
async with SessionLocal() as session:
|
||||
symbols = (await session.scalars(
|
||||
select(TradingSymbol)
|
||||
|
||||
@@ -14,20 +14,19 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import BigInteger, cast, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.ai.tools import LOCAL_TZ
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.market.binance import BinanceClient
|
||||
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, mark_price, symbol_rules
|
||||
from alphapulse.trading.fills import sync_fills
|
||||
from alphapulse.models import TradingAccount, TradingAnalysis, TradingCredential, TradingEvent, TradingFill, TradingOrder, TradingSymbol
|
||||
from alphapulse.trading.client import OWN_CLIENT_ID, DemoClient, ExchangeError, create_client, credential_key, mark_price, symbol_rules
|
||||
from alphapulse.trading.fills import save_fills, sync_fills
|
||||
from alphapulse.trading.plan import (
|
||||
MAX_HOLD_HOURS, TAKE_PROFIT_STEPS, OrderSpec, SymbolRules, condition_met, desired_orders, diff_orders, round_to_step,
|
||||
)
|
||||
from alphapulse.trading.positions import build_positions
|
||||
from alphapulse.trading.schedule import request_analysis
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.executor")
|
||||
@@ -49,10 +48,8 @@ PURPOSE_LABELS = {"entry": "入场", "tp": "止盈", "stop": "止损", "close":
|
||||
CLOSE_REASON_LABELS = {"stop": "止损", "take_profit": "止盈", "time": "到达持仓时限", "exit": "模型决定结束",
|
||||
"external": "外部平仓(如在网页手动平仓)", "stop_failed": "止损挂单失败", "close": "市价平仓",
|
||||
"manual_close": "一键平仓", "manual_cancel": "清除挂单"}
|
||||
# 页面上的「平仓」「清除挂单」:API 写入请求,执行器下一轮取走执行,避免和对账同时改计划
|
||||
MANUAL_ACTION_KEY = f"{KEY_PREFIX}trader:manual:{{}}"
|
||||
MANUAL_ACTIONS = {"close": "一键平仓", "cancel": "清除挂单"}
|
||||
CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒"}
|
||||
CANCEL_REASON_LABELS = {"expired": "限价单到期未成交", "stopped": "已停止交易", "entry_failed": "入场单被拒", "replaced": "被新计划替换"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -75,11 +72,11 @@ def utcnow() -> datetime:
|
||||
class Executor:
|
||||
def __init__(self) -> None:
|
||||
self.client: DemoClient | None = None
|
||||
self.key_ciphertext: bytes | None = None
|
||||
self.key_ciphertext: tuple | None = None
|
||||
self.exchange = "binance"
|
||||
self.market = BinanceClient()
|
||||
# 已设置为逐仓 3 倍的币;单向持仓是账户级,只设一次
|
||||
# 每家交易所分别缓存已设置为单向、逐仓 3 倍的币
|
||||
self.configured: set[str] = set()
|
||||
self.one_way_checked = False
|
||||
self.stop_failures: dict[str, int] = {}
|
||||
self.error_events: dict[str, float] = {}
|
||||
# (symbol, 周期) -> (下一根收盘时间 ms, 最近收盘价)
|
||||
@@ -90,17 +87,21 @@ class Executor:
|
||||
|
||||
# ---------- 基础 ----------
|
||||
|
||||
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
|
||||
ciphertext = account.api_key_encrypted if account else None
|
||||
async def ensure_client(self, account: TradingCredential | None) -> DemoClient | None:
|
||||
ciphertext = credential_key(account)
|
||||
if ciphertext != self.key_ciphertext:
|
||||
if self.client is not None:
|
||||
await self.client.http.aclose()
|
||||
self.client = None
|
||||
self.configured.clear()
|
||||
self.one_way_checked = False
|
||||
self.key_ciphertext = ciphertext
|
||||
if account is not None and ciphertext is not None:
|
||||
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
|
||||
self.client = create_client(account)
|
||||
self.exchange = account.exchange
|
||||
self.stop_failures.clear()
|
||||
self.unmanaged_noticed.clear()
|
||||
self.error_events.clear()
|
||||
self.fills_due = 0
|
||||
return self.client
|
||||
|
||||
async def report(self, session: AsyncSession, symbol: str | None, message: str, level: str = "error", analysis_id: int | None = None) -> None:
|
||||
@@ -110,7 +111,7 @@ class Executor:
|
||||
return
|
||||
self.error_events[key] = time.monotonic()
|
||||
logger.warning("%s %s", symbol, message)
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=analysis_id, kind="executor_error", level=level, message=message))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, analysis_id=analysis_id, kind="executor_error", level=level, message=message))
|
||||
|
||||
async def last_close(self, symbol: str, interval: str) -> Decimal:
|
||||
"""实盘该周期最近一根已收盘 K 线的收盘价;到下一根收盘前用缓存"""
|
||||
@@ -131,17 +132,9 @@ class Executor:
|
||||
|
||||
async def ensure_symbol_setup(self, client: DemoClient, symbol: str) -> None:
|
||||
"""单向持仓、逐仓、3 倍杠杆。先查再改:有挂单时即使不需要修改,写操作也可能被拒(-4067)"""
|
||||
if not self.one_way_checked:
|
||||
if (await client.request("GET", "/fapi/v1/positionSide/dual"))["dualSidePosition"]:
|
||||
await client.request("POST", "/fapi/v1/positionSide/dual", {"dualSidePosition": False})
|
||||
self.one_way_checked = True
|
||||
if symbol in self.configured:
|
||||
return
|
||||
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
if config["marginType"] != "ISOLATED":
|
||||
await client.request("POST", "/fapi/v1/marginType", {"symbol": symbol, "marginType": "ISOLATED"})
|
||||
if config["leverage"] != 3:
|
||||
await client.request("POST", "/fapi/v1/leverage", {"symbol": symbol, "leverage": 3})
|
||||
await client.ensure_setup(symbol)
|
||||
self.configured.add(symbol)
|
||||
|
||||
# ---------- 订单 ----------
|
||||
@@ -149,7 +142,7 @@ class Executor:
|
||||
async def place(self, session: AsyncSession, client: DemoClient, symbol: str, plan_id: int | None, purpose: str, spec: OrderSpec) -> ExchangeError | None:
|
||||
"""先写流水并提交,再用 ap{id} 下单。结果不明(网络、5xx)时保持 submitting,下一轮按 id 查询,绝不换 id 重发"""
|
||||
row = TradingOrder(
|
||||
analysis_id=plan_id, symbol=symbol, purpose=purpose, leg=spec.leg, is_algo=spec.type == "STOP_MARKET",
|
||||
exchange=client.exchange, analysis_id=plan_id, symbol=symbol, purpose=purpose, leg=spec.leg, is_algo=spec.type == "STOP_MARKET",
|
||||
side=spec.side, type=spec.type, price=spec.price, trigger_price=spec.trigger_price, quantity=spec.quantity,
|
||||
reduce_only=purpose != "entry", status="submitting",
|
||||
)
|
||||
@@ -157,21 +150,9 @@ class Executor:
|
||||
await session.commit()
|
||||
client_id = f"ap{row.id}"
|
||||
try:
|
||||
if row.is_algo:
|
||||
result = await client.request("POST", "/fapi/v1/algoOrder", {
|
||||
"algoType": "CONDITIONAL", "symbol": symbol, "side": spec.side, "type": "STOP_MARKET",
|
||||
"triggerPrice": fmt(spec.trigger_price), "quantity": fmt(spec.quantity), "reduceOnly": True,
|
||||
"workingType": "MARK_PRICE", "clientAlgoId": client_id,
|
||||
})
|
||||
row.exchange_id, row.status = str(result["algoId"]), "new"
|
||||
else:
|
||||
params = {"symbol": symbol, "side": spec.side, "type": spec.type, "quantity": fmt(spec.quantity),
|
||||
"reduceOnly": row.reduce_only or None, "newClientOrderId": client_id}
|
||||
if spec.type == "LIMIT":
|
||||
params |= {"price": fmt(spec.price), "timeInForce": "GTC"}
|
||||
result = await client.request("POST", "/fapi/v1/order", params)
|
||||
row.exchange_id = str(result["orderId"])
|
||||
row.status = ORDER_STATUSES.get(result["status"], result["status"].lower())
|
||||
result = await client.place_order(symbol, client_id, spec, row.reduce_only)
|
||||
row.exchange_id = str(result["algoId"] if row.is_algo else result["orderId"])
|
||||
row.status = "new" if row.is_algo and client.exchange == "binance" else ORDER_STATUSES.get(result["status"], result["status"].lower())
|
||||
except ExchangeError as e:
|
||||
row.error = str(e)
|
||||
if not (e.retryable or e.status is None):
|
||||
@@ -187,30 +168,27 @@ class Executor:
|
||||
async def cancel_client_id(self, client: DemoClient, symbol: str, client_id: str, is_algo: bool) -> None:
|
||||
"""撤单后不直接改状态:可能恰好已成交,交给下一轮同步确认"""
|
||||
try:
|
||||
if is_algo:
|
||||
await client.request("DELETE", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
else:
|
||||
await client.request("DELETE", "/fapi/v1/order", {"symbol": symbol, "origClientOrderId": client_id})
|
||||
await client.cancel_order(symbol, client_id, is_algo)
|
||||
except ExchangeError as e:
|
||||
# -2011 / -2013:单已不在,等同步
|
||||
if e.code not in (-2011, -2013):
|
||||
if e.code not in (-2011, -2013, 110001):
|
||||
raise
|
||||
|
||||
async def sync_order(self, session: AsyncSession, client: DemoClient, row: TradingOrder, open_orders: dict, open_algos: dict) -> None:
|
||||
client_id = f"ap{row.id}"
|
||||
before = row.filled_qty
|
||||
try:
|
||||
if row.is_algo:
|
||||
if row.is_algo and client.exchange == "binance":
|
||||
if client_id in open_algos:
|
||||
row.exchange_id, row.status = str(open_algos[client_id]["algoId"]), "new"
|
||||
return
|
||||
algo = await client.request("GET", "/fapi/v1/algoOrder", {"clientAlgoId": client_id})
|
||||
algo = await client.order(row.symbol, client_id, True)
|
||||
if algo["algoStatus"] in ("NEW", "TRIGGERING"):
|
||||
row.status = "new"
|
||||
return
|
||||
row.exchange_id = str(algo["algoId"])
|
||||
if algo["algoStatus"] == "FINISHED" and algo.get("actualOrderId"):
|
||||
actual = await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "orderId": algo["actualOrderId"]})
|
||||
actual = await client.actual_order(row.symbol, algo["actualOrderId"])
|
||||
row.filled_qty = Decimal(actual["executedQty"])
|
||||
row.avg_price = Decimal(actual["avgPrice"]) or None
|
||||
row.status = "filled" if actual["status"] == "FILLED" else ORDER_STATUSES.get(actual["status"], "finished")
|
||||
@@ -220,13 +198,15 @@ class Executor:
|
||||
else:
|
||||
row.status = algo["algoStatus"].lower()
|
||||
else:
|
||||
order = open_orders.get(client_id) or await client.request("GET", "/fapi/v1/order", {"symbol": row.symbol, "origClientOrderId": client_id})
|
||||
order = (open_algos if row.is_algo else open_orders).get(client_id) or await client.order(row.symbol, client_id, row.is_algo)
|
||||
row.exchange_id = str(order["orderId"])
|
||||
row.status = ORDER_STATUSES.get(order["status"], order["status"].lower())
|
||||
row.filled_qty = Decimal(order["executedQty"])
|
||||
row.avg_price = Decimal(order["avgPrice"]) or None
|
||||
if row.status == "rejected":
|
||||
row.error = order.get("rejectReason") or "交易所异步拒绝订单"
|
||||
except ExchangeError as e:
|
||||
if e.code != -2013:
|
||||
if e.code not in (-2013, 110001):
|
||||
raise
|
||||
if row.status == "submitting" and utcnow() - row.created_at < timedelta(seconds=SUBMIT_GRACE_SECONDS):
|
||||
return
|
||||
@@ -235,7 +215,7 @@ class Executor:
|
||||
if row.filled_qty > before:
|
||||
delta = row.filled_qty - before
|
||||
leg = f" {row.leg + 1} " if row.purpose == "tp" else ""
|
||||
session.add(TradingEvent(
|
||||
session.add(TradingEvent(exchange=self.exchange,
|
||||
symbol=row.symbol, analysis_id=row.analysis_id, kind=f"{row.purpose}_filled",
|
||||
level="warn" if row.purpose == "stop" else "info",
|
||||
message=f"{PURPOSE_LABELS[row.purpose]}{leg}成交 {fmt(delta)} @ {row.avg_price}",
|
||||
@@ -247,8 +227,10 @@ class Executor:
|
||||
async def adopt(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, current: TradingAnalysis | None,
|
||||
position: Position | None) -> TradingAnalysis | None:
|
||||
"""采纳最新的提议;较早的提议作废。返回采纳后的当前计划"""
|
||||
if current is not None and current.plan_status == "closing":
|
||||
return current
|
||||
proposals = (await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == client.exchange, TradingAnalysis.symbol == symbol.symbol, TradingAnalysis.plan_status == "proposed")
|
||||
.order_by(TradingAnalysis.id.desc())
|
||||
)).all()
|
||||
if not proposals:
|
||||
@@ -261,10 +243,10 @@ class Executor:
|
||||
proposal.plan_status = "applied"
|
||||
if current is not None:
|
||||
current.plan_status, current.close_reason = "closing", "exit"
|
||||
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=current.id, kind="plan_exit", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol.symbol, analysis_id=current.id, kind="plan_exit", level="warn",
|
||||
message=f"按分析 #{proposal.id} 结束计划:撤销入场单,有持仓则市价平仓"))
|
||||
elif position is not None:
|
||||
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_exit", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_exit", level="warn",
|
||||
message=f"按分析 #{proposal.id} 市价平掉无计划的持仓 {fmt(position.qty)}"))
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
await self.place(session, client, symbol.symbol, proposal.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
|
||||
@@ -274,29 +256,46 @@ class Executor:
|
||||
if qty > 0 or (current is not None and current.plan_status != "pending"):
|
||||
proposal.plan_status = "stale"
|
||||
reason = f"已有持仓 {fmt(qty)}" if qty > 0 else "计划正在平仓"
|
||||
session.add(TradingEvent(symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_stale", level="warn",
|
||||
message=f"计划未采纳:{reason}"))
|
||||
return current
|
||||
if current is not None:
|
||||
current.plan_status, current.close_reason, current.closed_at = "replaced", "replaced", now
|
||||
# 撤单回执不保证未成交;旧计划收尾前不能把迟到成交归到新计划。
|
||||
await self.cancel_plan(session, current, "replaced", "等待旧入场单撤销确认后替换计划")
|
||||
return current
|
||||
proposal.plan_status = "pending"
|
||||
proposal.valid_until = now + timedelta(hours=float(proposal.decision["valid_hours"]))
|
||||
entry = proposal.sizing["entry"]
|
||||
side = "做多" if proposal.sizing["direction"] == "long" else "做空"
|
||||
session.add(TradingEvent(
|
||||
session.add(TradingEvent(exchange=self.exchange,
|
||||
symbol=symbol.symbol, analysis_id=proposal.id, kind="plan_adopted",
|
||||
message=f"采纳计划 #{proposal.id}" + (f",替换 #{current.id}" if current else "")
|
||||
+ f":限价{side} {entry['price']} × {entry['quantity']},止损 {entry['stop']}({entry['stop_pct']}%),"
|
||||
message=f"采纳计划 #{proposal.id}:限价{side} {entry['price']} × {entry['quantity']},止损 {entry['stop']}({entry['stop_pct']}%),"
|
||||
f"有效至 {proposal.valid_until.astimezone(LOCAL_TZ).strftime('%H:%M')}",
|
||||
))
|
||||
return proposal
|
||||
|
||||
async def realized(self, client: DemoClient, symbol: str, since: datetime | None) -> dict | None:
|
||||
if since is None:
|
||||
async def realized(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rows: list[TradingOrder]) -> dict | None:
|
||||
if plan.first_fill_at is None:
|
||||
return None
|
||||
trades = await client.request("GET", "/fapi/v1/userTrades", {"symbol": symbol, "startTime": int(since.timestamp() * 1000) - 1000, "limit": 1000})
|
||||
pnl = sum((Decimal(t["realizedPnl"]) for t in trades), Decimal(0))
|
||||
fee = sum((Decimal(t["commission"]) for t in trades if t["commissionAsset"] == "USDT"), Decimal(0))
|
||||
entries = [r for r in rows if r.purpose == "entry" and r.filled_qty > 0]
|
||||
if not entries:
|
||||
raise ExchangeError(None, None, "入场成交尚未同步,暂不能结算计划盈亏")
|
||||
if any(r.exchange_id is None for r in entries):
|
||||
raise RuntimeError("已成交的入场单缺少交易所订单 ID")
|
||||
since = min(r.created_at for r in entries) - timedelta(seconds=1)
|
||||
trades = await client.trades(plan.symbol, int(since.timestamp() * 1000))
|
||||
await save_fills(session, client.exchange, trades)
|
||||
trade_order = cast(TradingFill.trade_id, BigInteger) if client.exchange == "binance" else TradingFill.trade_id
|
||||
fills = list(await session.scalars(select(TradingFill).where(
|
||||
TradingFill.exchange == client.exchange, TradingFill.symbol == plan.symbol, TradingFill.time >= since,
|
||||
).order_by(TradingFill.time, trade_order)))
|
||||
entry_ids = {r.exchange_id for r in entries}
|
||||
positions = [p for p in build_positions(fills) if p.order_ids & entry_ids]
|
||||
complete_entries = all(sum((p.entry_quantities.get(r.exchange_id, Decimal(0)) for p in positions), Decimal(0)) == r.filled_qty for r in entries)
|
||||
if not positions or not complete_entries:
|
||||
raise ExchangeError(None, None, "完整的开平仓成交尚未同步,暂不能结算计划盈亏")
|
||||
pnl = sum((p.realized_pnl for p in positions), Decimal(0))
|
||||
fee = sum((p.commission for p in positions), Decimal(0))
|
||||
return {"realized_pnl": fmt(pnl), "commission": fmt(fee), "net": fmt(pnl - fee)}
|
||||
|
||||
async def finish(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, rows: list[TradingOrder]) -> None:
|
||||
@@ -305,25 +304,25 @@ class Executor:
|
||||
filled = sorted((r for r in rows if r.filled_qty > 0 and r.purpose in ("stop", "tp", "close")), key=lambda r: r.updated_at)
|
||||
last = filled[-1].purpose if filled else None
|
||||
plan.close_reason = {"stop": "stop", "tp": "take_profit", "close": plan.close_reason or "close"}.get(last, "external")
|
||||
plan.plan_status = "closed" if plan.first_fill_at else "canceled"
|
||||
plan.result = await self.realized(session, client, plan, rows)
|
||||
plan.plan_status = "closed" if plan.first_fill_at else "replaced" if plan.close_reason == "replaced" else "canceled"
|
||||
plan.closed_at = utcnow()
|
||||
plan.result = await self.realized(client, plan.symbol, plan.first_fill_at)
|
||||
net = f",净盈亏 {plan.result['net']} USDT(含手续费 {plan.result['commission']})" if plan.result else ""
|
||||
label = CLOSE_REASON_LABELS.get(plan.close_reason, plan.close_reason)
|
||||
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closed", level="warn" if plan.close_reason in ("stop", "stop_failed", "external") else "info",
|
||||
label = CLOSE_REASON_LABELS.get(plan.close_reason) or CANCEL_REASON_LABELS.get(plan.close_reason, plan.close_reason)
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="plan_closed", level="warn" if plan.close_reason in ("stop", "stop_failed", "external") else "info",
|
||||
message=f"计划 #{plan.id} 结束:{label}{net}", data=plan.result))
|
||||
await request_analysis(session, plan.symbol, f"平仓({label})")
|
||||
if plan.close_reason not in ("manual_close", "manual_cancel", "replaced"):
|
||||
await request_analysis(session, plan.symbol, f"平仓({label})")
|
||||
|
||||
async def cancel_plan(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str, level: str = "info") -> None:
|
||||
"""未入场的计划撤销,排队重新分析(停止交易时 request_analysis 不排队)"""
|
||||
plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow()
|
||||
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_canceled", level=level,
|
||||
"""先撤入场单,确认终态和持仓后才结束;迟到的成交仍由原计划保护。"""
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="plan_canceled", level=level,
|
||||
message=f"计划 #{plan.id} 撤销:{detail}"))
|
||||
await request_analysis(session, plan.symbol, f"计划撤销({CANCEL_REASON_LABELS[reason]})")
|
||||
|
||||
async def start_closing(self, session: AsyncSession, plan: TradingAnalysis, reason: str, detail: str = "") -> None:
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="plan_closing", level="warn",
|
||||
message=f"{CLOSE_REASON_LABELS[reason]}{detail}:撤销入场单并市价平仓"))
|
||||
|
||||
async def review_once(self, session: AsyncSession, plan_id: int, symbol: str, kind: str, index: int, message: str, reason: str) -> None:
|
||||
@@ -333,41 +332,63 @@ class Executor:
|
||||
))
|
||||
if exists:
|
||||
return
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=plan_id, kind=kind, level="warn", message=message, data={"index": index}))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, analysis_id=plan_id, kind=kind, level="warn", message=message, data={"index": index}))
|
||||
await request_analysis(session, symbol, reason)
|
||||
|
||||
# ---------- 对账 ----------
|
||||
|
||||
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool, position: Position | None,
|
||||
manual_action: str | None) -> None:
|
||||
async def reconcile(self, session: AsyncSession, client: DemoClient, symbol: TradingSymbol, running: bool,
|
||||
manual_request: TradingEvent | None) -> None:
|
||||
name = symbol.symbol
|
||||
rules = await symbol_rules(name)
|
||||
open_orders = {o["clientOrderId"]: o for o in await client.request("GET", "/fapi/v1/openOrders", {"symbol": name})}
|
||||
open_algos = {o["clientAlgoId"]: o for o in await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": name})}
|
||||
rules = await symbol_rules(name, client.exchange)
|
||||
open_orders = {o["clientOrderId"]: o for o in await client.open_orders(name)}
|
||||
open_algos = {o["clientAlgoId"]: o for o in await client.open_orders(name, True)}
|
||||
|
||||
rows = list((await session.scalars(
|
||||
select(TradingOrder).where(TradingOrder.symbol == name, TradingOrder.status.in_(ACTIVE_ORDER_STATUSES))
|
||||
select(TradingOrder).where(TradingOrder.exchange == client.exchange, TradingOrder.symbol == name, TradingOrder.status.in_(ACTIVE_ORDER_STATUSES))
|
||||
)).all())
|
||||
for row in rows:
|
||||
await self.sync_order(session, client, row, open_orders, open_algos)
|
||||
await session.commit()
|
||||
|
||||
# 同步订单和成交可能耗时,不能用本轮开始前的仓位判断是否已经平仓。
|
||||
position = None
|
||||
for p in await client.positions():
|
||||
if p["symbol"] == name and Decimal(p["positionAmt"]) != 0:
|
||||
amount = Decimal(p["positionAmt"])
|
||||
position = Position("long" if amount > 0 else "short", abs(amount), Decimal(p["entryPrice"]),
|
||||
Decimal(p["breakEvenPrice"]), Decimal(p["markPrice"]))
|
||||
|
||||
# 交易所上本程序的单,但流水里不是活动状态:孤儿单,撤掉(用户手动下的单不动)
|
||||
active_ids = {f"ap{r.id}" for r in rows if r.status in ACTIVE_ORDER_STATUSES}
|
||||
for client_id, order in [*open_orders.items(), *open_algos.items()]:
|
||||
if OWN_CLIENT_ID.match(client_id) and client_id not in active_ids:
|
||||
await self.cancel_client_id(client, name, client_id, client_id in open_algos)
|
||||
session.add(TradingEvent(symbol=name, kind="orphan_canceled", level="warn", message=f"撤销孤儿单 {client_id}({order.get('type') or order.get('orderType')})"))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=name, kind="orphan_canceled", level="warn", message=f"撤销孤儿单 {client_id}({order.get('type') or order.get('orderType')})"))
|
||||
|
||||
plan = await session.scalar(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == name, TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES))
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == client.exchange, TradingAnalysis.symbol == name, TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES))
|
||||
.order_by(TradingAnalysis.id.desc()).limit(1)
|
||||
)
|
||||
plan = await self.adopt(session, client, symbol, plan, position)
|
||||
if manual_request is None and running:
|
||||
pending_old_entry = any(r.purpose == "entry" and r.status in ACTIVE_ORDER_STATUSES and (plan is None or r.analysis_id != plan.id) for r in rows)
|
||||
if not pending_old_entry:
|
||||
plan = await self.adopt(session, client, symbol, plan, position)
|
||||
else:
|
||||
for proposal in await session.scalars(select(TradingAnalysis).where(
|
||||
TradingAnalysis.exchange == client.exchange, TradingAnalysis.symbol == name, TradingAnalysis.plan_status == "proposed",
|
||||
)):
|
||||
proposal.plan_status = "stale"
|
||||
await session.commit()
|
||||
if manual_action is not None:
|
||||
await self.manual(session, client, name, manual_action, plan, position, open_orders, open_algos)
|
||||
if manual_request is not None:
|
||||
await self.manual(session, client, name, manual_request.data["action"], plan, position, open_orders, open_algos)
|
||||
await session.commit()
|
||||
if position is None and not open_orders and not open_algos and not any(r.status in ACTIVE_ORDER_STATUSES for r in rows) and (plan is None or plan.plan_status not in LIVE_PLAN_STATUSES):
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=name, kind="manual_completed",
|
||||
message=f"{MANUAL_ACTIONS[manual_request.data['action']]}完成:已确认无持仓、无挂单",
|
||||
data={"request_id": manual_request.id}))
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
# 不属于当前计划的活动订单(被替换、已结束的计划):入场和止盈先撤;止损等新止损挂上后再撤,不留无保护窗口
|
||||
stale = [r for r in rows if r.status in ACTIVE_ORDER_STATUSES and r.purpose != "close" and (plan is None or r.analysis_id != plan.id)]
|
||||
@@ -378,13 +399,13 @@ class Executor:
|
||||
|
||||
if plan is None:
|
||||
for row in stale:
|
||||
if row.purpose == "stop":
|
||||
if row.purpose == "stop" and position is None and not stale_entry_canceled:
|
||||
await self.cancel(client, row)
|
||||
if position is None:
|
||||
self.unmanaged_noticed.pop(name, None)
|
||||
elif running:
|
||||
elif running and manual_request is None:
|
||||
await self.unmanaged(session, client, name, position, open_algos)
|
||||
if running:
|
||||
if running and manual_request is None:
|
||||
await self.check_reevaluate(session, name, position.mark if position else None)
|
||||
await session.commit()
|
||||
return
|
||||
@@ -397,6 +418,12 @@ class Executor:
|
||||
qty = position.qty if position else Decimal(0)
|
||||
now = utcnow()
|
||||
plan_rows = list((await session.scalars(select(TradingOrder).where(TradingOrder.analysis_id == plan.id))).all())
|
||||
entry_pending = any(r.purpose == "entry" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows)
|
||||
close_pending = any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows)
|
||||
filled_entry = any(r.purpose == "entry" and r.filled_qty > 0 for r in plan_rows)
|
||||
if plan.first_fill_at is None and (qty > 0 or filled_entry):
|
||||
plan.first_fill_at = now
|
||||
plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
|
||||
|
||||
if plan.plan_status == "pending":
|
||||
entry_rows = [r for r in plan_rows if r.purpose == "entry"]
|
||||
@@ -405,7 +432,7 @@ class Executor:
|
||||
if qty > 0 or any(r.filled_qty > 0 for r in entry_rows):
|
||||
plan.plan_status, plan.first_fill_at = "open", now
|
||||
plan.close_deadline = now + timedelta(hours=MAX_HOLD_HOURS)
|
||||
session.add(TradingEvent(symbol=name, analysis_id=plan.id, kind="plan_open",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=name, analysis_id=plan.id, kind="plan_open",
|
||||
message=f"限价单成交,计划 #{plan.id} 进入持仓;最晚 {plan.close_deadline.astimezone(LOCAL_TZ).strftime('%m-%d %H:%M')} 平仓"))
|
||||
elif not running:
|
||||
await self.cancel_plan(session, plan, "stopped", "已停止交易,撤销未成交的限价单")
|
||||
@@ -416,24 +443,31 @@ class Executor:
|
||||
await self.cancel_plan(session, plan, "entry_failed", f"限价单被拒:{rejected.error or rejected.status}", "error")
|
||||
elif plan.plan_status == "open":
|
||||
if qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
if entry_pending or close_pending:
|
||||
await self.start_closing(session, plan, "external")
|
||||
else:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
elif now >= plan.close_deadline:
|
||||
await self.start_closing(session, plan, "time")
|
||||
elif plan.plan_status == "closing" and qty == 0:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
elif plan.plan_status == "closing":
|
||||
if plan.close_reason in CANCEL_REASON_LABELS and (qty > 0 or filled_entry):
|
||||
plan.plan_status, plan.close_reason = "open", None
|
||||
elif qty == 0 and not entry_pending and not close_pending:
|
||||
await self.finish(session, client, plan, plan_rows)
|
||||
await session.commit()
|
||||
|
||||
protected = False
|
||||
if plan.plan_status in LIVE_PLAN_STATUSES:
|
||||
await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
|
||||
protected = await self.sync_orders(session, client, plan, plan_rows, position, running, rules, stale_entry_canceled)
|
||||
else:
|
||||
# 计划已结束:只减仓止损不会被交易所自动撤销,必须主动撤,以免作用到之后的新仓位
|
||||
for row in plan_rows:
|
||||
if row.status in ACTIVE_ORDER_STATUSES and row.purpose != "close":
|
||||
await self.cancel(client, row)
|
||||
for row in stale:
|
||||
if row.purpose == "stop":
|
||||
if row.purpose == "stop" and not stale_entry_canceled and (protected or position is None and plan.plan_status not in LIVE_PLAN_STATUSES):
|
||||
await self.cancel(client, row)
|
||||
if running and plan.plan_status != "closing":
|
||||
if running and manual_request is None and plan.plan_status != "closing":
|
||||
await self.check_reevaluate(session, name, position.mark if position else None)
|
||||
await session.commit()
|
||||
|
||||
@@ -442,21 +476,14 @@ class Executor:
|
||||
"""一键平仓、清除挂单:撤掉该币全部挂单(含止损止盈和手动挂的),有持仓就市价平仓——程序不留没有止损的仓位。
|
||||
本程序的单通过结束计划撤销,否则下一轮对账会按计划补挂回来。撤销计划不像到期那样排队重新分析,免得马上又挂单"""
|
||||
reason = f"manual_{action}"
|
||||
label = MANUAL_ACTIONS[action]
|
||||
for client_id in [*open_orders, *open_algos]:
|
||||
if not OWN_CLIENT_ID.match(client_id):
|
||||
await self.cancel_client_id(client, symbol, client_id, client_id in open_algos)
|
||||
detail = f",市价平仓 {fmt(position.qty)}" if position else ""
|
||||
session.add(TradingEvent(symbol=symbol, analysis_id=plan.id if plan else None, kind=reason, level="warn",
|
||||
message=f"{label}:撤销全部挂单{detail}"))
|
||||
if plan is not None and plan.plan_status in ("pending", "open") and position is not None:
|
||||
# 平仓中的计划:撤入场和止盈、市价平仓,持仓归零后结束并撤掉止损
|
||||
plan.plan_status, plan.close_reason = "closing", reason
|
||||
elif plan is not None and plan.plan_status == "pending":
|
||||
plan.plan_status, plan.close_reason, plan.closed_at = "canceled", reason, utcnow()
|
||||
if plan is not None and plan.close_reason != reason:
|
||||
await self.start_closing(session, plan, reason)
|
||||
elif plan is None and position is not None:
|
||||
closing = await session.scalar(select(func.count()).where(
|
||||
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
TradingOrder.exchange == client.exchange, TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
))
|
||||
if not closing:
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
@@ -465,13 +492,13 @@ class Executor:
|
||||
async def check_reevaluate(self, session: AsyncSession, symbol: str, mark: Decimal | None) -> None:
|
||||
"""最近一次交易中分析给出的 reevaluate 条件:满足任一个就排队分析"""
|
||||
latest = await session.scalar(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.dry_run.is_(False))
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == self.exchange, TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.dry_run.is_(False))
|
||||
.order_by(TradingAnalysis.id.desc()).limit(1)
|
||||
)
|
||||
if latest is None or not latest.decision["reevaluate"]:
|
||||
return
|
||||
if mark is None and any(c["timeframe"] == "mark" for c in latest.decision["reevaluate"]):
|
||||
mark = await mark_price(symbol)
|
||||
mark = await mark_price(symbol, self.exchange)
|
||||
for i, condition in enumerate(latest.decision["reevaluate"]):
|
||||
met, price = await self.condition_met(symbol, condition, mark)
|
||||
if met:
|
||||
@@ -481,24 +508,31 @@ class Executor:
|
||||
async def unmanaged(self, session: AsyncSession, client: DemoClient, symbol: str, position: Position, open_algos: dict) -> None:
|
||||
"""有持仓但没有计划:有止损就只提示,没有止损就市价平仓"""
|
||||
close_side = "SELL" if position.side == "long" else "BUY"
|
||||
has_stop = any(o["side"] == close_side and o["orderType"].startswith("STOP") for o in open_algos.values())
|
||||
has_stop = any(
|
||||
o["side"] == close_side and o["orderType"] == "STOP_MARKET" and (o["reduceOnly"] or o["closePosition"])
|
||||
and (o["closePosition"] or Decimal(o["quantity"]) >= position.qty)
|
||||
for o in open_algos.values()
|
||||
)
|
||||
if has_stop:
|
||||
if self.unmanaged_noticed.get(symbol) != position.entry:
|
||||
self.unmanaged_noticed[symbol] = position.entry
|
||||
session.add(TradingEvent(symbol=symbol, kind="unmanaged_position", level="warn",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, kind="unmanaged_position", level="warn",
|
||||
message=f"{symbol} 有持仓 {fmt(position.qty)} 和止损但没有计划:程序不管理,由止损保护"))
|
||||
return
|
||||
closing = await session.scalar(select(func.count()).where(
|
||||
TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
TradingOrder.exchange == client.exchange, TradingOrder.symbol == symbol, TradingOrder.purpose == "close", TradingOrder.status.in_(ACTIVE_ORDER_STATUSES),
|
||||
))
|
||||
if closing:
|
||||
return
|
||||
session.add(TradingEvent(symbol=symbol, kind="unmanaged_closed", level="error",
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=symbol, kind="unmanaged_closed", level="error",
|
||||
message=f"{symbol} 有持仓 {fmt(position.qty)} 但没有计划也没有止损,市价平仓"))
|
||||
await self.place(session, client, symbol, None, "close", OrderSpec("close", None, close_side, "MARKET", None, None, position.qty))
|
||||
|
||||
async def sync_orders(self, session: AsyncSession, client: DemoClient, plan: TradingAnalysis, plan_rows: list[TradingOrder],
|
||||
position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> None:
|
||||
position: Position | None, running: bool, rules: SymbolRules, block_entries: bool) -> bool:
|
||||
if client.exchange == "bybit" and plan.plan_status != "closing" and any(r.purpose == "stop" and r.status in ("rejected", "failed") for r in plan_rows):
|
||||
await self.start_closing(session, plan, "stop_failed")
|
||||
return False
|
||||
sizing = plan.sizing
|
||||
qty = position.qty if position else Decimal(0)
|
||||
long = sizing["direction"] == "long"
|
||||
@@ -508,10 +542,10 @@ class Executor:
|
||||
for row in active:
|
||||
if row.purpose != "stop":
|
||||
await self.cancel(client, row)
|
||||
if qty > 0 and not any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows):
|
||||
if qty > 0 and plan.close_reason not in CANCEL_REASON_LABELS and not any(r.purpose == "close" and r.status in ACTIVE_ORDER_STATUSES for r in plan_rows):
|
||||
close_side = "SELL" if long else "BUY"
|
||||
await self.place(session, client, plan.symbol, plan.id, "close", OrderSpec("close", None, close_side, "MARKET", None, None, qty))
|
||||
return
|
||||
return False
|
||||
|
||||
entry_rows = [r for r in plan_rows if r.purpose == "entry" and r.filled_qty > 0]
|
||||
filled = sum((r.filled_qty for r in entry_rows), Decimal(0))
|
||||
@@ -541,20 +575,23 @@ class Executor:
|
||||
error = await self.place(session, client, plan.symbol, plan.id, "stop", spec)
|
||||
if error is not None:
|
||||
await self.stop_failed(session, plan, error)
|
||||
return
|
||||
return False
|
||||
self.stop_failures.pop(plan.symbol, None)
|
||||
has_stop = any(r.purpose == "stop" and r.status == "new" for r in active if r not in to_cancel) or (client.exchange == "binance" and any(d.purpose == "stop" for d in to_place))
|
||||
# 2. 撤多余的单
|
||||
canceled_entry = False
|
||||
for row in to_cancel:
|
||||
if row.purpose == "stop" and not has_stop:
|
||||
continue
|
||||
await self.cancel(client, row)
|
||||
canceled_entry = canceled_entry or row.purpose == "entry"
|
||||
# 3. 补止盈;入场单只在本轮没有撤旧入场单、且止损在场时下
|
||||
has_stop = any(r.purpose == "stop" for r in active if r not in to_cancel) or any(d.purpose == "stop" for d in to_place)
|
||||
for spec in [d for d in to_place if d.purpose == "tp"]:
|
||||
await self.place(session, client, plan.symbol, plan.id, "tp", spec)
|
||||
if entries and has_stop and not canceled_entry and not block_entries:
|
||||
for spec in entries:
|
||||
await self.place(session, client, plan.symbol, plan.id, "entry", spec)
|
||||
return has_stop
|
||||
|
||||
async def stop_failed(self, session: AsyncSession, plan: TradingAnalysis, error: ExchangeError) -> None:
|
||||
"""会立即触发(-2021)或不可重试的错误:撤单平仓;可重试的错误连续失败多次后同样处理"""
|
||||
@@ -565,7 +602,7 @@ class Executor:
|
||||
return
|
||||
self.stop_failures.pop(plan.symbol, None)
|
||||
reason = "止损价已越过现价" if error.code == -2021 else f"挂止损失败:{error}"
|
||||
session.add(TradingEvent(symbol=plan.symbol, analysis_id=plan.id, kind="stop_failed", level="error", message=reason))
|
||||
session.add(TradingEvent(exchange=self.exchange, symbol=plan.symbol, analysis_id=plan.id, kind="stop_failed", level="error", message=reason))
|
||||
await self.start_closing(session, plan, "stop_failed")
|
||||
|
||||
# ---------- 主循环 ----------
|
||||
@@ -573,12 +610,13 @@ class Executor:
|
||||
async def tick(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
symbols = list((await session.scalars(select(TradingSymbol))).all())
|
||||
client = await self.ensure_client(account)
|
||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
||||
credential = await session.get(TradingCredential, account.exchange) if account else None
|
||||
client = await self.ensure_client(credential)
|
||||
if client is None:
|
||||
return
|
||||
positions = {}
|
||||
for p in await client.request("GET", "/fapi/v3/positionRisk"):
|
||||
for p in await client.positions():
|
||||
amount = Decimal(p["positionAmt"])
|
||||
if amount != 0:
|
||||
positions[p["symbol"]] = Position(
|
||||
@@ -591,23 +629,30 @@ class Executor:
|
||||
self.fills_due = time.monotonic() + FILLS_SYNC_SECONDS
|
||||
try:
|
||||
async with SessionLocal() as fills_session:
|
||||
fetched = await sync_fills(fills_session, client, {s.symbol for s in symbols} | set(positions))
|
||||
fetched = await sync_fills(fills_session, client, set(symbols) | set(positions))
|
||||
if fetched:
|
||||
logger.info("同步成交记录 %d 条", fetched)
|
||||
except Exception:
|
||||
logger.warning("同步成交记录失败,%ds 后重试", FILLS_SYNC_SECONDS, exc_info=True)
|
||||
for symbol in symbols:
|
||||
for name in symbols:
|
||||
try:
|
||||
manual_action = await redis.getdel(MANUAL_ACTION_KEY.format(symbol.symbol))
|
||||
await self.reconcile(session, client, symbol, symbol.running, positions.get(symbol.symbol), manual_action)
|
||||
symbol = await session.get(TradingSymbol, name)
|
||||
completed = select(TradingEvent.data["request_id"].as_integer()).where(
|
||||
TradingEvent.exchange == client.exchange, TradingEvent.symbol == name, TradingEvent.kind == "manual_completed",
|
||||
)
|
||||
manual_request = await session.scalar(select(TradingEvent).where(
|
||||
TradingEvent.exchange == client.exchange, TradingEvent.symbol == name, TradingEvent.kind == "manual_requested",
|
||||
TradingEvent.id.not_in(completed),
|
||||
).order_by(TradingEvent.id).limit(1))
|
||||
await self.reconcile(session, client, symbol, symbol.running, manual_request)
|
||||
except ExchangeError as e:
|
||||
await session.rollback()
|
||||
await self.report(session, symbol.symbol, f"对账失败:{e}")
|
||||
await self.report(session, name, f"对账失败:{e}")
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.exception("%s 对账出错", symbol.symbol)
|
||||
logger.exception("%s 对账出错", name)
|
||||
await session.rollback()
|
||||
await self.report(session, symbol.symbol, f"对账出错:{e!r}")
|
||||
await self.report(session, name, f"对账出错:{e!r}")
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import BigInteger, cast, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -23,7 +23,7 @@ def to_ms(dt: datetime) -> int:
|
||||
|
||||
async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str, int]:
|
||||
"""用手续费流水(每笔成交一条,不限币种)发现新成交:返回每个币库里还没有的最小 trade id"""
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)))
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)).where(TradingFill.exchange == client.exchange))
|
||||
start = to_ms(latest - OVERLAP if latest else datetime.now(UTC) - timedelta(days=HISTORY_DAYS))
|
||||
found: dict[str, set[int]] = {}
|
||||
while True:
|
||||
@@ -37,7 +37,7 @@ async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str,
|
||||
start = rows[-1]["time"]
|
||||
starts = {}
|
||||
for symbol, ids in found.items():
|
||||
known = set(await session.scalars(select(TradingFill.trade_id).where(TradingFill.symbol == symbol, TradingFill.trade_id.in_(ids))))
|
||||
known = {int(v) for v in await session.scalars(select(TradingFill.trade_id).where(TradingFill.exchange == client.exchange, TradingFill.symbol == symbol, TradingFill.trade_id.in_([str(i) for i in ids])))}
|
||||
if ids - known:
|
||||
starts[symbol] = min(ids - known)
|
||||
return starts
|
||||
@@ -46,9 +46,16 @@ async def missing_starts(session: AsyncSession, client: DemoClient) -> dict[str,
|
||||
async def sync_fills(session: AsyncSession, client: DemoClient, symbols: set[str]) -> int:
|
||||
"""把新成交写入 trading_fills,返回拉到的成交数。symbols 为交易列表和当前持仓的币:
|
||||
每轮按库里最大 trade id 往后查一次,兜底手续费为 0、没有流水的成交"""
|
||||
if client.exchange == "bybit":
|
||||
latest = await session.scalar(select(func.max(TradingFill.time)).where(TradingFill.exchange == client.exchange))
|
||||
start = latest - timedelta(minutes=10) if latest else datetime.now(UTC) - timedelta(days=7)
|
||||
trades = await client.trades(None, to_ms(start))
|
||||
await save_fills(session, client.exchange, trades)
|
||||
await session.commit()
|
||||
return len(trades)
|
||||
starts = await missing_starts(session, client)
|
||||
for symbol in symbols:
|
||||
last = await session.scalar(select(func.max(TradingFill.trade_id)).where(TradingFill.symbol == symbol))
|
||||
last = await session.scalar(select(func.max(cast(TradingFill.trade_id, BigInteger))).where(TradingFill.exchange == client.exchange, TradingFill.symbol == symbol))
|
||||
if last is not None:
|
||||
starts[symbol] = min(starts.get(symbol, last + 1), last + 1)
|
||||
fetched = 0
|
||||
@@ -56,15 +63,21 @@ async def sync_fills(session: AsyncSession, client: DemoClient, symbols: set[str
|
||||
while True:
|
||||
trades = await client.request("GET", "/fapi/v1/userTrades", {"symbol": symbol, "fromId": from_id, "limit": PAGE})
|
||||
if trades:
|
||||
await session.execute(insert(TradingFill).values([{
|
||||
"symbol": symbol, "trade_id": t["id"], "order_id": t["orderId"], "side": t["side"],
|
||||
"price": Decimal(t["price"]), "qty": Decimal(t["qty"]), "realized_pnl": Decimal(t["realizedPnl"]),
|
||||
"commission": Decimal(t["commission"]), "commission_asset": t["commissionAsset"],
|
||||
"time": datetime.fromtimestamp(t["time"] / 1000, UTC),
|
||||
} for t in trades]).on_conflict_do_nothing())
|
||||
await save_fills(session, client.exchange, trades)
|
||||
fetched += len(trades)
|
||||
if len(trades) < PAGE:
|
||||
break
|
||||
from_id = trades[-1]["id"] + 1
|
||||
await session.commit()
|
||||
return fetched
|
||||
|
||||
|
||||
async def save_fills(session: AsyncSession, exchange: str, trades: list[dict]) -> None:
|
||||
if not trades:
|
||||
return
|
||||
await session.execute(insert(TradingFill).values([{
|
||||
"exchange": exchange, "symbol": t["symbol"], "trade_id": str(t["id"]), "order_id": str(t["orderId"]), "side": t["side"],
|
||||
"price": Decimal(t["price"]), "qty": Decimal(t["qty"]), "realized_pnl": Decimal(t["realizedPnl"]),
|
||||
"commission": Decimal(t["commission"]), "commission_asset": t["commissionAsset"],
|
||||
"time": datetime.fromtimestamp(t["time"] / 1000, UTC),
|
||||
} for t in trades]).on_conflict_do_nothing())
|
||||
@@ -62,12 +62,18 @@ def clean(text: str) -> str:
|
||||
return re.sub(r"^(财联社)?\d{1,2}月\d{1,2}日电[,,]?", "", text).strip()[:200]
|
||||
|
||||
|
||||
def is_forward_sample(published_at: datetime, labeled_at: datetime) -> bool:
|
||||
entry_at = published_at.replace(second=0, microsecond=0) + timedelta(minutes=OFFSETS["entry"])
|
||||
return labeled_at < entry_at
|
||||
|
||||
|
||||
async def label_new() -> int:
|
||||
"""给还没打标签的加密新闻打标签,返回本轮标注条数。模型漏掉的条目下一轮重试"""
|
||||
async with SessionLocal() as session:
|
||||
rows = (await session.execute(
|
||||
select(NewsItem.id, news_text()).outerjoin(NewsSignal, NewsSignal.news_id == NewsItem.id)
|
||||
.where(NewsSignal.news_id.is_(None), NewsItem.published_at >= FORWARD_START, news_text().op("~")(CRYPTO_PATTERN))
|
||||
.where(NewsSignal.news_id.is_(None), NewsItem.published_at >= FORWARD_START,
|
||||
NewsItem.published_at >= datetime.now(UTC) - timedelta(minutes=OFFSETS["entry"]), news_text().op("~")(CRYPTO_PATTERN))
|
||||
.order_by(NewsItem.published_at).limit(BATCH)
|
||||
)).all()
|
||||
if not rows:
|
||||
@@ -113,6 +119,8 @@ async def fill_prices() -> int:
|
||||
.where(NewsSignal.prices.is_(None), NewsItem.published_at <= cutoff)
|
||||
)).all()
|
||||
for signal, published_at in pending:
|
||||
if not is_forward_sample(published_at, signal.labeled_at):
|
||||
continue
|
||||
minute = int(published_at.timestamp()) // 60 * 60_000
|
||||
times = {key: minute + offset * 60_000 for key, offset in OFFSETS.items()}
|
||||
prices = {}
|
||||
|
||||
@@ -14,8 +14,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
Direction = Literal["long", "short"]
|
||||
|
||||
# 所有币合计每天(北京时间)最多自动分析的次数
|
||||
DAILY_ANALYSIS_LIMIT = 60
|
||||
RISK_PCT = Decimal("0.01")
|
||||
LOW_CONFIDENCE_FACTOR = Decimal("0.5")
|
||||
MAX_LEVERAGE = 3
|
||||
|
||||
@@ -10,7 +10,7 @@ class Fill(Protocol):
|
||||
"""一笔成交;models.TradingFill 满足此接口"""
|
||||
|
||||
symbol: str
|
||||
order_id: int
|
||||
order_id: str
|
||||
side: str
|
||||
price: Decimal
|
||||
qty: Decimal
|
||||
@@ -36,7 +36,9 @@ class ClosedPosition:
|
||||
# 只计 USDT 计价的手续费,与 Executor.realized 口径一致
|
||||
commission: Decimal = Decimal(0)
|
||||
# 参与这笔仓位的订单,用于关联程序的计划
|
||||
order_ids: set[int] = field(default_factory=set)
|
||||
order_ids: set[str] = field(default_factory=set)
|
||||
# 结算计划时核对入场成交是否全部进入已平仓仓位,不能只凭订单 ID 判断。
|
||||
entry_quantities: dict[str, Decimal] = field(default_factory=dict)
|
||||
# 带符号的持仓量:多为正、空为负
|
||||
position: Decimal = Decimal(0)
|
||||
|
||||
@@ -70,6 +72,7 @@ def build_positions(fills: list[Fill]) -> list[ClosedPosition]:
|
||||
part = remaining
|
||||
pos.quantity += abs(part)
|
||||
pos.open_value += abs(part) * fill.price
|
||||
pos.entry_quantities[fill.order_id] = pos.entry_quantities.get(fill.order_id, Decimal(0)) + abs(part)
|
||||
else:
|
||||
part = remaining if abs(remaining) <= abs(pos.position) else -pos.position
|
||||
pos.close_qty += abs(part)
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.ai.tools import LOCAL_TZ
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.models import TradingAnalysis, TradingEvent, TradingSymbol
|
||||
from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT, MIN_ANALYSIS_GAP, next_routine_at
|
||||
from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
|
||||
from alphapulse.trading.client import active_exchange
|
||||
from alphapulse.trading.plan import MIN_ANALYSIS_GAP, next_routine_at
|
||||
|
||||
# 自动分析失败后的重试:间隔、连续失败上限
|
||||
RETRY_AFTER = timedelta(minutes=5)
|
||||
@@ -16,14 +17,16 @@ MAX_CONSECUTIVE_FAILURES = 3
|
||||
|
||||
|
||||
async def daily_limit_reached(session: AsyncSession) -> bool:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
limit = account.daily_analysis_limit if account else 60
|
||||
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
count = await session.scalar(select(func.count()).where(TradingAnalysis.started_at >= today))
|
||||
if count < DAILY_ANALYSIS_LIMIT:
|
||||
if count < limit:
|
||||
return False
|
||||
noticed = await session.scalar(select(func.count()).where(TradingEvent.kind == "daily_limit", TradingEvent.created_at >= today))
|
||||
if not noticed:
|
||||
session.add(TradingEvent(kind="daily_limit", level="warn",
|
||||
message=f"今日自动分析已达 {DAILY_ANALYSIS_LIMIT} 次上限,之后只执行程序规则(止损、止盈、到期平仓等)"))
|
||||
session.add(TradingEvent(exchange=await active_exchange(session), kind="daily_limit", level="warn",
|
||||
message=f"今日分析已达 {limit} 次上限,暂停自动分析,继续执行程序规则(止损、止盈、到期平仓等)"))
|
||||
return True
|
||||
|
||||
|
||||
@@ -33,7 +36,7 @@ async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> N
|
||||
if target is None or not target.running or await daily_limit_reached(session):
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
last_started = await session.scalar(select(func.max(TradingAnalysis.started_at)).where(TradingAnalysis.symbol == symbol))
|
||||
last_started = await session.scalar(select(func.max(TradingAnalysis.started_at)).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == symbol))
|
||||
at = max(now, last_started + MIN_ANALYSIS_GAP) if last_started else now
|
||||
target.analysis_requested_at = min(target.analysis_requested_at, at) if target.analysis_requested_at else at
|
||||
if reason not in target.analysis_reasons:
|
||||
@@ -49,7 +52,7 @@ async def routine_requests() -> None:
|
||||
if target.analysis_requested_at is not None:
|
||||
continue
|
||||
recent = (await session.scalars(
|
||||
select(TradingAnalysis).where(TradingAnalysis.symbol == target.symbol)
|
||||
select(TradingAnalysis).where(TradingAnalysis.exchange == await active_exchange(session), TradingAnalysis.symbol == target.symbol)
|
||||
.order_by(TradingAnalysis.id.desc()).limit(MAX_CONSECUTIVE_FAILURES)
|
||||
)).all()
|
||||
if recent and recent[0].status == "running":
|
||||
|
||||
@@ -8,11 +8,10 @@ from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import TradingAccount, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
||||
from alphapulse.models import TradingAccount, TradingCredential, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError, create_client, credential_key
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.snapshot")
|
||||
|
||||
@@ -20,21 +19,36 @@ logger = logging.getLogger("alphapulse.trading.snapshot")
|
||||
STATUS_KEY = f"{KEY_PREFIX}trader:status"
|
||||
STATUS_TTL_SECONDS = 15
|
||||
SNAPSHOT_SECONDS = 3
|
||||
SNAPSHOT_FRESH_SECONDS = 30
|
||||
# 杠杆和保证金模式很少变,按币缓存
|
||||
SYMBOL_CONFIG_SECONDS = 60
|
||||
|
||||
|
||||
class SnapshotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def require_fresh_snapshot(snapshot: dict | None) -> dict:
|
||||
"""页面可以显示旧数据,但交易决策必须使用最近一次成功同步的账户。"""
|
||||
if snapshot is None or snapshot["error"] or not snapshot["synced_at"] or not snapshot["account"]:
|
||||
raise SnapshotError("取不到可用的模拟盘账户快照(引擎离线或同步失败)")
|
||||
synced_at = datetime.fromisoformat(snapshot["synced_at"])
|
||||
if (datetime.now(UTC) - synced_at).total_seconds() > SNAPSHOT_FRESH_SECONDS:
|
||||
raise SnapshotError("模拟盘账户快照已过期")
|
||||
return snapshot
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self) -> None:
|
||||
self.client: DemoClient | None = None
|
||||
self.key_ciphertext: bytes | None = None
|
||||
self.key_ciphertext: tuple | None = None
|
||||
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
|
||||
self.data: dict | None = None
|
||||
self.synced_at: datetime | None = None
|
||||
self.symbol_config: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
|
||||
ciphertext = account.api_key_encrypted if account else None
|
||||
async def ensure_client(self, account: TradingCredential | None) -> DemoClient | None:
|
||||
ciphertext = credential_key(account)
|
||||
if ciphertext != self.key_ciphertext:
|
||||
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
|
||||
if self.client is not None:
|
||||
@@ -44,26 +58,26 @@ class Snapshot:
|
||||
self.symbol_config.clear()
|
||||
self.key_ciphertext = ciphertext
|
||||
if account is not None and ciphertext is not None:
|
||||
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
|
||||
self.client = create_client(account)
|
||||
return self.client
|
||||
|
||||
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
|
||||
cached = self.symbol_config.get(symbol)
|
||||
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
|
||||
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
config = await client.symbol_config(symbol)
|
||||
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
|
||||
self.symbol_config[symbol] = cached
|
||||
return cached[1]
|
||||
|
||||
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
|
||||
account = await client.request("GET", "/fapi/v3/account")
|
||||
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
|
||||
account = await client.account()
|
||||
positions = [p for p in await client.positions() if float(p["positionAmt"]) != 0]
|
||||
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
|
||||
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
|
||||
orders, algo_orders = [], []
|
||||
for symbol in watched:
|
||||
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
|
||||
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
|
||||
orders += await client.open_orders(symbol)
|
||||
algo_orders += await client.open_orders(symbol, True)
|
||||
return {
|
||||
"account": {
|
||||
"wallet_balance": account["totalWalletBalance"],
|
||||
@@ -108,9 +122,10 @@ class Snapshot:
|
||||
async def publish(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
credential = await session.get(TradingCredential, account.exchange) if account else None
|
||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
||||
error = None
|
||||
client = await self.ensure_client(account)
|
||||
client = await self.ensure_client(credential)
|
||||
if client is None:
|
||||
error = "还没有填写模拟盘 key"
|
||||
else:
|
||||
@@ -121,6 +136,8 @@ class Snapshot:
|
||||
logger.warning("同步模拟盘失败:%s", e)
|
||||
error = str(e)
|
||||
status = {
|
||||
"exchange": account.exchange if account else "binance",
|
||||
"credential_updated_at": credential.updated_at.isoformat() if credential else None,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
|
||||
"error": error,
|
||||
@@ -143,4 +160,13 @@ async def snapshot_loop() -> None:
|
||||
|
||||
async def read_snapshot() -> dict | None:
|
||||
raw = await redis.get(STATUS_KEY)
|
||||
return json.loads(raw) if raw else None
|
||||
if not raw:
|
||||
return None
|
||||
snapshot = json.loads(raw)
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
exchange = account.exchange if account else "binance"
|
||||
credential = await session.get(TradingCredential, exchange)
|
||||
if snapshot.get("exchange") != exchange or snapshot.get("credential_updated_at") != (credential.updated_at.isoformat() if credential else None):
|
||||
return None
|
||||
return snapshot
|
||||
@@ -0,0 +1,14 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading(monkeypatch):
|
||||
# 测试只替换 I/O 边界,不读取项目凭证,也不连接线上数据库或 Redis。
|
||||
for key, value in {
|
||||
"DATABASE_URL": "postgresql+asyncpg://test:[email protected]/test",
|
||||
"REDIS_URL": "redis://127.0.0.1:1/0", "JWT_SECRET": "test-only",
|
||||
"ADMIN_USERNAME": "test", "SECRET_ENCRYPTION_KEY": "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=",
|
||||
}.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
from alphapulse.trading import executor
|
||||
return executor
|
||||
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from alphapulse.trading.bybit import BybitDemoClient, normalize_order, normalize_trade
|
||||
from alphapulse.trading.client import ExchangeError
|
||||
from alphapulse.trading.plan import OrderSpec
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,params", [("GET", {"symbol": "BTCUSDT", "cursor": "a:b+="}), ("POST", {"reduceOnly": True, "qty": "0.001"})])
|
||||
def test_signature_matches_exact_transmitted_payload(method, params):
|
||||
def handle(request):
|
||||
payload = request.url.query.decode() if method == "GET" else request.content.decode()
|
||||
headers = request.headers
|
||||
signed = headers["X-BAPI-TIMESTAMP"] + "test-key" + headers["X-BAPI-RECV-WINDOW"] + payload
|
||||
assert headers["X-BAPI-SIGN"] == hmac.new(b"test-secret", signed.encode(), hashlib.sha256).hexdigest()
|
||||
assert request.url.host == "api-demo.bybit.com"
|
||||
if method == "POST":
|
||||
assert json.loads(payload) == params
|
||||
return httpx.Response(200, json={"retCode": 0, "result": {"confirmed": True}})
|
||||
|
||||
async def check():
|
||||
async with BybitDemoClient("test-key", "test-secret") as client:
|
||||
await client.http.aclose()
|
||||
client.http = httpx.AsyncClient(base_url="https://api-demo.bybit.com", transport=httpx.MockTransport(handle))
|
||||
assert await client.request(method, "/v5/test", params) == {"confirmed": True}
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_conditional_stop_ack_keeps_execution_unknown():
|
||||
def handle(request):
|
||||
params = json.loads(request.content)
|
||||
assert (params["orderType"], params["triggerDirection"], params["triggerBy"]) == ("Market", 2, "MarkPrice")
|
||||
assert params["reduceOnly"] and params["closeOnTrigger"]
|
||||
assert params["positionIdx"] == 0
|
||||
assert params["orderLinkId"] == "ap42"
|
||||
return httpx.Response(200, json={"retCode": 0, "result": {"orderId": "7b6a537e-6f74-43d9-b775-35d0521c20de"}})
|
||||
|
||||
async def check():
|
||||
async with BybitDemoClient("test-key", "test-secret") as client:
|
||||
await client.http.aclose()
|
||||
client.http = httpx.AsyncClient(base_url="https://api-demo.bybit.com", transport=httpx.MockTransport(handle))
|
||||
result = await client.place_order("BTCUSDT", "ap42", OrderSpec("stop", None, "SELL", "STOP_MARKET", None, Decimal("70000"), Decimal("0.001")), True)
|
||||
assert result["status"] == "SUBMITTING"
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_filled_conditional_order_preserves_execution_and_uuid():
|
||||
order_id = "7b6a537e-6f74-43d9-b775-35d0521c20de"
|
||||
result = normalize_order({
|
||||
"symbol": "BTCUSDT", "orderId": order_id, "orderLinkId": "ap42", "side": "Sell", "orderType": "Market",
|
||||
"orderStatus": "Filled", "price": "", "qty": "0.001", "cumExecQty": "0.001", "avgPrice": "70000",
|
||||
"reduceOnly": True, "createdTime": "1792400000000", "triggerPrice": "70100",
|
||||
"stopOrderType": "Stop", "triggerDirection": 2, "closeOnTrigger": True,
|
||||
}, True)
|
||||
assert (result["status"], result["executedQty"], result["avgPrice"]) == ("FILLED", "0.001", "70000")
|
||||
assert result["actualOrderId"] == result["algoId"] == order_id
|
||||
|
||||
|
||||
def test_trade_uses_realized_cashflow_without_subtracting_fee_twice():
|
||||
result = normalize_trade({"symbol": "BTCUSDT", "tradeId": "trade-uuid", "orderId": "order-uuid", "side": "Sell",
|
||||
"tradePrice": "81000", "qty": "0.001", "cashFlow": "1", "fee": "0.04", "currency": "USDT",
|
||||
"transactionTime": "1792400000000"})
|
||||
assert (result["realizedPnl"], result["commission"], result["id"]) == ("1", "0.04", "trade-uuid")
|
||||
|
||||
|
||||
def test_timeout_response_is_unknown_and_not_resent():
|
||||
calls = []
|
||||
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={"retCode": 10000, "retMsg": "Server Timeout", "result": {}})
|
||||
|
||||
async def check():
|
||||
async with BybitDemoClient("test-key", "test-secret") as client:
|
||||
await client.http.aclose()
|
||||
client.http = httpx.AsyncClient(base_url="https://api-demo.bybit.com", transport=httpx.MockTransport(handle))
|
||||
with pytest.raises(ExchangeError) as error:
|
||||
await client.request("POST", "/v5/order/create", {"orderLinkId": "ap42"})
|
||||
assert error.value.retryable
|
||||
assert len(calls) == 1
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_binance_backend_timeout_is_not_definitive_rejection():
|
||||
assert ExchangeError(408, -1007, "Timeout").retryable
|
||||
@@ -0,0 +1,181 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
|
||||
def install_model_transport(llm, monkeypatch, protocol, handler):
|
||||
monkeypatch.setattr(llm, "decrypt", lambda value: "test-only")
|
||||
module = llm.openai if protocol == "openai" else llm.anthropic
|
||||
name = "AsyncOpenAI" if protocol == "openai" else "AsyncAnthropic"
|
||||
constructor = getattr(module, name)
|
||||
http = httpx if protocol == "openai" else httpx2
|
||||
monkeypatch.setattr(module, name, lambda **kwargs: constructor(**kwargs, http_client=http.AsyncClient(transport=http.MockTransport(handler))))
|
||||
return SimpleNamespace(protocol=protocol, base_url="https://model.invalid", api_key_encrypted=b"test", model="test", max_tokens=100)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol", ["openai", "anthropic"])
|
||||
@pytest.mark.parametrize("complete", [False, True])
|
||||
def test_model_requires_protocol_end_marker(trading, monkeypatch, protocol, complete):
|
||||
from alphapulse.ai import llm
|
||||
if protocol == "openai":
|
||||
chunks = [{"id": "1", "object": "chat.completion.chunk", "created": 0, "model": "test", "choices": [{"index": 0, "delta": {"content": "部分回答"}, "finish_reason": None}]}]
|
||||
if complete:
|
||||
chunks.append(chunks[0] | {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
|
||||
body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n"
|
||||
else:
|
||||
events = [
|
||||
{"type": "message_start", "message": {"id": "1", "type": "message", "role": "assistant", "model": "test", "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 0}}},
|
||||
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "部分回答"}},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}},
|
||||
]
|
||||
if complete:
|
||||
events.append({"type": "message_stop"})
|
||||
body = "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events)
|
||||
|
||||
def handle(request):
|
||||
http = httpx if protocol == "openai" else httpx2
|
||||
return http.Response(200, headers={"content-type": "text/event-stream"}, content=body.encode())
|
||||
|
||||
provider = install_model_transport(llm, monkeypatch, protocol, handle)
|
||||
async def check():
|
||||
output = []
|
||||
try:
|
||||
async for item in llm.stream(provider, "system", [], []):
|
||||
output.append(item)
|
||||
except llm.LlmError:
|
||||
assert not complete
|
||||
else:
|
||||
assert complete
|
||||
assert [x.text for x in output if isinstance(x, llm.TextDelta)] == ["部分回答"]
|
||||
assert any(isinstance(x, llm.Completion) for x in output) == complete
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_connection_failure_uses_only_sdk_retry_budget(trading, monkeypatch):
|
||||
from alphapulse.ai import llm
|
||||
attempts = 0
|
||||
def handle(request):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise httpx.ConnectError("测试连接失败", request=request)
|
||||
provider = install_model_transport(llm, monkeypatch, "openai", handle)
|
||||
async def check():
|
||||
with pytest.raises(llm.LlmError, match="无法连接"):
|
||||
async for _ in llm.stream(provider, "system", [], []):
|
||||
pass
|
||||
asyncio.run(check())
|
||||
assert attempts == llm.MAX_RETRIES + 1
|
||||
|
||||
|
||||
def test_pipeline_error_keeps_history_checkpoint_and_marks_run_failed(trading, monkeypatch):
|
||||
from scrapy import signals
|
||||
from scrapy.crawler import Crawler
|
||||
from twisted.python.failure import Failure
|
||||
from alphapulse.crawler.spiders import base
|
||||
|
||||
class Spider(base.NewsSpider):
|
||||
name = "test"
|
||||
|
||||
now = datetime.now(UTC)
|
||||
target, checkpoint = now - timedelta(days=10), now - timedelta(days=1)
|
||||
source = SimpleNamespace(history_target=target, history_position=checkpoint, consecutive_failures=0)
|
||||
writes = []
|
||||
class Session:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
async def execute(self, statement):
|
||||
writes.append(statement.compile().params)
|
||||
async def get(self, *args, **kwargs):
|
||||
return source
|
||||
async def commit(self):
|
||||
pass
|
||||
monkeypatch.setattr(base, "SessionLocal", Session)
|
||||
async def check():
|
||||
crawler = Crawler(Spider)
|
||||
spider = Spider.from_crawler(crawler)
|
||||
run = base.RunState(uuid.uuid4(), "history", now, target, history_target=target, history_position=target)
|
||||
spider.run = run
|
||||
crawler.signals.send_catch_log(signals.item_error, item={}, response=None, spider=spider, failure=Failure(RuntimeError("写库失败")))
|
||||
spider._close_run()
|
||||
await asyncio.gather(*spider._tasks)
|
||||
assert spider.history_checkpoint is None and source.history_position == checkpoint
|
||||
assert source.consecutive_failures == 1
|
||||
assert writes[0]["status"] == "failed" and "写库失败" in writes[0]["error"]
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_at", ["usage", "second_tool"])
|
||||
def test_chat_completes_tool_results_on_disconnect_or_exception(trading, monkeypatch, failure_at):
|
||||
from alphapulse.api import chat
|
||||
provider = SimpleNamespace(id=1, context_window=10000, max_tokens=100, name="test", reasoning_effort=None)
|
||||
class Session:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
async def scalar(self, *args):
|
||||
return provider
|
||||
def expunge(self, *args):
|
||||
pass
|
||||
async def current(*args):
|
||||
return SimpleNamespace(id=1)
|
||||
async def system(*args):
|
||||
return "system"
|
||||
async def history(*args):
|
||||
return []
|
||||
saved = []
|
||||
async def save(*messages):
|
||||
saved.extend(messages)
|
||||
calls = [{"id": "a", "name": "first", "arguments": {}}, {"id": "b", "name": "second", "arguments": {}}]
|
||||
async def stream(*args):
|
||||
yield chat.llm.Completion(tool_calls=calls)
|
||||
async def tool(name, args):
|
||||
if name == "second":
|
||||
raise RuntimeError("工具失败")
|
||||
return chat.ToolOutcome("实际结果")
|
||||
monkeypatch.setattr(chat, "SessionLocal", Session)
|
||||
monkeypatch.setattr(chat, "active_session", current)
|
||||
monkeypatch.setattr(chat, "build_system_prompt", system)
|
||||
monkeypatch.setattr(chat, "session_messages", history)
|
||||
monkeypatch.setattr(chat, "save_messages", save)
|
||||
monkeypatch.setattr(chat.llm, "stream", stream)
|
||||
monkeypatch.setattr(chat, "run_tool", tool)
|
||||
async def check():
|
||||
generator = chat.send_message(chat.SendIn(text="test"))
|
||||
events = []
|
||||
async for event in generator:
|
||||
events.append(event.event)
|
||||
if failure_at == "usage" and event.event == "usage":
|
||||
await generator.aclose()
|
||||
break
|
||||
results = [m.content for m in saved if m.role == "tool"]
|
||||
assert [r["tool_call_id"] for r in results] == ["a", "b"]
|
||||
if failure_at == "second_tool":
|
||||
assert results[0]["result"] == "实际结果"
|
||||
assert "error" in json.loads(results[1]["result"])
|
||||
assert events[-1] == "error"
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_skill_rename_rejects_whitespace_without_writing(trading):
|
||||
from fastapi import HTTPException
|
||||
from alphapulse.api import skills
|
||||
node = SimpleNamespace(name="原名称")
|
||||
class Session:
|
||||
async def get(self, *args):
|
||||
return node
|
||||
async def check():
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await skills.patch(1, skills.NodePatch(name=" \t "), Session())
|
||||
assert error.value.status_code == 400
|
||||
assert node.name == "原名称"
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_forward_endpoint_excludes_labels_at_or_after_entry(trading):
|
||||
from alphapulse.api.trading import list_news_signals
|
||||
from alphapulse.trading.news_signals import is_forward_sample
|
||||
published = datetime(2026, 10, 20, 12, 0, 59, tzinfo=UTC)
|
||||
entry = published.replace(second=0) + timedelta(minutes=5)
|
||||
assert is_forward_sample(published, entry - timedelta(microseconds=1))
|
||||
assert not is_forward_sample(published, entry)
|
||||
prices = {symbol: {"entry": "100", "1h": "101", "4h": "102"} for symbol in ("BTCUSDT", "ETHUSDT")}
|
||||
rows = [(SimpleNamespace(news_id=i, labeled_at=labeled, category="event", direction="bullish", prices=prices), published, "test", "新闻")
|
||||
for i, labeled in enumerate((entry - timedelta(seconds=1), entry, entry + timedelta(hours=5)), 1)]
|
||||
class Session:
|
||||
async def execute(self, statement):
|
||||
return SimpleNamespace(all=lambda: rows)
|
||||
result = asyncio.run(list_news_signals(Session()))
|
||||
assert result.labeled == result.filled == 1
|
||||
assert [item.news_id for item in result.recent] == [1]
|
||||
assert all(s.n == 1 for s in result.stats)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["complete", "missing_close", "partial_entry"])
|
||||
def test_plan_pnl_includes_early_entry_fee_and_manual_close_only(trading, monkeypatch, scenario):
|
||||
from alphapulse.models import TradingFill
|
||||
start = datetime(2026, 10, 20, 10, tzinfo=UTC)
|
||||
fills = [TradingFill(symbol="BTCUSDT", order_id=order_id, side=side, qty=Decimal(1), price=Decimal(price),
|
||||
realized_pnl=Decimal(pnl), commission=Decimal("0.1"), commission_asset="USDT", time=start + timedelta(seconds=seconds))
|
||||
for seconds, order_id, side, price, pnl in [(1, "entry", "BUY", "100", "0"), (120, "manual", "SELL", "102", "2"),
|
||||
(130, "other", "BUY", "100", "0"), (140, "other-close", "SELL", "200", "100")]
|
||||
if not (scenario == "missing_close" and order_id == "manual")]
|
||||
if scenario == "partial_entry":
|
||||
# 同一张限价单在前一笔仓位归零的同一毫秒继续成交,后一笔平仓记录尚未同步。
|
||||
fills.insert(2, TradingFill(symbol="BTCUSDT", order_id="entry", side="BUY", qty=Decimal(1), price=Decimal(100),
|
||||
realized_pnl=Decimal(0), commission=Decimal("0.1"), commission_asset="USDT", time=start + timedelta(seconds=120)))
|
||||
class Session:
|
||||
async def scalars(self, query):
|
||||
return fills
|
||||
class Client:
|
||||
exchange = "bybit"
|
||||
async def trades(self, symbol, since_ms):
|
||||
assert since_ms <= int(start.timestamp() * 1000)
|
||||
return []
|
||||
async def save(*args):
|
||||
pass
|
||||
monkeypatch.setattr(trading, "save_fills", save)
|
||||
async def check():
|
||||
engine = trading.Executor()
|
||||
p = SimpleNamespace(symbol="BTCUSDT", first_fill_at=start + timedelta(seconds=90))
|
||||
entry = SimpleNamespace(purpose="entry", filled_qty=Decimal(2 if scenario == "partial_entry" else 1), exchange_id="entry", created_at=start)
|
||||
try:
|
||||
if scenario != "complete":
|
||||
with pytest.raises(trading.ExchangeError, match="完整的开平仓成交"):
|
||||
await engine.realized(Session(), Client(), p, [entry])
|
||||
else:
|
||||
assert await engine.realized(Session(), Client(), p, [entry]) == {"realized_pnl": "2", "commission": "0.2", "net": "1.8"}
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,290 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from alphapulse.trading.bybit import normalize_order
|
||||
from alphapulse.trading.client import ExchangeError
|
||||
from alphapulse.trading.plan import SymbolRules
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, scalar=None, groups=()):
|
||||
self.current = scalar
|
||||
self.groups = iter(groups)
|
||||
self.added = []
|
||||
|
||||
def add(self, row):
|
||||
if row.id is None:
|
||||
row.id = 100 + len(self.added)
|
||||
self.added.append(row)
|
||||
|
||||
async def commit(self):
|
||||
for row in self.added:
|
||||
if hasattr(row, "filled_qty") and row.filled_qty is None:
|
||||
row.filled_qty = Decimal(0)
|
||||
|
||||
async def scalar(self, query):
|
||||
return self.current
|
||||
|
||||
async def scalars(self, query):
|
||||
class Rows(list):
|
||||
def all(self):
|
||||
return self
|
||||
return Rows(next(self.groups))
|
||||
|
||||
|
||||
class Client:
|
||||
exchange = "bybit"
|
||||
|
||||
def __init__(self):
|
||||
self.canceled = []
|
||||
self.created = []
|
||||
self.position_rows = []
|
||||
self.entry_status = "NEW"
|
||||
|
||||
async def place_order(self, symbol, client_id, spec, reduce_only):
|
||||
self.created.append(spec)
|
||||
return {"orderId": client_id, "algoId": client_id, "status": "SUBMITTING"}
|
||||
|
||||
async def cancel_order(self, symbol, client_id, is_algo):
|
||||
self.canceled.append(client_id)
|
||||
|
||||
async def open_orders(self, symbol, is_algo=False):
|
||||
return []
|
||||
|
||||
async def order(self, symbol, client_id, is_algo=False):
|
||||
return {"orderId": client_id, "status": self.entry_status,
|
||||
"executedQty": "1" if self.entry_status == "FILLED" else "0", "avgPrice": "100"}
|
||||
|
||||
async def positions(self):
|
||||
return self.position_rows
|
||||
|
||||
|
||||
def plan(trading):
|
||||
return trading.TradingAnalysis(id=1, symbol="BTCUSDT", plan_status="pending", exchange="bybit",
|
||||
sizing={"direction": "long", "entry": {"quantity": "1", "price": "100", "stop": "90"}},
|
||||
valid_until=datetime.now(UTC) + timedelta(hours=1))
|
||||
|
||||
|
||||
def order(trading, purpose, **values):
|
||||
return trading.TradingOrder(id=2 if purpose == "entry" else 3, analysis_id=1, exchange="bybit", symbol="BTCUSDT",
|
||||
purpose=purpose, side="BUY" if purpose == "entry" else "SELL", leg=0 if purpose == "entry" else None,
|
||||
type="LIMIT" if purpose == "entry" else "STOP_MARKET", price=Decimal(100) if purpose == "entry" else None,
|
||||
trigger_price=Decimal(80) if purpose == "stop" else None, quantity=Decimal(1), filled_qty=Decimal(0),
|
||||
is_algo=purpose == "stop", status="new", **values)
|
||||
|
||||
|
||||
RULES = SymbolRules(Decimal("0.1"), Decimal("0.01"), Decimal(5))
|
||||
|
||||
|
||||
def test_stop_replacement_waits_for_exchange_confirmation(trading):
|
||||
async def check():
|
||||
engine, client, session = trading.Executor(), Client(), Session()
|
||||
try:
|
||||
p, old = plan(trading), order(trading, "stop")
|
||||
p.plan_status = "open"
|
||||
position = trading.Position("long", Decimal(1), Decimal(100), Decimal(100), Decimal(101))
|
||||
assert not await engine.sync_orders(session, client, p, [old], position, False, RULES, False)
|
||||
assert client.canceled == []
|
||||
[replacement] = [r for r in session.added if isinstance(r, trading.TradingOrder)]
|
||||
assert replacement.status == "submitting"
|
||||
replacement.status = "new"
|
||||
assert await engine.sync_orders(session, client, p, [old, replacement], position, False, RULES, False)
|
||||
assert client.canceled == ["ap3"]
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_rejected_replacement_does_not_cancel_existing_stop(trading, monkeypatch):
|
||||
async def reject(*args):
|
||||
raise ExchangeError(400, 110092, "止损被拒")
|
||||
async def check():
|
||||
engine, client, session = trading.Executor(), Client(), Session()
|
||||
monkeypatch.setattr(client, "place_order", reject)
|
||||
p = plan(trading)
|
||||
try:
|
||||
protected = await engine.sync_orders(session, client, p, [order(trading, "stop")],
|
||||
trading.Position("long", Decimal(1), Decimal(100), Decimal(100), Decimal(101)), False, RULES, False)
|
||||
assert not protected and client.canceled == []
|
||||
assert p.plan_status == "closing" and p.close_reason == "stop_failed"
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_stopped_pending_plan_keeps_late_fill_and_stop(trading, monkeypatch):
|
||||
async def rules(*args):
|
||||
return RULES
|
||||
monkeypatch.setattr(trading, "symbol_rules", rules)
|
||||
|
||||
async def check():
|
||||
engine, client = trading.Executor(), Client()
|
||||
p, entry, stop = plan(trading), order(trading, "entry"), order(trading, "stop")
|
||||
stop.trigger_price = Decimal(90)
|
||||
symbol = SimpleNamespace(symbol="BTCUSDT")
|
||||
# 首轮撤单只收到 ACK,计划不能结束,也不能撤保护止损。
|
||||
session = Session(p, [[entry, stop], [], [entry, stop]])
|
||||
async def sync(session, client, row, *args):
|
||||
if row.purpose == "entry" and client.entry_status == "FILLED":
|
||||
row.status, row.filled_qty, row.avg_price = "filled", Decimal(1), Decimal(100)
|
||||
monkeypatch.setattr(engine, "sync_order", sync)
|
||||
try:
|
||||
await engine.reconcile(session, client, symbol, False, None)
|
||||
assert p.plan_status == "closing" and p.closed_at is None
|
||||
assert "ap2" in client.canceled and "ap3" not in client.canceled
|
||||
client.entry_status = "FILLED"
|
||||
client.position_rows = [{"symbol": "BTCUSDT", "positionAmt": "1", "entryPrice": "100", "breakEvenPrice": "100", "markPrice": "101"}]
|
||||
await engine.reconcile(Session(p, [[entry, stop], [], [entry, stop]]), client, symbol, False, None)
|
||||
assert p.plan_status == "open" and p.first_fill_at is not None
|
||||
assert all(spec.type != "MARKET" for spec in client.created)
|
||||
assert "ap3" not in client.canceled
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stop_type,direction,expected", [("TakeProfit", 1, "TAKE_PROFIT_MARKET"), ("Stop", 1, "TAKE_PROFIT_MARKET"), ("Stop", 2, "STOP_MARKET")])
|
||||
def test_bybit_conditional_profit_is_not_a_protective_stop(stop_type, direction, expected):
|
||||
result = normalize_order({"symbol": "BTCUSDT", "orderId": "external", "orderLinkId": "", "side": "Sell", "orderType": "Market",
|
||||
"orderStatus": "Untriggered", "price": "", "qty": "1", "cumExecQty": "0", "avgPrice": "",
|
||||
"reduceOnly": True, "createdTime": "1792400000000", "triggerPrice": "110", "stopOrderType": stop_type,
|
||||
"triggerDirection": direction}, True)
|
||||
assert result["orderType"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,quantity,reduce_only", [("TAKE_PROFIT_MARKET", "1", True), ("STOP_MARKET", "0.1", True), ("STOP_MARKET", "1", False)])
|
||||
def test_unmanaged_requires_full_reduce_only_stop(trading, kind, quantity, reduce_only):
|
||||
async def check():
|
||||
engine, session, client = trading.Executor(), Session(0), Client()
|
||||
try:
|
||||
await engine.unmanaged(session, client, "BTCUSDT", trading.Position("long", Decimal(1), Decimal(100), Decimal(100), Decimal(101)),
|
||||
{"external": {"side": "SELL", "orderType": kind, "quantity": quantity, "reduceOnly": reduce_only, "closePosition": False}})
|
||||
assert [spec.type for spec in client.created] == ["MARKET"]
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_analysis_snapshot_rejects_recent_error_and_old_success(trading):
|
||||
from alphapulse.trading.snapshot import SnapshotError, require_fresh_snapshot
|
||||
snapshot = {"account": {"margin_balance": "100"}, "error": None, "synced_at": datetime.now(UTC).isoformat()}
|
||||
assert require_fresh_snapshot(snapshot) is snapshot
|
||||
for change in ({"error": "网络错误"}, {"synced_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat()}):
|
||||
with pytest.raises(SnapshotError):
|
||||
require_fresh_snapshot(snapshot | change)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unavailable", ["missing", "error", "stale"])
|
||||
@pytest.mark.parametrize("reasons", [["例行分析"], ["手动分析"]])
|
||||
def test_analysis_request_waits_for_fresh_snapshot(trading, monkeypatch, unavailable, reasons):
|
||||
from alphapulse.trading import engine
|
||||
|
||||
requested_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
target = trading.TradingSymbol(symbol="BTCUSDT", analysis_requested_at=requested_at, analysis_reasons=list(reasons))
|
||||
fresh = {"account": {"margin_balance": "100"}, "error": None, "synced_at": datetime.now(UTC).isoformat()}
|
||||
snapshot = {
|
||||
"missing": None,
|
||||
"error": fresh | {"error": "账户同步失败"},
|
||||
"stale": fresh | {"synced_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat()},
|
||||
}[unavailable]
|
||||
|
||||
class QueueSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
async def scalars(self, query):
|
||||
return SimpleNamespace(all=lambda: [target] if target.analysis_requested_at is not None else [])
|
||||
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def read_snapshot():
|
||||
return snapshot
|
||||
|
||||
monkeypatch.setattr(engine, "SessionLocal", QueueSession)
|
||||
monkeypatch.setattr(engine, "read_snapshot", read_snapshot)
|
||||
|
||||
async def check():
|
||||
nonlocal snapshot
|
||||
assert await engine.claim_requests(1, set()) == []
|
||||
assert target.analysis_requested_at == requested_at
|
||||
assert target.analysis_reasons == reasons
|
||||
snapshot = fresh
|
||||
assert await engine.claim_requests(1, set()) == [("BTCUSDT", reasons)]
|
||||
assert target.analysis_requested_at is None and target.analysis_reasons == []
|
||||
assert await engine.claim_requests(1, set()) == []
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_manual_request_survives_failure_before_execution(trading, monkeypatch):
|
||||
from alphapulse.api import trading as api
|
||||
|
||||
async def get_symbol(*args):
|
||||
return SimpleNamespace(symbol="BTCUSDT")
|
||||
|
||||
async def fresh(*args):
|
||||
return {}
|
||||
|
||||
async def exchange(*args):
|
||||
return "bybit"
|
||||
|
||||
async def rules(*args):
|
||||
return RULES
|
||||
|
||||
monkeypatch.setattr(api, "get_symbol", get_symbol)
|
||||
monkeypatch.setattr(api, "fresh_snapshot", fresh)
|
||||
monkeypatch.setattr(api, "active_exchange", exchange)
|
||||
monkeypatch.setattr(trading, "symbol_rules", rules)
|
||||
|
||||
async def check():
|
||||
session = Session()
|
||||
await api.manual_action("BTCUSDT", "cancel", session)
|
||||
[request] = session.added
|
||||
engine, client = trading.Executor(), Client()
|
||||
async def unavailable(*args):
|
||||
raise ExchangeError(408, -1007, "结果未知")
|
||||
monkeypatch.setattr(client, "open_orders", unavailable)
|
||||
try:
|
||||
with pytest.raises(ExchangeError) as error:
|
||||
await engine.reconcile(session, client, SimpleNamespace(symbol="BTCUSDT"), True, request)
|
||||
assert error.value.code == -1007
|
||||
assert request.kind == "manual_requested" and request.data == {"action": "cancel"}
|
||||
assert not any(e.kind == "manual_completed" for e in session.added)
|
||||
async def empty(*args):
|
||||
return []
|
||||
monkeypatch.setattr(client, "open_orders", empty)
|
||||
recovered = Session(None, [[], []])
|
||||
await engine.reconcile(recovered, client, SimpleNamespace(symbol="BTCUSDT"), True, request)
|
||||
[completed] = recovered.added
|
||||
assert completed.kind == "manual_completed" and completed.data == {"request_id": request.id}
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_replacement_proposal_survives_old_plan_cancellation(trading):
|
||||
async def check():
|
||||
engine, client = trading.Executor(), Client()
|
||||
current, proposal = plan(trading), plan(trading)
|
||||
proposal.id, proposal.action, proposal.plan_status = 4, "plan", "proposed"
|
||||
proposal.decision = {"valid_hours": 1}
|
||||
proposal.sizing["entry"]["stop_pct"] = "1"
|
||||
symbol = SimpleNamespace(symbol="BTCUSDT")
|
||||
try:
|
||||
assert await engine.adopt(Session(groups=[[proposal]]), client, symbol, current, None) is current
|
||||
assert current.plan_status == "closing" and proposal.plan_status == "proposed"
|
||||
assert await engine.adopt(Session(), client, symbol, current, None) is current
|
||||
assert proposal.plan_status == "proposed"
|
||||
assert await engine.adopt(Session(groups=[[proposal]]), client, symbol, None, None) is proposal
|
||||
assert proposal.plan_status == "pending"
|
||||
finally:
|
||||
await engine.market.http.aclose()
|
||||
asyncio.run(check())
|
||||
@@ -64,7 +64,11 @@ export function testProvider(id: number): Promise<TestResult> {
|
||||
return http.post(`/settings/providers/${id}/test`, null, { timeout: 1900000 })
|
||||
}
|
||||
|
||||
export type ExchangeName = "binance" | "bybit"
|
||||
|
||||
export interface Exchange {
|
||||
exchange: ExchangeName
|
||||
configured_exchanges: ExchangeName[]
|
||||
configured: boolean
|
||||
api_key_hint: string | null
|
||||
/** 是否有币种在交易:交易中不能修改 key */
|
||||
@@ -86,7 +90,7 @@ export function getExchange(): Promise<Exchange> {
|
||||
return http.get("/settings/exchange")
|
||||
}
|
||||
|
||||
export function putExchange(body: { api_key: string, api_secret: string }): Promise<Exchange> {
|
||||
export function putExchange(body: { exchange?: ExchangeName, api_key?: string, api_secret?: string }): Promise<Exchange> {
|
||||
return http.put("/settings/exchange", body)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Effort } from "@/apis/settings"
|
||||
import { http } from "@/http"
|
||||
|
||||
/** 以下数值字段都是币安返回的十进制字符串,避免浮点误差 */
|
||||
/** 以下数值字段都是交易所返回的十进制字符串,避免浮点误差 */
|
||||
export interface SnapshotPosition {
|
||||
symbol: string
|
||||
side: "LONG" | "SHORT"
|
||||
@@ -42,8 +42,9 @@ export interface SnapshotAlgoOrder {
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
exchange: "binance" | "bybit"
|
||||
updated_at: string
|
||||
/** 最近一次成功同步币安的时间 */
|
||||
/** 最近一次成功同步交易所的时间 */
|
||||
synced_at: string | null
|
||||
error: string | null
|
||||
account: { wallet_balance: string, margin_balance: string, available_balance: string, unrealized_pnl: string } | null
|
||||
@@ -53,6 +54,7 @@ export interface Snapshot {
|
||||
}
|
||||
|
||||
export interface TradingStatus {
|
||||
exchange: "binance" | "bybit"
|
||||
configured: boolean
|
||||
provider_id: number | null
|
||||
reasoning_effort: Effort | null
|
||||
@@ -117,7 +119,7 @@ export function patchSymbol(symbol: string, body: { capital_usdt: string | null
|
||||
return http.patch(`/trading/symbols/${symbol}`, body)
|
||||
}
|
||||
|
||||
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
|
||||
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null, daily_analysis_limit?: number }): Promise<TradingStatus> {
|
||||
return http.patch("/trading/config", body)
|
||||
}
|
||||
|
||||
@@ -275,7 +277,7 @@ export const TAKE_PROFIT_STEPS: Record<"long" | "short", [number, number][]> = {
|
||||
short: [[0.005, 0.8], [0.0075, 0.2]]
|
||||
}
|
||||
|
||||
/** 由币安成交记录还原的已平仓仓位(含程序外的手动仓位);数值为十进制字符串 */
|
||||
/** 由当前交易所成交记录还原的已平仓仓位(含程序外的手动仓位);数值为十进制字符串 */
|
||||
export interface PositionHistory {
|
||||
symbol: string
|
||||
direction: "long" | "short"
|
||||
|
||||
@@ -45,6 +45,7 @@ let chart: Chart | null = null
|
||||
let barCallback: ((bar: KLineData) => void) | undefined
|
||||
// 切换交易对或周期后,丢弃旧请求的返回
|
||||
let loadVersion = 0
|
||||
let tickerVersion = 0
|
||||
|
||||
function toBar(r: KlineRow): KLineData {
|
||||
return { timestamp: r[0], open: r[1], high: r[2], low: r[3], close: r[4], volume: r[5], turnover: r[6] }
|
||||
@@ -85,8 +86,11 @@ const { connected, subscribe } = useMarketSocket(
|
||||
)
|
||||
|
||||
async function loadTicker() {
|
||||
const version = ++tickerVersion
|
||||
const { market, symbol } = selection.value
|
||||
const t = await getTicker(market, symbol)
|
||||
if (version !== tickerVersion)
|
||||
return
|
||||
Object.assign(ticker, {
|
||||
last: +t.lastPrice,
|
||||
high: +t.highPrice,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Exchange, Provider, ProviderForm } from "@/apis/settings"
|
||||
import type { Exchange, ExchangeName, Provider, ProviderForm } from "@/apis/settings"
|
||||
import { Plus } from "@element-plus/icons-vue"
|
||||
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
|
||||
import { formatTime } from "@/utils/text"
|
||||
@@ -78,7 +78,7 @@ async function test(p: Provider) {
|
||||
|
||||
load()
|
||||
|
||||
// ---------- 币安模拟盘 ----------
|
||||
// ---------- 模拟盘 ----------
|
||||
|
||||
const exchange = ref<Exchange>()
|
||||
const exchangeDialogVisible = ref(false)
|
||||
@@ -90,6 +90,16 @@ async function loadExchange() {
|
||||
exchange.value = await getExchange()
|
||||
}
|
||||
|
||||
async function changeExchange(value: ExchangeName) {
|
||||
exchangeSaving.value = true
|
||||
try {
|
||||
exchange.value = await putExchange({ exchange: value })
|
||||
ElMessage.success("已切换模拟盘,另一家交易所的凭证和历史记录仍保留")
|
||||
} finally {
|
||||
exchangeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openExchangeEdit() {
|
||||
exchangeForm.value = { api_key: "", api_secret: "" }
|
||||
exchangeDialogVisible.value = true
|
||||
@@ -98,7 +108,7 @@ function openExchangeEdit() {
|
||||
async function saveExchange() {
|
||||
exchangeSaving.value = true
|
||||
try {
|
||||
exchange.value = await putExchange(exchangeForm.value)
|
||||
exchange.value = await putExchange({ exchange: exchange.value?.exchange, ...exchangeForm.value })
|
||||
exchangeDialogVisible.value = false
|
||||
ElMessage.success("已保存")
|
||||
} finally {
|
||||
@@ -253,8 +263,15 @@ loadExchange()
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
币安模拟盘
|
||||
<span class="ml-3 text-xs text-slate-500 font-normal">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
|
||||
模拟盘交易所
|
||||
<el-select :model-value="exchange?.exchange" class="ml-3 w-36" :disabled="exchange?.running || exchangeSaving" @update:model-value="changeExchange">
|
||||
<el-option value="binance" label="币安模拟盘" />
|
||||
<el-option value="bybit" label="Bybit 模拟盘" />
|
||||
</el-select>
|
||||
<div class="mt-2 text-xs text-slate-500 font-normal">
|
||||
{{ exchange?.exchange === "bybit" ? "在 Bybit 主站切换到模拟交易后创建 API Key,并开启合约交易权限;实盘和 Testnet 的 Key 不可用。" : "在 demo.binance.com 的 API 管理创建 Key,并开启合约交易权限。" }}
|
||||
两家凭证分别保存;切换不会撤销原交易所的挂单,旧挂单仍须在原平台处理。
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
|
||||
@@ -285,7 +302,7 @@ loadExchange()
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="exchangeDialogVisible" title="币安模拟盘 API key" width="560px">
|
||||
<el-dialog v-model="exchangeDialogVisible" :title="`${exchange?.exchange === 'bybit' ? 'Bybit' : '币安'}模拟盘 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" />
|
||||
|
||||
@@ -14,6 +14,8 @@ const latest = ref<SkillVersion | null>(null)
|
||||
const content = ref("")
|
||||
const dirty = computed(() => selected.value?.kind === "skill" && content.value !== (latest.value?.content ?? ""))
|
||||
const saving = ref(false)
|
||||
const loadingContent = ref(false)
|
||||
let contentVersion = 0
|
||||
const historyVisible = ref(false)
|
||||
const preview = computed(() => renderMarkdown(content.value))
|
||||
|
||||
@@ -24,13 +26,25 @@ async function loadTree() {
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
if (selected.value?.kind !== "skill") {
|
||||
latest.value = null
|
||||
content.value = ""
|
||||
const version = ++contentVersion
|
||||
const skill = selected.value
|
||||
latest.value = null
|
||||
content.value = ""
|
||||
if (skill?.kind !== "skill") {
|
||||
loadingContent.value = false
|
||||
return
|
||||
}
|
||||
latest.value = (await listSkillVersions(selected.value.id))[0] ?? null
|
||||
content.value = latest.value?.content ?? ""
|
||||
loadingContent.value = true
|
||||
try {
|
||||
const versions = await listSkillVersions(skill.id)
|
||||
if (version !== contentVersion)
|
||||
return
|
||||
latest.value = versions[0] ?? null
|
||||
content.value = latest.value?.content ?? ""
|
||||
} finally {
|
||||
if (version === contentVersion)
|
||||
loadingContent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function select(node: SkillNode) {
|
||||
@@ -43,11 +57,18 @@ async function select(node: SkillNode) {
|
||||
}
|
||||
|
||||
async function saveDraft(): Promise<SkillVersion | null> {
|
||||
if (loadingContent.value || saving.value)
|
||||
return null
|
||||
if (!selected.value || !dirty.value)
|
||||
return latest.value
|
||||
const id = selected.value.id
|
||||
const version = contentVersion
|
||||
saving.value = true
|
||||
try {
|
||||
latest.value = await saveSkillDraft(selected.value.id, content.value)
|
||||
const saved = await saveSkillDraft(id, content.value)
|
||||
if (version !== contentVersion)
|
||||
return null
|
||||
latest.value = saved
|
||||
ElMessage.success(`已保存草稿 v${latest.value.version_no}`)
|
||||
await loadTree()
|
||||
return latest.value
|
||||
@@ -57,11 +78,18 @@ async function saveDraft(): Promise<SkillVersion | null> {
|
||||
}
|
||||
|
||||
async function publishLatest() {
|
||||
const skill = selected.value
|
||||
if (!skill)
|
||||
return
|
||||
const id = skill.id
|
||||
const contentRequest = contentVersion
|
||||
const version = await saveDraft()
|
||||
if (!version || !selected.value)
|
||||
if (!version || selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
|
||||
await publishSkillVersion(selected.value.id, version.id)
|
||||
if (selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
await publishSkillVersion(id, version.id)
|
||||
ElMessage.success(`已发布 v${version.version_no}`)
|
||||
await loadTree()
|
||||
}
|
||||
@@ -148,6 +176,7 @@ loadTree()
|
||||
<div class="min-h-0 flex flex-1 gap-3">
|
||||
<textarea
|
||||
v-model="content" placeholder="用 Markdown 写下投资经验、判断流程或输出规范"
|
||||
:disabled="loadingContent || saving"
|
||||
class="box-border flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
|
||||
/>
|
||||
<el-scrollbar class="flex-1 border border-slate-200 rounded-lg border-solid bg-slate-50/50">
|
||||
@@ -157,10 +186,10 @@ loadTree()
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<el-button :disabled="!dirty" :loading="saving" @click="saveDraft">
|
||||
<el-button :disabled="loadingContent || !dirty" :loading="saving" @click="saveDraft">
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!dirty && !selected.has_draft" :loading="saving" @click="publishLatest">
|
||||
<el-button type="primary" :disabled="loadingContent || (!dirty && !selected.has_draft)" :loading="saving" @click="publishLatest">
|
||||
{{ dirty ? "保存并发布" : "发布最新版本" }}
|
||||
</el-button>
|
||||
<el-button @click="historyVisible = true">
|
||||
|
||||
@@ -33,12 +33,13 @@ const drawerAnalysisId = ref<number | null>(null)
|
||||
const historyVisible = ref(false)
|
||||
const signalsVisible = ref(false)
|
||||
|
||||
const exchangeName = computed(() => status.value?.exchange === "bybit" ? "Bybit" : "币安")
|
||||
const snapshot = computed(() => status.value?.snapshot ?? null)
|
||||
const account = computed(() => snapshot.value?.account ?? null)
|
||||
const positions = computed(() => snapshot.value?.positions ?? [])
|
||||
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
|
||||
const runningCount = computed(() => symbols.value.filter(s => s.running).length)
|
||||
const engineText = computed(() => snapshot.value ? `在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : "离线")
|
||||
const engineText = computed(() => snapshot.value ? `${exchangeName.value} · 在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : `${exchangeName.value} · 离线`)
|
||||
/** 各币交易资金合计,不超过账户权益 */
|
||||
const allocated = computed(() => symbols.value.reduce((sum, s) => sum + Number(s.capital_usdt ?? 0), 0))
|
||||
const symbolOrders = computed(() => {
|
||||
@@ -117,11 +118,12 @@ async function refresh() {
|
||||
}
|
||||
|
||||
async function refreshAnalysis() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
if (!symbol)
|
||||
return
|
||||
const [latest, { plan, orders }] = await Promise.all([listAnalyses({ symbol, limit: 1 }), getSymbolPlan(symbol)])
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
latestAnalysis.value = latest[0] ?? null
|
||||
livePlan.value = plan
|
||||
@@ -155,10 +157,11 @@ function openAnalysis(id: number) {
|
||||
|
||||
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
|
||||
async function refreshEvents() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
const latest = await listEvents({ symbol: symbol || undefined, limit: EVENTS_PAGE })
|
||||
// 请求期间切换了币种:丢弃旧币种的结果
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
const newestId = events.value[0]?.id ?? 0
|
||||
const fresh = latest.filter(e => e.id > newestId)
|
||||
@@ -168,15 +171,18 @@ async function refreshEvents() {
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
const exchange = status.value?.exchange
|
||||
const symbol = selected.value
|
||||
const older = await listEvents({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
|
||||
if (symbol !== selected.value)
|
||||
if (symbol !== selected.value || exchange !== status.value?.exchange)
|
||||
return
|
||||
eventsExhausted.value = older.length < EVENTS_PAGE
|
||||
events.value = [...events.value, ...older]
|
||||
}
|
||||
|
||||
watch(selected, () => {
|
||||
watch([selected, () => status.value?.exchange], () => {
|
||||
historyVisible.value = false
|
||||
drawerVisible.value = false
|
||||
events.value = []
|
||||
latestAnalysis.value = null
|
||||
livePlan.value = null
|
||||
@@ -194,7 +200,7 @@ async function toggleRunning(s: TradingSymbol) {
|
||||
)
|
||||
} else {
|
||||
await ElMessageBox.confirm(
|
||||
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在币安模拟盘下单。`,
|
||||
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在 ${exchangeName.value} 模拟盘下单。`,
|
||||
`开始 ${s.symbol} 行情交易`,
|
||||
{ type: "info", confirmButtonText: "开始" }
|
||||
)
|
||||
@@ -235,6 +241,21 @@ async function changeEffort(effort: Effort | null) {
|
||||
status.value = await patchConfig({ reasoning_effort: effort })
|
||||
}
|
||||
|
||||
async function editDailyLimit() {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
"所有币种和交易所共用,每日零点重新计数。手动分析计入今日次数,但不受自动分析上限限制。降低至已用次数时暂停新的自动分析,进行中的分析和持仓管理继续。",
|
||||
"设置每日分析上限",
|
||||
{
|
||||
inputValue: String(status.value!.today.limit),
|
||||
inputValidator: value => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 2147483647,
|
||||
inputErrorMessage: "请输入 1–2147483647 之间的整数",
|
||||
confirmButtonText: "保存"
|
||||
}
|
||||
)
|
||||
status.value = await patchConfig({ daily_analysis_limit: Number(value) })
|
||||
ElMessage.success("每日分析上限已保存,即时生效")
|
||||
}
|
||||
|
||||
async function closePosition(symbol: string) {
|
||||
await ElMessageBox.confirm(
|
||||
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止 ${symbol} 的行情交易(其他币不受影响)。`,
|
||||
@@ -338,7 +359,12 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
|
||||
<div class="w-56">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
今日分析
|
||||
<span class="flex items-center gap-1">
|
||||
今日分析
|
||||
<el-button link type="primary" size="small" @click="editDailyLimit">
|
||||
调整上限
|
||||
</el-button>
|
||||
</span>
|
||||
<span class="num text-sm text-slate-900 font-semibold">{{ status.today.analyses }} / {{ status.today.limit }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, status.today.analyses / status.today.limit * 100)" :show-text="false" :stroke-width="4" class="my-1" />
|
||||
@@ -372,7 +398,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</el-card>
|
||||
|
||||
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
|
||||
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
|
||||
还没有填写 {{ exchangeName }} 模拟盘 key,请先到「环境变量」页填写。
|
||||
</el-alert>
|
||||
<el-alert v-else-if="snapshot?.error" type="error" :closable="false" show-icon>
|
||||
同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}
|
||||
@@ -470,7 +496,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<el-button v-if="symbols.some(s => s.symbol === row.symbol)" link type="danger" @click="closePosition(row.symbol)">
|
||||
平仓
|
||||
</el-button>
|
||||
<span v-else class="font-sans text-slate-400" title="不在交易列表中,程序不管理,请到币安网页处理">-</span>
|
||||
<span v-else class="font-sans text-slate-400" title="不在交易列表中,程序不管理,请到当前交易所网页处理">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire, Module } from "node:module"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import ts from "typescript"
|
||||
import * as vue from "vue"
|
||||
import { compileScript, parse } from "vue/compiler-sfc"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
interface Version {
|
||||
id: number
|
||||
content: string
|
||||
version_no: number
|
||||
}
|
||||
|
||||
function setupPage(page: string, modules: Record<string, unknown>, confirm: () => Promise<unknown> = async () => {}) {
|
||||
const filename = new URL(`../src/pages/${page}/index.vue`, import.meta.url)
|
||||
const { descriptor } = parse(readFileSync(filename, "utf8"))
|
||||
const script = compileScript(descriptor, { id: page }).content
|
||||
const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const globals = {
|
||||
ref: vue.ref,
|
||||
reactive: vue.reactive,
|
||||
computed: vue.computed,
|
||||
watch: vue.watch,
|
||||
onMounted() {},
|
||||
onBeforeUnmount() {},
|
||||
onBeforeRouteLeave() {},
|
||||
setInterval() {},
|
||||
ElMessage: { success() {} },
|
||||
ElMessageBox: { confirm }
|
||||
}
|
||||
const mod = new Module(filename.pathname)
|
||||
const load = (path: string) => path in modules ? modules[path] : require(path)
|
||||
runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: `${page}.vue` })
|
||||
return mod.exports.default.setup({}, { expose() {} })
|
||||
}
|
||||
|
||||
function skills(api: Record<string, unknown>, confirm?: () => Promise<unknown>) {
|
||||
return setupPage("skills", {
|
||||
"@/apis/skills": api,
|
||||
"@/components/SkillTree.vue": {},
|
||||
"@/components/SkillVersionDrawer.vue": {},
|
||||
"@/utils/markdown": { renderMarkdown: (text: string) => text }
|
||||
}, confirm)
|
||||
}
|
||||
|
||||
async function checkSkillSwitch() {
|
||||
const a = Promise.withResolvers<Version[]>()
|
||||
const b = Promise.withResolvers<Version[]>()
|
||||
const drafts: { id: number, content: string }[] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise,
|
||||
saveSkillDraft: async (id: number, content: string) => {
|
||||
drafts.push({ id, content })
|
||||
return { id: 20, version_no: 2, content }
|
||||
}
|
||||
})
|
||||
await page.loadTree()
|
||||
const first = page.select(nodes[0])
|
||||
const second = page.select(nodes[1])
|
||||
assert.equal(await page.saveDraft(), null)
|
||||
b.resolve([{ id: 2, content: "B 原文", version_no: 1 }])
|
||||
await second
|
||||
a.resolve([{ id: 1, content: "A 迟到", version_no: 1 }])
|
||||
await first
|
||||
assert.equal(page.content.value, "B 原文")
|
||||
page.content.value = "B 修改"
|
||||
await page.saveDraft()
|
||||
assert.equal(drafts.length, 1)
|
||||
assert.equal(drafts[0].id, 2)
|
||||
assert.equal(drafts[0].content, "B 修改")
|
||||
}
|
||||
|
||||
async function checkSkillReload() {
|
||||
const old = Promise.withResolvers<Version[]>()
|
||||
let calls = 0
|
||||
const page = skills({
|
||||
listSkillTree: async () => [{ id: 1, kind: "skill" }],
|
||||
listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }])
|
||||
})
|
||||
await page.loadTree()
|
||||
page.selectedId.value = 1
|
||||
const first = page.loadContent()
|
||||
await page.loadContent()
|
||||
old.resolve([{ id: 1, content: "旧内容", version_no: 1 }])
|
||||
await first
|
||||
assert.equal(page.content.value, "最新")
|
||||
}
|
||||
|
||||
async function checkPublishSwitch() {
|
||||
const saved = Promise.withResolvers<Version>()
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }],
|
||||
saveSkillDraft: () => saved.promise,
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
})
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
page.content.value = "待发布修改"
|
||||
const publishing = page.publishLatest()
|
||||
await page.select(nodes[1])
|
||||
saved.resolve({ id: 10, version_no: 2, content: "待发布修改" })
|
||||
await publishing
|
||||
assert.deepEqual(published, [])
|
||||
assert.equal(page.content.value, "内容 2")
|
||||
}
|
||||
|
||||
async function checkTickerSwitch() {
|
||||
const btc = Promise.withResolvers<Record<string, string>>()
|
||||
const eth = Promise.withResolvers<Record<string, string>>()
|
||||
const page = setupPage("market", {
|
||||
"@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise },
|
||||
"@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||
"./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] },
|
||||
"./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) },
|
||||
"./components/AddSymbolDialog.vue": {},
|
||||
"./components/SymbolManager.vue": {},
|
||||
"klinecharts": {}
|
||||
})
|
||||
const first = page.loadTicker()
|
||||
page.selection.value = { market: "um", symbol: "ETHUSDT" }
|
||||
await vue.nextTick()
|
||||
eth.resolve({ lastPrice: "2000", highPrice: "2100", lowPrice: "1900", volume: "5", quoteVolume: "10000", priceChangePercent: "1" })
|
||||
await vue.nextTick()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
btc.resolve({ lastPrice: "80000", highPrice: "81000", lowPrice: "79000", volume: "1", quoteVolume: "80000", priceChangePercent: "2" })
|
||||
await first
|
||||
assert.equal(page.ticker.last, 2000)
|
||||
assert.equal(page.ticker.changePercent, 1)
|
||||
}
|
||||
|
||||
async function checkPublishConfirmationSwitch() {
|
||||
const confirmation = Promise.withResolvers<void>()
|
||||
const published: [number, number][] = []
|
||||
const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }]
|
||||
const page = skills({
|
||||
listSkillTree: async () => nodes,
|
||||
listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }],
|
||||
publishSkillVersion: async (id: number, version: number) => published.push([id, version])
|
||||
}, () => confirmation.promise)
|
||||
await page.loadTree()
|
||||
await page.select(nodes[0])
|
||||
const publishing = page.publishLatest()
|
||||
await Promise.resolve()
|
||||
await page.select(nodes[1])
|
||||
confirmation.resolve()
|
||||
await publishing
|
||||
assert.deepEqual(published, [])
|
||||
}
|
||||
|
||||
await checkSkillSwitch()
|
||||
await checkSkillReload()
|
||||
await checkPublishSwitch()
|
||||
await checkTickerSwitch()
|
||||
await checkPublishConfirmationSwitch()
|
||||
console.log("页面异步竞态:5 项回归检查通过")
|
||||
Reference in new issue
Block a user