diff --git a/backend/alembic/versions/20261016_a7d3e5f20c18_trading_per_symbol_engine.py b/backend/alembic/versions/20261016_a7d3e5f20c18_trading_per_symbol_engine.py new file mode 100644 index 0000000..a751577 --- /dev/null +++ b/backend/alembic/versions/20261016_a7d3e5f20c18_trading_per_symbol_engine.py @@ -0,0 +1,41 @@ +"""trading per symbol engine + +Revision ID: a7d3e5f20c18 +Revises: f2a6c8d91b47 +Create Date: 2026-10-16 15:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a7d3e5f20c18' +down_revision: Union[str, Sequence[str], None] = 'f2a6c8d91b47' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 用户决定启停和交易资金按币种分开(2026-10-16):原账户资金复制给每个币,全部停止 + op.add_column('trading_symbols', sa.Column('running', sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column('trading_symbols', sa.Column('running_changed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('trading_symbols', sa.Column('capital_usdt', sa.Numeric(), nullable=True)) + op.execute("UPDATE trading_symbols SET capital_usdt = (SELECT capital_usdt FROM trading_account WHERE id = 1)") + op.drop_column('trading_account', 'running') + op.drop_column('trading_account', 'running_changed_at') + op.drop_column('trading_account', 'capital_usdt') + + +def downgrade() -> None: + """Downgrade schema.""" + op.add_column('trading_account', sa.Column('capital_usdt', sa.Numeric(), nullable=True)) + op.add_column('trading_account', sa.Column('running_changed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('trading_account', sa.Column('running', sa.Boolean(), nullable=False, server_default=sa.false())) + op.execute("UPDATE trading_account SET capital_usdt = (SELECT max(capital_usdt) FROM trading_symbols)") + op.drop_column('trading_symbols', 'capital_usdt') + op.drop_column('trading_symbols', 'running_changed_at') + op.drop_column('trading_symbols', 'running') diff --git a/backend/src/alphapulse/api/settings.py b/backend/src/alphapulse/api/settings.py index eeaa1ac..8ff84c7 100644 --- a/backend/src/alphapulse/api/settings.py +++ b/backend/src/alphapulse/api/settings.py @@ -6,7 +6,7 @@ from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field, HttpUrl, StringConstraints -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +14,7 @@ from alphapulse.ai import llm from alphapulse.core.auth import require_admin from alphapulse.core.crypto import decrypt, encrypt from alphapulse.core.db import get_session -from alphapulse.models import AiProvider, TradingAccount +from alphapulse.models import AiProvider, TradingAccount, TradingSymbol from alphapulse.trading.client import DemoClient, ExchangeError router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)]) @@ -186,25 +186,29 @@ class ExchangeTestResult(BaseModel): error: str | None = None -def exchange_out(account: TradingAccount | None) -> ExchangeOut: +async def any_running(session: AsyncSession) -> bool: + return bool(await session.scalar(select(func.count()).where(TradingSymbol.running))) + + +async def exchange_out(session: AsyncSession, account: TradingAccount | None) -> ExchangeOut: if account is None: return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None) return ExchangeOut( configured=account.api_key_encrypted is not None, api_key_hint=account.api_key_hint, - running=account.running, updated_at=account.updated_at, + running=await any_running(session), updated_at=account.updated_at, ) async def editable_account(session: AsyncSession) -> TradingAccount: account = await session.get(TradingAccount, 1) - if account is not None and account.running: - raise HTTPException(status.HTTP_409_CONFLICT, "模拟盘正在交易,请先停止再修改 key") + if await any_running(session): + raise HTTPException(status.HTTP_409_CONFLICT, "有币种正在交易,请先全部停止再修改 key") return account or TradingAccount(id=1) @router.get("/exchange") async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut: - return exchange_out(await session.get(TradingAccount, 1)) + return await exchange_out(session, await session.get(TradingAccount, 1)) @router.put("/exchange") @@ -216,7 +220,7 @@ async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depend session.add(account) await session.commit() await session.refresh(account) - return exchange_out(account) + return await exchange_out(session, account) @router.delete("/exchange") @@ -226,7 +230,7 @@ async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)] session.add(account) await session.commit() await session.refresh(account) - return exchange_out(account) + return await exchange_out(session, account) @router.post("/exchange/test") diff --git a/backend/src/alphapulse/api/trading.py b/backend/src/alphapulse/api/trading.py index 490ef3b..e6ef709 100644 --- a/backend/src/alphapulse/api/trading.py +++ b/backend/src/alphapulse/api/trading.py @@ -45,11 +45,6 @@ class TodayUsage(BaseModel): class StatusOut(BaseModel): configured: bool - running: bool - running_changed_at: datetime | None - capital_usdt: Decimal | None - # 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算 - effective_capital: Decimal | None provider_id: int | None reasoning_effort: str | None # trader 进程的心跳快照;进程离线时为空 @@ -58,7 +53,6 @@ class StatusOut(BaseModel): class ConfigIn(BaseModel): - capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None = None provider_id: int | None = None reasoning_effort: llm.Effort | None = None @@ -73,12 +67,21 @@ class SymbolOut(BaseModel): added_at: datetime analysis_requested_at: datetime | None analyzing: bool + running: bool + running_changed_at: datetime | None + capital_usdt: Decimal | None + # 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算 + effective_capital: Decimal | None # 当前计划:pending / open / closing,没有计划为空 plan_id: int | None plan_status: str | None close_deadline: datetime | None +class SymbolConfigIn(BaseModel): + capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None + + class SymbolIn(BaseModel): symbol: str @@ -177,7 +180,7 @@ class PositionHistoryPage(BaseModel): async def get_account(session: AsyncSession) -> TradingAccount: - return await session.get(TradingAccount, 1) or TradingAccount(id=1, running=False) + return await session.get(TradingAccount, 1) or TradingAccount(id=1) @router.get("/status") @@ -191,64 +194,19 @@ 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() - snapshot = await read_snapshot() - equity = snapshot and snapshot["account"] and Decimal(snapshot["account"]["margin_balance"]) return StatusOut( - configured=account.api_key_encrypted is not None, running=account.running, - running_changed_at=account.running_changed_at, capital_usdt=account.capital_usdt, - effective_capital=min(account.capital_usdt, equity) if account.capital_usdt and equity else None, - provider_id=account.provider_id, reasoning_effort=account.reasoning_effort, snapshot=snapshot, + configured=account.api_key_encrypted is not None, 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), ) -async def set_running(session: AsyncSession, running: bool) -> StatusOut: - account = await get_account(session) - if running and account.api_key_encrypted is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有填写模拟盘 key,请先到环境变量页填写") - if running and account.capital_usdt is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有设置交易资金") - if account.running != running: - account.running = running - account.running_changed_at = datetime.now(LOCAL_TZ) - session.add(account) - session.add(TradingEvent( - kind="started" if running else "stopped", - message="开始行情交易" if running else "停止行情交易:不再分析,撤销未成交的入场单;已有持仓的止损、止盈等程序规则继续执行", - )) - await session.commit() - return await get_status(session) - - -@router.post("/start") -async def start(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut: - return await set_running(session, True) - - -@router.post("/stop") -async def stop(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut: - return await set_running(session, False) - - @router.patch("/config") async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut: account = await get_account(session) fields = body.model_dump(exclude_unset=True) if fields.get("provider_id") is not None and await session.get(AiProvider, fields["provider_id"]) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "模型配置不存在") - if "capital_usdt" in fields: - capital = fields["capital_usdt"] - if capital is None and account.running: - raise HTTPException(status.HTTP_409_CONFLICT, "交易中不能清空交易资金") - snapshot = await read_snapshot() - equity = snapshot and snapshot["account"] and Decimal(snapshot["account"]["margin_balance"]) - if capital is not None and equity and capital > equity: - raise HTTPException(status.HTTP_400_BAD_REQUEST, f"交易资金不能超过账户权益 {equity:.2f} USDT") - if capital != account.capital_usdt: - session.add(TradingEvent( - kind="capital_changed", - message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金", - )) for key, value in fields.items(): setattr(account, key, value) session.add(account) @@ -284,9 +242,71 @@ async def list_symbol_options() -> list[SymbolOption]: return [SymbolOption.model_validate(o) for o in await symbol_options()] +async def current_equity() -> Decimal | None: + """账户权益,取自 trader 的快照;进程离线或未同步到币安时为空""" + snapshot = await read_snapshot() + return Decimal(snapshot["account"]["margin_balance"]) if snapshot and snapshot["account"] else None + + +async def get_symbol(session: AsyncSession, symbol: str) -> TradingSymbol: + s = await session.get(TradingSymbol, symbol) + if s is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") + return s + + +async def set_running(session: AsyncSession, s: TradingSymbol, running: bool) -> None: + if running and (await get_account(session)).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 s.running != running: + s.running = running + s.running_changed_at = datetime.now(LOCAL_TZ) + session.add(TradingEvent( + symbol=s.symbol, kind="started" if running else "stopped", + message="开始行情交易" if running else "停止行情交易:不再分析,撤销未成交的入场单;已有持仓的止损、止盈等程序规则继续执行", + )) + await session.commit() + + +@router.post("/symbols/{symbol}/start") +async def start(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + await set_running(session, await get_symbol(session, symbol), True) + + +@router.post("/symbols/{symbol}/stop") +async def stop(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + await set_running(session, await get_symbol(session, symbol), False) + + +@router.patch("/symbols/{symbol}") +async def patch_symbol(symbol: str, body: SymbolConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None: + """设置该币的交易资金:各币合计不超过账户权益(取不到权益时不校验)""" + s = await get_symbol(session, symbol) + capital = body.capital_usdt + if capital == s.capital_usdt: + return + if capital is None and s.running: + raise HTTPException(status.HTTP_409_CONFLICT, "交易中不能清空交易资金") + equity = await current_equity() + if capital is not None and equity: + others = await session.scalar( + select(func.coalesce(func.sum(TradingSymbol.capital_usdt), 0)).where(TradingSymbol.symbol != symbol) + ) + if capital + others > equity: + 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", + message=f"交易资金设为 {capital} USDT" if capital is not None else "清空交易资金")) + await session.commit() + + @router.get("/symbols") 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()) plans = {p.symbol: p for p in await session.scalars( select(TradingAnalysis).where(TradingAnalysis.plan_status.in_(LIVE_PLAN_STATUSES)).order_by(TradingAnalysis.id) @@ -294,7 +314,9 @@ async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) - return [ SymbolOut( symbol=s.symbol, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at, - analyzing=s.symbol in analyzing, plan_id=plans[s.symbol].id if s.symbol in plans else None, + analyzing=s.symbol in analyzing, running=s.running, running_changed_at=s.running_changed_at, capital_usdt=s.capital_usdt, + effective_capital=min(s.capital_usdt, equity) if s.capital_usdt and equity else None, + plan_id=plans[s.symbol].id if s.symbol in plans else None, plan_status=plans[s.symbol].plan_status if s.symbol in plans else None, close_deadline=plans[s.symbol].close_deadline if s.symbol in plans else None, ) @@ -354,14 +376,13 @@ async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(ge @router.post("/symbols/{symbol}/manual/{action}", status_code=status.HTTP_202_ACCEPTED) async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> None: - """一键平仓(同时停止行情交易)、清除挂单:交给执行器执行,结果写入时间线""" + """一键平仓(同时停止该币的行情交易)、清除挂单:交给执行器执行,结果写入时间线""" label = "平仓" if action == "close" else "清除挂单" - if await session.get(TradingSymbol, symbol) is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") + s = await get_symbol(session, symbol) await fresh_snapshot(symbol, label) if action == "close": # 不停止的话,平仓后会重新分析,可能马上又开仓 - await set_running(session, False) + await set_running(session, s, False) await redis.set(MANUAL_ACTION_KEY.format(symbol), action, ex=MANUAL_ACTION_TTL_SECONDS) @@ -435,12 +456,10 @@ async def list_position_history_symbols(session: Annotated[AsyncSession, Depends @router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED) async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: - """排队一次手动分析:引擎停止时为试运行,计划只记录、不下单""" - s = await session.get(TradingSymbol, symbol) - if s is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") - if (await get_account(session)).capital_usdt is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有设置交易资金") + """排队一次手动分析:该币停止交易时为试运行,计划只记录、不下单""" + 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")) if running or s.analysis_requested_at is not None: raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中") diff --git a/backend/src/alphapulse/models/trading.py b/backend/src/alphapulse/models/trading.py index 626266b..e9da887 100644 --- a/backend/src/alphapulse/models/trading.py +++ b/backend/src/alphapulse/models/trading.py @@ -9,7 +9,7 @@ from alphapulse.core.db import Base class TradingAccount(Base): - """币安模拟盘账户:单行(id=1)。key 只以密文存储;running 是引擎启停的唯一来源""" + """币安模拟盘账户:单行(id=1)。key 只以密文存储;启停和交易资金按币种设置,见 TradingSymbol""" __tablename__ = "trading_account" __table_args__ = (CheckConstraint("id = 1", name="trading_account_single_row"),) @@ -18,10 +18,6 @@ class TradingAccount(Base): api_key_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary) api_secret_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary) api_key_hint: Mapped[str | None] = mapped_column(String(8)) - running: Mapped[bool] = mapped_column(Boolean, default=False) - running_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # 交易资金:仓位计算按 min(交易资金, 账户权益),不动用账户的全部余额;未设置时不能开始交易 - capital_usdt: Mapped[Decimal | None] = mapped_column(Numeric) # 交易分析用的模型配置;为空时用默认配置 provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL")) reasoning_effort: Mapped[str | None] = mapped_column(String(8)) @@ -33,6 +29,11 @@ class TradingSymbol(Base): symbol: Mapped[str] = mapped_column(String(32), primary_key=True) added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + # 该币引擎启停的唯一来源:停止时不自动分析、不挂新的入场单,已有持仓的程序规则照常执行 + running: Mapped[bool] = mapped_column(Boolean, default=False) + running_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # 该币的交易资金:仓位按 min(交易资金, 账户权益) 计算;各币合计不超过账户权益;未设置时不能开始交易 + capital_usdt: Mapped[Decimal | None] = mapped_column(Numeric) # 待执行的分析请求:多个触发在执行前合并为一次 analysis_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) analysis_reasons: Mapped[list[str]] = mapped_column(JSONB, default=list) diff --git a/backend/src/alphapulse/trading/analyst.py b/backend/src/alphapulse/trading/analyst.py index c35f41d..6ae0294 100644 --- a/backend/src/alphapulse/trading/analyst.py +++ b/backend/src/alphapulse/trading/analyst.py @@ -64,7 +64,7 @@ INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币 - 止损:区间远端外 {rules.STOP_ATR_FACTOR}×ATR14(5m),做多放在 zone_low 下方,做空放在 zone_high 上方;且距挂单价至少 {rules.MIN_STOP_ATR}×ATR14(5m),挂单价贴近远端时按这条算。 - 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后撤销未成交的入场部分,止损移到保本价。 - 首次成交后最长持仓 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。 -- 仓位:单笔风险为交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。 +- 仓位:单笔风险为该币交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位不超过 {rules.MAX_LEVERAGE} 倍该币交易资金。 - reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个),用来表达另一侧方向、需要重新划区的位置。 ## 程序的硬性校验 @@ -137,15 +137,16 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared target = await session.get(TradingSymbol, symbol) if target is None: raise AnalysisError(f"{symbol} 已不在交易列表中") - if account is None or account.capital_usdt is None: - raise AnalysisError("还没有设置交易资金") + if account is None: + raise AnalysisError("还没有填写模拟盘 key") + if target.capital_usdt is None: + raise AnalysisError(f"{symbol} 还没有设置交易资金") provider = await session.get(AiProvider, account.provider_id) if account.provider_id else await session.scalar( select(AiProvider).where(AiProvider.is_default) ) if provider is None: raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加") session.expunge(provider) - symbols = list((await session.scalars(select(TradingSymbol.symbol))).all()) recent = (await session.scalars( select(TradingAnalysis) .where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id) @@ -163,20 +164,20 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared )).all() ] if current else [] system = "\n\n".join([INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)]) - dry_run = not account.running + dry_run = not target.running + 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("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位") equity = Decimal(snapshot["account"]["margin_balance"]) - capital = min(account.capital_usdt, equity) + capital = min(capital_usdt, equity) position = next((p for p in snapshot["positions"] if p["symbol"] == symbol), None) - other_notional = sum((Decimal(p["notional"]) for p in snapshot["positions"] if p["symbol"] != symbol and p["symbol"] in symbols), Decimal(0)) symbol_rules_ = await symbol_rules(symbol) mark = await mark_price(symbol) context = PlanContext( - mark_price=mark, capital=capital, other_notional=other_notional, rules=symbol_rules_, atr=await recent_atr(symbol), + 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), ) @@ -186,12 +187,12 @@ async def prepare(symbol: str, reasons: list[str], analysis_id: int) -> Prepared "## 任务", f"- 币种:{symbol}(币安 U 本位永续)", f"- 触发原因:{';'.join(reasons)}", - f"- 运行模式:{'试运行(引擎已停止,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}", + f"- 运行模式:{'试运行(该币未在交易,计划只记录、不下单)' if dry_run else '交易中(计划会在模拟盘执行)'}", "", "## 账户(程序据此计算数量)", - f"- 交易资金:{capital:.2f} USDT(设定 {account.capital_usdt} USDT,账户权益 {equity:.2f} USDT,取较小者)", + f"- 该币交易资金:{capital:.2f} USDT(设定 {capital_usdt} USDT,账户权益 {equity:.2f} USDT,取较小者)", f"- 本次单笔风险金额:{risk:.2f} USDT({_pct(rules.RISK_PCT)};置信度低时 {risk * rules.LOW_CONFIDENCE_FACTOR:.2f})", - f"- 可用名义仓位:{capital * rules.MAX_LEVERAGE - other_notional:.2f} USDT({rules.MAX_LEVERAGE} 倍交易资金减去交易列表其他币的持仓 {other_notional:.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"- 最近已收盘 5m K 线的 ATR14:{context.atr:.{max(0, -symbol_rules_.tick_size.as_tuple().exponent)}f}(程序按它计算止损)", @@ -266,8 +267,8 @@ 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: - account = await session.get(TradingAccount, 1) - analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (account and account.running), status="running") + target = await session.get(TradingSymbol, symbol) + analysis = TradingAnalysis(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/executor.py b/backend/src/alphapulse/trading/executor.py index 314ef87..22e2c70 100644 --- a/backend/src/alphapulse/trading/executor.py +++ b/backend/src/alphapulse/trading/executor.py @@ -599,7 +599,7 @@ class Executor: for symbol in symbols: try: manual_action = await redis.getdel(MANUAL_ACTION_KEY.format(symbol.symbol)) - await self.reconcile(session, client, symbol, account.running, positions.get(symbol.symbol), manual_action) + await self.reconcile(session, client, symbol, symbol.running, positions.get(symbol.symbol), manual_action) except ExchangeError as e: await session.rollback() await self.report(session, symbol.symbol, f"对账失败:{e}") diff --git a/backend/src/alphapulse/trading/plan.py b/backend/src/alphapulse/trading/plan.py index 897b14d..1d9b5ef 100644 --- a/backend/src/alphapulse/trading/plan.py +++ b/backend/src/alphapulse/trading/plan.py @@ -93,10 +93,8 @@ class SymbolRules: @dataclass class PlanContext: mark_price: Decimal - # min(设定的交易资金, 账户权益) + # 该币的 min(交易资金, 账户权益) capital: Decimal - # 交易列表中其他币的持仓名义价值合计 - other_notional: Decimal rules: SymbolRules # 最近已收盘 5m K 线的 ATR14,用于计算止损 atr: Decimal @@ -154,9 +152,8 @@ def setup_stop(direction: Direction, entry: Decimal, far_edge: Decimal, atr_valu return round_to_step(max(far_edge + STOP_ATR_FACTOR * atr_value, entry + MIN_STOP_ATR * atr_value), tick, ROUND_CEILING) -def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: Decimal, other_notional: Decimal, - confidence: str, rules: SymbolRules) -> dict: - """按入场价和止损算数量:单笔风险上限与全账户杠杆取小。止损离入场价太远、方向不对或数量不足时抛 PlanError""" +def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: Decimal, confidence: str, rules: SymbolRules) -> dict: + """按入场价和止损算数量:单笔风险上限与该币资金的杠杆上限取小。止损离入场价太远、方向不对或数量不足时抛 PlanError""" sign = 1 if direction == "long" else -1 stop_pct = sign * (price - stop) / price if stop_pct <= 0: @@ -165,9 +162,7 @@ def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: raise PlanError([f"止损 {stop} 距入场价 {price} {stop_pct * 100:.2f}%,超过 {MAX_STOP_PCT * 100:.0f}%"]) unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1) - max_notional = capital * MAX_LEVERAGE - other_notional - if max_notional <= 0: - raise PlanError([f"交易列表其他币的名义仓位 {other_notional:.2f} 已达到 {MAX_LEVERAGE} 倍交易资金,不能开新仓"]) + max_notional = capital * MAX_LEVERAGE by_risk = risk_amount / unit_loss by_leverage = max_notional / price quantity = round_to_step(min(by_risk, by_leverage), rules.step_size, ROUND_FLOOR) @@ -246,8 +241,7 @@ def check_setup(plan: TradePlan, ctx: PlanContext) -> dict: raise PlanError(errors) stop = setup_stop(direction, entry, setup.zone_low if long else setup.zone_high, ctx.atr, rules.tick_size) try: - sizing = size_entry(direction, entry, stop, capital=ctx.capital, other_notional=ctx.other_notional, - confidence=plan.confidence, rules=rules) + sizing = size_entry(direction, entry, stop, capital=ctx.capital, confidence=plan.confidence, rules=rules) except PlanError as e: raise PlanError([f"止损 {stop}(区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m),且距挂单价至少 " f"{MIN_STOP_ATR}×ATR(5m),ATR={ctx.atr.quantize(rules.tick_size)}):{e}。" diff --git a/backend/src/alphapulse/trading/schedule.py b/backend/src/alphapulse/trading/schedule.py index 2007971..0ff43bf 100644 --- a/backend/src/alphapulse/trading/schedule.py +++ b/backend/src/alphapulse/trading/schedule.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from alphapulse.ai.tools import LOCAL_TZ from alphapulse.core.db import SessionLocal -from alphapulse.models import TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol +from alphapulse.models import TradingAnalysis, TradingEvent, TradingSymbol from alphapulse.trading.plan import DAILY_ANALYSIS_LIMIT, MIN_ANALYSIS_GAP, next_routine_at # 自动分析失败后的重试:间隔、连续失败上限 @@ -29,9 +29,8 @@ async def daily_limit_reached(session: AsyncSession) -> bool: async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> None: """排队一次自动分析:多个原因合并;距上次分析不足最短间隔时顺延。调用方负责 commit""" - account = await session.get(TradingAccount, 1) target = await session.get(TradingSymbol, symbol) - if account is None or not account.running or target is None or await daily_limit_reached(session): + 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)) @@ -43,13 +42,10 @@ async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> N async def routine_requests() -> None: - """交易中:到例行时间的币排队例行分析;自动分析失败的隔一段时间重试""" + """交易中的币:到例行时间的排队例行分析;自动分析失败的隔一段时间重试""" async with SessionLocal() as session: - account = await session.get(TradingAccount, 1) - if account is None or not account.running: - return now = datetime.now(UTC) - for target in (await session.scalars(select(TradingSymbol))).all(): + for target in (await session.scalars(select(TradingSymbol).where(TradingSymbol.running))).all(): if target.analysis_requested_at is not None: continue recent = (await session.scalars( diff --git a/backend/tests/test_plan.py b/backend/tests/test_plan.py index dc369a8..9ea75d8 100644 --- a/backend/tests/test_plan.py +++ b/backend/tests/test_plan.py @@ -41,7 +41,7 @@ def bars_until(hhmm: str) -> list[Bar]: def context(**overrides) -> PlanContext: values = { - "mark_price": Decimal("81000"), "capital": Decimal("1000"), "other_notional": Decimal("0"), "rules": BTC, + "mark_price": Decimal("81000"), "capital": Decimal("1000"), "rules": BTC, "atr": Decimal("100"), "has_plan": False, "position_side": None, } return PlanContext(**{**values, **overrides}) @@ -84,7 +84,7 @@ def test_atr_and_stop_from_example(): # 做多:支撑区 80200–80700 内挂 80500;止损 80200 − 0.5 × 302.97 = 80048.51 → 向下取整 80048.5,距挂单价 0.56% stop = setup_stop("long", Decimal("80500"), Decimal("80200"), value, BTC.tick_size) assert stop == Decimal("80048.5") - entry = size_entry("long", Decimal("80500"), stop, capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC) + entry = size_entry("long", Decimal("80500"), stop, capital=Decimal("1000"), confidence="medium", rules=BTC) assert entry["stop_pct"] == "0.56" # 10 ÷ (451.5 + (80500 + 80048.5) × 0.0004 = 515.72) = 0.01939… → 0.0193 assert entry["quantity"] == "0.0193" @@ -101,15 +101,15 @@ def test_stop_keeps_one_atr_from_entry(): def test_size_entry_rejects_far_stop_and_halves_low_confidence(): with pytest.raises(PlanError, match="超过 1%"): - size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC) + size_entry("long", Decimal("80000"), Decimal("79100"), capital=Decimal("1000"), confidence="high", rules=BTC) with pytest.raises(PlanError, match="亏损一侧"): - size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", rules=BTC) - medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="medium", rules=BTC) - low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="low", rules=BTC) + size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), confidence="high", rules=BTC) + medium = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), confidence="medium", rules=BTC) + low = size_entry("long", Decimal("80000"), Decimal("79600"), capital=Decimal("1000"), confidence="low", rules=BTC) assert (medium["risk_amount"], low["risk_amount"]) == ("10.00", "5.00") - # 止损很近时按 3 倍杠杆封顶:3000 − 其他币 1000 = 2000 ÷ 80000 = 0.025 - capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), other_notional=Decimal("1000"), confidence="high", rules=BTC) - assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0250", True) + # 止损很近时按该币资金的 3 倍杠杆封顶:3000 ÷ 80000 = 0.0375 + capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), confidence="high", rules=BTC) + assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0375", True) def test_take_profit_ladders(): diff --git a/web/src/apis/settings.ts b/web/src/apis/settings.ts index 2224471..42f3fd4 100644 --- a/web/src/apis/settings.ts +++ b/web/src/apis/settings.ts @@ -67,6 +67,7 @@ export function testProvider(id: number): Promise { export interface Exchange { configured: boolean api_key_hint: string | null + /** 是否有币种在交易:交易中不能修改 key */ running: boolean updated_at: string | null } diff --git a/web/src/apis/trading.ts b/web/src/apis/trading.ts index 48d5046..329ff15 100644 --- a/web/src/apis/trading.ts +++ b/web/src/apis/trading.ts @@ -54,12 +54,6 @@ export interface Snapshot { export interface TradingStatus { configured: boolean - running: boolean - running_changed_at: string | null - /** 设定的交易资金,十进制字符串;未设置时不能开始交易 */ - capital_usdt: string | null - /** 实际用于仓位计算的资金:min(交易资金, 账户权益) */ - effective_capital: string | null provider_id: number | null reasoning_effort: Effort | null /** trader 进程离线时为空 */ @@ -72,6 +66,13 @@ export interface TradingSymbol { added_at: string analysis_requested_at: string | null analyzing: boolean + /** 该币是否在交易;停止时分析为试运行,不挂新的入场单 */ + running: boolean + running_changed_at: string | null + /** 该币的交易资金,十进制字符串;未设置时不能开始交易 */ + capital_usdt: string | null + /** 实际用于仓位计算的资金:min(交易资金, 账户权益) */ + effective_capital: string | null /** 执行器正在管理的计划 */ plan_id: number | null plan_status: "pending" | "open" | "closing" | null @@ -103,15 +104,20 @@ export function getStatus(): Promise { return http.get("/trading/status") } -export function startTrading(): Promise { - return http.post("/trading/start") +export function startTrading(symbol: string): Promise { + return http.post(`/trading/symbols/${symbol}/start`) } -export function stopTrading(): Promise { - return http.post("/trading/stop") +export function stopTrading(symbol: string): Promise { + return http.post(`/trading/symbols/${symbol}/stop`) } -export function patchConfig(body: { capital_usdt?: string | null, provider_id?: number | null, reasoning_effort?: Effort | null }): Promise { +/** 设置该币的交易资金:各币合计不超过账户权益 */ +export function patchSymbol(symbol: string, body: { capital_usdt: string | null }): Promise { + return http.patch(`/trading/symbols/${symbol}`, body) +} + +export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise { return http.patch("/trading/config", body) } @@ -321,7 +327,7 @@ export function requestAnalysis(symbol: string): Promise { return http.post(`/trading/symbols/${symbol}/analyze`) } -/** 一键平仓(同时停止行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */ +/** 一键平仓(同时停止该币的行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */ export function manualAction(symbol: string, action: "close" | "cancel"): Promise { return http.post(`/trading/symbols/${symbol}/manual/${action}`) } diff --git a/web/src/pages/settings/index.vue b/web/src/pages/settings/index.vue index 6cf43e1..ac209db 100644 --- a/web/src/pages/settings/index.vue +++ b/web/src/pages/settings/index.vue @@ -275,7 +275,7 @@ loadExchange() - 交易中(停止后才能修改 key) + 有币种在交易(全部停止后才能修改 key) 未运行 diff --git a/web/src/pages/trading/components/PlanCard.vue b/web/src/pages/trading/components/PlanCard.vue index 9fb5f56..b025f29 100644 --- a/web/src/pages/trading/components/PlanCard.vue +++ b/web/src/pages/trading/components/PlanCard.vue @@ -1,12 +1,12 @@