diff --git a/backend/alembic/versions/20261019_b8d1f4a92c60_trading_exchanges.py b/backend/alembic/versions/20261019_b8d1f4a92c60_trading_exchanges.py new file mode 100644 index 0000000..4237a8d --- /dev/null +++ b/backend/alembic/versions/20261019_b8d1f4a92c60_trading_exchanges.py @@ -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") diff --git a/backend/alembic/versions/20261020_d2a4c6e8f103_daily_analysis_limit.py b/backend/alembic/versions/20261020_d2a4c6e8f103_daily_analysis_limit.py new file mode 100644 index 0000000..cbab2f8 --- /dev/null +++ b/backend/alembic/versions/20261020_d2a4c6e8f103_daily_analysis_limit.py @@ -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") diff --git a/backend/src/alphapulse/ai/llm.py b/backend/src/alphapulse/ai/llm.py index 66b38fe..1f1a566 100644 --- a/backend/src/alphapulse/ai/llm.py +++ b/backend/src/alphapulse/ai/llm.py @@ -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 diff --git a/backend/src/alphapulse/api/chat.py b/backend/src/alphapulse/api/chat.py index ba9fcb4..239dd6b 100644 --- a/backend/src/alphapulse/api/chat.py +++ b/backend/src/alphapulse/api/chat.py @@ -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", {}) diff --git a/backend/src/alphapulse/api/settings.py b/backend/src/alphapulse/api/settings.py index 8ff84c7..b310c62 100644 --- a/backend/src/alphapulse/api/settings.py +++ b/backend/src/alphapulse/api/settings.py @@ -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, ) diff --git a/backend/src/alphapulse/api/skills.py b/backend/src/alphapulse/api/skills.py index 37aca3f..ebdd0f9 100644 --- a/backend/src/alphapulse/api/skills.py +++ b/backend/src/alphapulse/api/skills.py @@ -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 )) diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index f5601fb..602a859 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -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: diff --git a/backend/src/alphapulse/crawler/spiders/base.py b/backend/src/alphapulse/crawler/spiders/base.py index 97746d2..ed203e6 100644 --- a/backend/src/alphapulse/crawler/spiders/base.py +++ b/backend/src/alphapulse/crawler/spiders/base.py @@ -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() diff --git a/backend/src/alphapulse/models/__init__.py b/backend/src/alphapulse/models/__init__.py index 4d1b70c..e23d1b6 100644 --- a/backend/src/alphapulse/models/__init__.py +++ b/backend/src/alphapulse/models/__init__.py @@ -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", diff --git a/backend/src/alphapulse/models/trading.py b/backend/src/alphapulse/models/trading.py index a0065c5..767ade6 100644 --- a/backend/src/alphapulse/models/trading.py +++ b/backend/src/alphapulse/models/trading.py @@ -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) diff --git a/backend/src/alphapulse/trading/analyst.py b/backend/src/alphapulse/trading/analyst.py index 267963a..eabcb0a 100644 --- a/backend/src/alphapulse/trading/analyst.py +++ b/backend/src/alphapulse/trading/analyst.py @@ -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 diff --git a/backend/src/alphapulse/trading/bybit.py b/backend/src/alphapulse/trading/bybit.py new file mode 100644 index 0000000..09c3b5a --- /dev/null +++ b/backend/src/alphapulse/trading/bybit.py @@ -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"])) diff --git a/backend/src/alphapulse/trading/client.py b/backend/src/alphapulse/trading/client.py index e930582..c57d56f 100644 --- a/backend/src/alphapulse/trading/client.py +++ b/backend/src/alphapulse/trading/client.py @@ -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"]) diff --git a/backend/src/alphapulse/trading/engine.py b/backend/src/alphapulse/trading/engine.py index ca6341a..990604b 100644 --- a/backend/src/alphapulse/trading/engine.py +++ b/backend/src/alphapulse/trading/engine.py @@ -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) diff --git a/backend/src/alphapulse/trading/executor.py b/backend/src/alphapulse/trading/executor.py index 22e2c70..37652ff 100644 --- a/backend/src/alphapulse/trading/executor.py +++ b/backend/src/alphapulse/trading/executor.py @@ -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() diff --git a/backend/src/alphapulse/trading/fills.py b/backend/src/alphapulse/trading/fills.py index 661ffe9..1ad9526 100644 --- a/backend/src/alphapulse/trading/fills.py +++ b/backend/src/alphapulse/trading/fills.py @@ -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()) diff --git a/backend/src/alphapulse/trading/news_signals.py b/backend/src/alphapulse/trading/news_signals.py index c6890a4..dae28ff 100644 --- a/backend/src/alphapulse/trading/news_signals.py +++ b/backend/src/alphapulse/trading/news_signals.py @@ -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 = {} diff --git a/backend/src/alphapulse/trading/plan.py b/backend/src/alphapulse/trading/plan.py index 1d9b5ef..7759ae0 100644 --- a/backend/src/alphapulse/trading/plan.py +++ b/backend/src/alphapulse/trading/plan.py @@ -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 diff --git a/backend/src/alphapulse/trading/positions.py b/backend/src/alphapulse/trading/positions.py index 1f35e77..8310caf 100644 --- a/backend/src/alphapulse/trading/positions.py +++ b/backend/src/alphapulse/trading/positions.py @@ -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) diff --git a/backend/src/alphapulse/trading/schedule.py b/backend/src/alphapulse/trading/schedule.py index 0ff43bf..82d26e6 100644 --- a/backend/src/alphapulse/trading/schedule.py +++ b/backend/src/alphapulse/trading/schedule.py @@ -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": diff --git a/backend/src/alphapulse/trading/snapshot.py b/backend/src/alphapulse/trading/snapshot.py index 264260f..21c4b79 100644 --- a/backend/src/alphapulse/trading/snapshot.py +++ b/backend/src/alphapulse/trading/snapshot.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a68720a --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.fixture +def trading(monkeypatch): + # 测试只替换 I/O 边界,不读取项目凭证,也不连接线上数据库或 Redis。 + for key, value in { + "DATABASE_URL": "postgresql+asyncpg://test:test@127.0.0.1/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 diff --git a/backend/tests/test_bybit.py b/backend/tests/test_bybit.py new file mode 100644 index 0000000..cec4ef0 --- /dev/null +++ b/backend/tests/test_bybit.py @@ -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 diff --git a/backend/tests/test_failure_paths.py b/backend/tests/test_failure_paths.py new file mode 100644 index 0000000..843fe20 --- /dev/null +++ b/backend/tests/test_failure_paths.py @@ -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()) diff --git a/backend/tests/test_research_accounting.py b/backend/tests/test_research_accounting.py new file mode 100644 index 0000000..807397a --- /dev/null +++ b/backend/tests/test_research_accounting.py @@ -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()) diff --git a/backend/tests/test_trading_safety.py b/backend/tests/test_trading_safety.py new file mode 100644 index 0000000..a65ef7b --- /dev/null +++ b/backend/tests/test_trading_safety.py @@ -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()) diff --git a/web/src/apis/settings.ts b/web/src/apis/settings.ts index 42f3fd4..8e761fe 100644 --- a/web/src/apis/settings.ts +++ b/web/src/apis/settings.ts @@ -64,7 +64,11 @@ export function testProvider(id: number): Promise { 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 { return http.get("/settings/exchange") } -export function putExchange(body: { api_key: string, api_secret: string }): Promise { +export function putExchange(body: { exchange?: ExchangeName, api_key?: string, api_secret?: string }): Promise { return http.put("/settings/exchange", body) } diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index 2964646..f995e65 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -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 { +export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null, daily_analysis_limit?: number }): Promise { 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" diff --git a/web/src/pages/market/index.vue b/web/src/pages/market/index.vue index c7c0cd1..2d890c0 100644 --- a/web/src/pages/market/index.vue +++ b/web/src/pages/market/index.vue @@ -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, diff --git a/web/src/pages/settings/index.vue b/web/src/pages/settings/index.vue index ac209db..61250c9 100644 --- a/web/src/pages/settings/index.vue +++ b/web/src/pages/settings/index.vue @@ -1,5 +1,5 @@