Run demo trading and allocate capital per symbol

Move running, running_changed_at and capital_usdt from the account to
each trading symbol; the migration copies the account capital to every
symbol and leaves them all stopped. Each symbol is sized against its
own capital with a 3x notional cap, and the capitals together must not
exceed account equity. Scheduling, dry-run decisions and the executor
follow the per-symbol flag. The global start/stop button is replaced by
start/stop and capital controls on each symbol's plan card.
This commit is contained in:
eezd committed 2026-10-16 13:44:02 +08:00
1 parent e3242f2b7a
commit 0397e30870
14 files changed
+262 -186

No files matched your search

@@ -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')
+13 -9
View File
@@ -6,7 +6,7 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, HttpUrl, StringConstraints 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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession 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.auth import require_admin
from alphapulse.core.crypto import decrypt, encrypt from alphapulse.core.crypto import decrypt, encrypt
from alphapulse.core.db import get_session 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 from alphapulse.trading.client import DemoClient, ExchangeError
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)]) router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
@@ -186,25 +186,29 @@ class ExchangeTestResult(BaseModel):
error: str | None = None 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: if account is None:
return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None) return ExchangeOut(configured=False, api_key_hint=None, running=False, updated_at=None)
return ExchangeOut( return ExchangeOut(
configured=account.api_key_encrypted is not None, api_key_hint=account.api_key_hint, 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: async def editable_account(session: AsyncSession) -> TradingAccount:
account = await session.get(TradingAccount, 1) account = await session.get(TradingAccount, 1)
if account is not None and account.running: if await any_running(session):
raise HTTPException(status.HTTP_409_CONFLICT, "模拟盘正在交易,请先停止再修改 key") raise HTTPException(status.HTTP_409_CONFLICT, "有币种正在交易,请先全部停止再修改 key")
return account or TradingAccount(id=1) return account or TradingAccount(id=1)
@router.get("/exchange") @router.get("/exchange")
async def get_exchange(session: Annotated[AsyncSession, Depends(get_session)]) -> ExchangeOut: 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") @router.put("/exchange")
@@ -216,7 +220,7 @@ async def put_exchange(body: ExchangeIn, session: Annotated[AsyncSession, Depend
session.add(account) session.add(account)
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)
return exchange_out(account) return await exchange_out(session, account)
@router.delete("/exchange") @router.delete("/exchange")
@@ -226,7 +230,7 @@ async def delete_exchange(session: Annotated[AsyncSession, Depends(get_session)]
session.add(account) session.add(account)
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)
return exchange_out(account) return await exchange_out(session, account)
@router.post("/exchange/test") @router.post("/exchange/test")
+84 -65
View File
@@ -45,11 +45,6 @@ class TodayUsage(BaseModel):
class StatusOut(BaseModel): class StatusOut(BaseModel):
configured: bool configured: bool
running: bool
running_changed_at: datetime | None
capital_usdt: Decimal | None
# 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算
effective_capital: Decimal | None
provider_id: int | None provider_id: int | None
reasoning_effort: str | None reasoning_effort: str | None
# trader 进程的心跳快照;进程离线时为空 # trader 进程的心跳快照;进程离线时为空
@@ -58,7 +53,6 @@ class StatusOut(BaseModel):
class ConfigIn(BaseModel): class ConfigIn(BaseModel):
capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None = None
provider_id: int | None = None provider_id: int | None = None
reasoning_effort: llm.Effort | None = None reasoning_effort: llm.Effort | None = None
@@ -73,12 +67,21 @@ class SymbolOut(BaseModel):
added_at: datetime added_at: datetime
analysis_requested_at: datetime | None analysis_requested_at: datetime | None
analyzing: bool analyzing: bool
running: bool
running_changed_at: datetime | None
capital_usdt: Decimal | None
# 实际用于仓位计算的资金:min(交易资金, 账户权益);没有快照时无法计算
effective_capital: Decimal | None
# 当前计划:pending / open / closing,没有计划为空 # 当前计划:pending / open / closing,没有计划为空
plan_id: int | None plan_id: int | None
plan_status: str | None plan_status: str | None
close_deadline: datetime | None close_deadline: datetime | None
class SymbolConfigIn(BaseModel):
capital_usdt: Annotated[Decimal, Field(gt=0, decimal_places=2)] | None
class SymbolIn(BaseModel): class SymbolIn(BaseModel):
symbol: str symbol: str
@@ -177,7 +180,7 @@ class PositionHistoryPage(BaseModel):
async def get_account(session: AsyncSession) -> TradingAccount: 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") @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), func.coalesce(func.sum(TradingAnalysis.usage["output_tokens"].as_integer()), 0),
).where(TradingAnalysis.started_at >= today) ).where(TradingAnalysis.started_at >= today)
)).one() )).one()
snapshot = await read_snapshot()
equity = snapshot and snapshot["account"] and Decimal(snapshot["account"]["margin_balance"])
return StatusOut( return StatusOut(
configured=account.api_key_encrypted is not None, running=account.running, configured=account.api_key_encrypted is not None, provider_id=account.provider_id, reasoning_effort=account.reasoning_effort,
running_changed_at=account.running_changed_at, capital_usdt=account.capital_usdt, snapshot=await read_snapshot(),
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,
today=TodayUsage(analyses=count, limit=DAILY_ANALYSIS_LIMIT, input_tokens=input_tokens, output_tokens=output_tokens), 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") @router.patch("/config")
async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut: async def patch_config(body: ConfigIn, session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
account = await get_account(session) account = await get_account(session)
fields = body.model_dump(exclude_unset=True) 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: 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, "模型配置不存在") 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(): for key, value in fields.items():
setattr(account, key, value) setattr(account, key, value)
session.add(account) 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()] 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") @router.get("/symbols")
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]: async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
symbols = (await session.scalars(select(TradingSymbol).order_by(TradingSymbol.added_at))).all() 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.status == "running"))).all())
plans = {p.symbol: p for p in await session.scalars( 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.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 [ return [
SymbolOut( SymbolOut(
symbol=s.symbol, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at, 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, 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, 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) @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: async def manual_action(symbol: str, action: Literal["close", "cancel"], session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""一键平仓(同时停止行情交易)、清除挂单:交给执行器执行,结果写入时间线""" """一键平仓(同时停止该币的行情交易)、清除挂单:交给执行器执行,结果写入时间线"""
label = "平仓" if action == "close" else "清除挂单" label = "平仓" if action == "close" else "清除挂单"
if await session.get(TradingSymbol, symbol) is None: s = await get_symbol(session, symbol)
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
await fresh_snapshot(symbol, label) await fresh_snapshot(symbol, label)
if action == "close": 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) 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) @router.post("/symbols/{symbol}/analyze", status_code=status.HTTP_202_ACCEPTED)
async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None: async def request_analysis(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""排队一次手动分析:引擎停止时为试运行,计划只记录、不下单""" """排队一次手动分析:该币停止交易时为试运行,计划只记录、不下单"""
s = await session.get(TradingSymbol, symbol) s = await get_symbol(session, symbol)
if s is None: if s.capital_usdt is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中") raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 还没有设置交易资金")
if (await get_account(session)).capital_usdt is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "还没有设置交易资金")
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.status == "running"))
if running or s.analysis_requested_at is not None: if running or s.analysis_requested_at is not None:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中") raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在分析或排队中")
+6 -5
View File
@@ -9,7 +9,7 @@ from alphapulse.core.db import Base
class TradingAccount(Base): class TradingAccount(Base):
"""币安模拟盘账户:单行(id=1)。key 只以密文存储;running 是引擎启停的唯一来源""" """币安模拟盘账户:单行(id=1)。key 只以密文存储;启停和交易资金按币种设置,见 TradingSymbol"""
__tablename__ = "trading_account" __tablename__ = "trading_account"
__table_args__ = (CheckConstraint("id = 1", name="trading_account_single_row"),) __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_key_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary)
api_secret_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)) 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")) provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
reasoning_effort: Mapped[str | None] = mapped_column(String(8)) 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) symbol: Mapped[str] = mapped_column(String(32), primary_key=True)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
analysis_reasons: Mapped[list[str]] = mapped_column(JSONB, default=list) analysis_reasons: Mapped[list[str]] = mapped_column(JSONB, default=list)
+14 -13
View File
@@ -64,7 +64,7 @@ INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币
- 止损:区间远端外 {rules.STOP_ATR_FACTOR}×ATR14(5m),做多放在 zone_low 下方,做空放在 zone_high 上方;且距挂单价至少 {rules.MIN_STOP_ATR}×ATR14(5m),挂单价贴近远端时按这条算。 - 止损:区间远端外 {rules.STOP_ATR_FACTOR}×ATR14(5m),做多放在 zone_low 下方,做空放在 zone_high 上方;且距挂单价至少 {rules.MIN_STOP_ATR}×ATR14(5m),挂单价贴近远端时按这条算。
- 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后撤销未成交的入场部分,止损移到保本价。 - 止盈:按成交均价,做多 {_ladder("long")};做空 {_ladder("short")}。止盈 1 成交后撤销未成交的入场部分,止损移到保本价。
- 首次成交后最长持仓 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。 - 首次成交后最长持仓 {rules.MAX_HOLD_HOURS} 小时,到时市价平仓。平仓后程序会请你重新分析。
- 仓位:单笔风险为交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位合计不超过 {rules.MAX_LEVERAGE} 倍交易资金。 - 仓位:单笔风险为该币交易资金的 {_pct(rules.RISK_PCT)},置信度低时减半;名义仓位不超过 {rules.MAX_LEVERAGE} 倍该币交易资金。
- reevaluate:任一条件满足时程序重新请你分析(最多 {rules.MAX_REEVALUATE} 个),用来表达另一侧方向、需要重新划区的位置。 - 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) target = await session.get(TradingSymbol, symbol)
if target is None: if target is None:
raise AnalysisError(f"{symbol} 已不在交易列表中") raise AnalysisError(f"{symbol} 已不在交易列表中")
if account is None or account.capital_usdt is None: if account is None:
raise AnalysisError("还没有设置交易资金") 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( provider = await session.get(AiProvider, account.provider_id) if account.provider_id else await session.scalar(
select(AiProvider).where(AiProvider.is_default) select(AiProvider).where(AiProvider.is_default)
) )
if provider is None: if provider is None:
raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加") raise AnalysisError("没有可用的模型配置,请先到「环境变量」页面添加")
session.expunge(provider) session.expunge(provider)
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
recent = (await session.scalars( recent = (await session.scalars(
select(TradingAnalysis) select(TradingAnalysis)
.where(TradingAnalysis.symbol == symbol, TradingAnalysis.status == "done", TradingAnalysis.id != analysis_id) .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() )).all()
] if current else [] ] if current else []
system = "\n\n".join([INSTRUCTIONS, "## 数据概况", await data_overview(session), await skills_section(session)]) 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 effort = account.reasoning_effort or provider.reasoning_effort
snapshot = await read_snapshot() snapshot = await read_snapshot()
if not snapshot or not snapshot["account"]: if not snapshot or not snapshot["account"]:
raise AnalysisError("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位") raise AnalysisError("取不到模拟盘账户快照(引擎未同步到币安),无法计算仓位")
equity = Decimal(snapshot["account"]["margin_balance"]) 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) 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) symbol_rules_ = await symbol_rules(symbol)
mark = await mark_price(symbol) mark = await mark_price(symbol)
context = PlanContext( 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), 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"- 币种:{symbol}(币安 U 本位永续)",
f"- 触发原因:{';'.join(reasons)}", 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"- 本次单笔风险金额:{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"- 模拟盘交易规则:价格精度 {symbol_rules_.tick_size},数量精度 {symbol_rules_.step_size},最小下单金额 {symbol_rules_.min_notional} USDT",
f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)", f"- 模拟盘标记价:{mark}(校验以此为准;行情数据来自实盘,两者通常相差不到 0.1%)",
f"- 最近已收盘 5m K 线的 ATR14:{context.atr:.{max(0, -symbol_rules_.tick_size.as_tuple().exponent)}f}(程序按它计算止损)", 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: async def run_analysis(symbol: str, reasons: list[str]) -> None:
"""一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出""" """一次完整的分析;结果和失败原因都写进 trading_analyses 和时间线,不向外抛出"""
async with SessionLocal() as session: async with SessionLocal() as session:
account = await session.get(TradingAccount, 1) target = await session.get(TradingSymbol, symbol)
analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (account and account.running), status="running") analysis = TradingAnalysis(symbol=symbol, reasons=reasons, dry_run=not (target and target.running), status="running")
session.add(analysis) session.add(analysis)
await session.commit() await session.commit()
analysis_id, dry_run = analysis.id, analysis.dry_run analysis_id, dry_run = analysis.id, analysis.dry_run
+1 -1
View File
@@ -599,7 +599,7 @@ class Executor:
for symbol in symbols: for symbol in symbols:
try: try:
manual_action = await redis.getdel(MANUAL_ACTION_KEY.format(symbol.symbol)) 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: except ExchangeError as e:
await session.rollback() await session.rollback()
await self.report(session, symbol.symbol, f"对账失败:{e}") await self.report(session, symbol.symbol, f"对账失败:{e}")
+5 -11
View File
@@ -93,10 +93,8 @@ class SymbolRules:
@dataclass @dataclass
class PlanContext: class PlanContext:
mark_price: Decimal mark_price: Decimal
# min(设定的交易资金, 账户权益) # 该币的 min(交易资金, 账户权益)
capital: Decimal capital: Decimal
# 交易列表中其他币的持仓名义价值合计
other_notional: Decimal
rules: SymbolRules rules: SymbolRules
# 最近已收盘 5m K 线的 ATR14,用于计算止损 # 最近已收盘 5m K 线的 ATR14,用于计算止损
atr: Decimal 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) 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, def size_entry(direction: Direction, price: Decimal, stop: Decimal, *, capital: Decimal, confidence: str, rules: SymbolRules) -> dict:
confidence: str, rules: SymbolRules) -> dict: """按入场价和止损算数量:单笔风险上限与该币资金的杠杆上限取小。止损离入场价太远、方向不对或数量不足时抛 PlanError"""
"""按入场价和止损算数量:单笔风险上限与全账户杠杆取小。止损离入场价太远、方向不对或数量不足时抛 PlanError"""
sign = 1 if direction == "long" else -1 sign = 1 if direction == "long" else -1
stop_pct = sign * (price - stop) / price stop_pct = sign * (price - stop) / price
if stop_pct <= 0: 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}%"]) raise PlanError([f"止损 {stop} 距入场价 {price} {stop_pct * 100:.2f}%,超过 {MAX_STOP_PCT * 100:.0f}%"])
unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE unit_loss = abs(price - stop) + (price + stop) * TAKER_FEE_RATE
risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1) risk_amount = capital * RISK_PCT * (LOW_CONFIDENCE_FACTOR if confidence == "low" else 1)
max_notional = capital * MAX_LEVERAGE - other_notional max_notional = capital * MAX_LEVERAGE
if max_notional <= 0:
raise PlanError([f"交易列表其他币的名义仓位 {other_notional:.2f} 已达到 {MAX_LEVERAGE} 倍交易资金,不能开新仓"])
by_risk = risk_amount / unit_loss by_risk = risk_amount / unit_loss
by_leverage = max_notional / price by_leverage = max_notional / price
quantity = round_to_step(min(by_risk, by_leverage), rules.step_size, ROUND_FLOOR) 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) raise PlanError(errors)
stop = setup_stop(direction, entry, setup.zone_low if long else setup.zone_high, ctx.atr, rules.tick_size) stop = setup_stop(direction, entry, setup.zone_low if long else setup.zone_high, ctx.atr, rules.tick_size)
try: try:
sizing = size_entry(direction, entry, stop, capital=ctx.capital, other_notional=ctx.other_notional, sizing = size_entry(direction, entry, stop, capital=ctx.capital, confidence=plan.confidence, rules=rules)
confidence=plan.confidence, rules=rules)
except PlanError as e: except PlanError as e:
raise PlanError([f"止损 {stop}(区间{'下沿' if long else '上沿'}外 {STOP_ATR_FACTOR}×ATR(5m),且距挂单价至少 " 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}。" f"{MIN_STOP_ATR}×ATR(5m),ATR={ctx.atr.quantize(rules.tick_size)}):{e}。"
+4 -8
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.tools import LOCAL_TZ from alphapulse.ai.tools import LOCAL_TZ
from alphapulse.core.db import SessionLocal 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 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: async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> None:
"""排队一次自动分析:多个原因合并;距上次分析不足最短间隔时顺延。调用方负责 commit""" """排队一次自动分析:多个原因合并;距上次分析不足最短间隔时顺延。调用方负责 commit"""
account = await session.get(TradingAccount, 1)
target = await session.get(TradingSymbol, symbol) 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 return
now = datetime.now(UTC) 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.symbol == symbol))
@@ -43,13 +42,10 @@ async def request_analysis(session: AsyncSession, symbol: str, reason: str) -> N
async def routine_requests() -> None: async def routine_requests() -> None:
"""交易中:到例行时间的币排队例行分析;自动分析失败的隔一段时间重试""" """交易中的币:到例行时间的排队例行分析;自动分析失败的隔一段时间重试"""
async with SessionLocal() as session: async with SessionLocal() as session:
account = await session.get(TradingAccount, 1)
if account is None or not account.running:
return
now = datetime.now(UTC) 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: if target.analysis_requested_at is not None:
continue continue
recent = (await session.scalars( recent = (await session.scalars(
+9 -9
View File
@@ -41,7 +41,7 @@ def bars_until(hhmm: str) -> list[Bar]:
def context(**overrides) -> PlanContext: def context(**overrides) -> PlanContext:
values = { 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, "atr": Decimal("100"), "has_plan": False, "position_side": None,
} }
return PlanContext(**{**values, **overrides}) 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% # 做多:支撑区 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) stop = setup_stop("long", Decimal("80500"), Decimal("80200"), value, BTC.tick_size)
assert stop == Decimal("80048.5") 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" assert entry["stop_pct"] == "0.56"
# 10 ÷ (451.5 + (80500 + 80048.5) × 0.0004 = 515.72) = 0.01939… → 0.0193 # 10 ÷ (451.5 + (80500 + 80048.5) × 0.0004 = 515.72) = 0.01939… → 0.0193
assert entry["quantity"] == "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(): def test_size_entry_rejects_far_stop_and_halves_low_confidence():
with pytest.raises(PlanError, match="超过 1%"): 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="亏损一侧"): with pytest.raises(PlanError, match="亏损一侧"):
size_entry("short", Decimal("80000"), Decimal("79900"), capital=Decimal("1000"), other_notional=Decimal("0"), confidence="high", 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"), other_notional=Decimal("0"), confidence="medium", 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"), other_notional=Decimal("0"), confidence="low", 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") assert (medium["risk_amount"], low["risk_amount"]) == ("10.00", "5.00")
# 止损很近时按 3 倍杠杆封顶:3000 − 其他币 1000 = 2000 ÷ 80000 = 0.025 # 止损很近时按该币资金的 3 倍杠杆封顶:3000 ÷ 80000 = 0.0375
capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), other_notional=Decimal("1000"), confidence="high", rules=BTC) capped = size_entry("long", Decimal("80000"), Decimal("79990"), capital=Decimal("1000"), confidence="high", rules=BTC)
assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0250", True) assert (capped["quantity"], capped["capped_by_leverage"]) == ("0.0375", True)
def test_take_profit_ladders(): def test_take_profit_ladders():
+1
View File
@@ -67,6 +67,7 @@ export function testProvider(id: number): Promise<TestResult> {
export interface Exchange { export interface Exchange {
configured: boolean configured: boolean
api_key_hint: string | null api_key_hint: string | null
/** 是否有币种在交易:交易中不能修改 key */
running: boolean running: boolean
updated_at: string | null updated_at: string | null
} }
+18 -12
View File
@@ -54,12 +54,6 @@ export interface Snapshot {
export interface TradingStatus { export interface TradingStatus {
configured: boolean configured: boolean
running: boolean
running_changed_at: string | null
/** 设定的交易资金,十进制字符串;未设置时不能开始交易 */
capital_usdt: string | null
/** 实际用于仓位计算的资金:min(交易资金, 账户权益) */
effective_capital: string | null
provider_id: number | null provider_id: number | null
reasoning_effort: Effort | null reasoning_effort: Effort | null
/** trader 进程离线时为空 */ /** trader 进程离线时为空 */
@@ -72,6 +66,13 @@ export interface TradingSymbol {
added_at: string added_at: string
analysis_requested_at: string | null analysis_requested_at: string | null
analyzing: boolean analyzing: boolean
/** 该币是否在交易;停止时分析为试运行,不挂新的入场单 */
running: boolean
running_changed_at: string | null
/** 该币的交易资金,十进制字符串;未设置时不能开始交易 */
capital_usdt: string | null
/** 实际用于仓位计算的资金:min(交易资金, 账户权益) */
effective_capital: string | null
/** 执行器正在管理的计划 */ /** 执行器正在管理的计划 */
plan_id: number | null plan_id: number | null
plan_status: "pending" | "open" | "closing" | null plan_status: "pending" | "open" | "closing" | null
@@ -103,15 +104,20 @@ export function getStatus(): Promise<TradingStatus> {
return http.get("/trading/status") return http.get("/trading/status")
} }
export function startTrading(): Promise<TradingStatus> { export function startTrading(symbol: string): Promise<void> {
return http.post("/trading/start") return http.post(`/trading/symbols/${symbol}/start`)
} }
export function stopTrading(): Promise<TradingStatus> { export function stopTrading(symbol: string): Promise<void> {
return http.post("/trading/stop") return http.post(`/trading/symbols/${symbol}/stop`)
} }
export function patchConfig(body: { capital_usdt?: string | null, provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> { /** 设置该币的交易资金:各币合计不超过账户权益 */
export function patchSymbol(symbol: string, body: { capital_usdt: string | null }): Promise<void> {
return http.patch(`/trading/symbols/${symbol}`, body)
}
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
return http.patch("/trading/config", body) return http.patch("/trading/config", body)
} }
@@ -321,7 +327,7 @@ export function requestAnalysis(symbol: string): Promise<void> {
return http.post(`/trading/symbols/${symbol}/analyze`) return http.post(`/trading/symbols/${symbol}/analyze`)
} }
/** 一键平仓(同时停止行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */ /** 一键平仓(同时停止该币的行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
export function manualAction(symbol: string, action: "close" | "cancel"): Promise<void> { export function manualAction(symbol: string, action: "close" | "cancel"): Promise<void> {
return http.post(`/trading/symbols/${symbol}/manual/${action}`) return http.post(`/trading/symbols/${symbol}/manual/${action}`)
} }
+1 -1
View File
@@ -275,7 +275,7 @@ loadExchange()
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small"> <el-tag v-if="exchange.running" type="success" size="small">
交易中(停止后才能修改 key) 有币种在交易(全部停止后才能修改 key)
</el-tag> </el-tag>
<span v-else>未运行</span> <span v-else>未运行</span>
</el-descriptions-item> </el-descriptions-item>
+32 -5
View File
@@ -1,12 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading" import type { Analysis, OrderRow, PlanCondition, TradingSymbol } from "@/apis/trading"
import { Document, Lightning } from "@element-plus/icons-vue" import { Document, Edit, Lightning, SwitchButton, VideoPlay } from "@element-plus/icons-vue"
import { CLOSE_REASON_LABELS, PLAN_STATUS_LABELS, TAKE_PROFIT_STEPS } from "@/apis/trading" import { CLOSE_REASON_LABELS, PLAN_STATUS_LABELS, TAKE_PROFIT_STEPS } from "@/apis/trading"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
/** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */ /** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean }>() const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean, toggling: boolean }>()
const emit = defineEmits<{ analyze: [], detail: [id: number] }>() const emit = defineEmits<{ analyze: [], detail: [id: number], toggle: [], capital: [] }>()
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } as const
const CONFIDENCE_LABELS = { high: "高", medium: "中", low: "低" } as const const CONFIDENCE_LABELS = { high: "高", medium: "中", low: "低" } as const
@@ -74,11 +74,18 @@ const ladder = computed(() => sizing.value?.entry && entryRow.value
{{ PLAN_STATUS_LABELS[analysis.plan_status] }} {{ PLAN_STATUS_LABELS[analysis.plan_status] }}
</el-tag> </el-tag>
</div> </div>
<div class="text-xs text-slate-500 font-normal"> <div class="truncate text-xs text-slate-500 font-normal" title="短线做波动(区间内挂限价单,最长持仓 4 小时)">
短线做波动(区间内挂限价单,最长持仓 4 小时) 短线做波动(区间内挂限价单,最长持仓 4 小时)
</div> </div>
</div> </div>
<div class="flex-1" /> <div class="flex-1" />
<el-button
:type="symbol.running ? 'danger' : 'success'" plain :icon="symbol.running ? SwitchButton : VideoPlay" :loading="toggling"
:disabled="!symbol.running && !symbol.capital_usdt" :title="!symbol.running && !symbol.capital_usdt ? '先设置交易资金' : ''"
@click="emit('toggle')"
>
{{ symbol.running ? "停止交易" : "开始交易" }}
</el-button>
<el-button v-if="analysis && analysis.status !== 'running'" :icon="Document" @click="emit('detail', analysis.id)"> <el-button v-if="analysis && analysis.status !== 'running'" :icon="Document" @click="emit('detail', analysis.id)">
查看完整分析 查看完整分析
</el-button> </el-button>
@@ -88,8 +95,28 @@ const ladder = computed(() => sizing.value?.entry && entryRow.value
</div> </div>
</template> </template>
<div class="flex flex-wrap items-center gap-x-6 gap-y-1 border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2 text-xs text-slate-500">
<span class="flex items-center gap-1">
交易资金
<b v-if="symbol.capital_usdt" class="num text-sm text-slate-900">{{ Number(symbol.capital_usdt).toFixed(2) }} USDT</b>
<b v-else class="text-sm text-rose-600">未设置</b>
<el-icon class="cursor-pointer text-[var(--el-color-primary)]" title="设置交易资金" @click="emit('capital')">
<Edit />
</el-icon>
</span>
<span v-if="symbol.effective_capital && Number(symbol.effective_capital) < Number(symbol.capital_usdt)" class="text-amber-600">
权益不足,按 {{ Number(symbol.effective_capital).toFixed(2) }} 计算
</span>
<span v-if="symbol.capital_usdt">名义仓位上限 <b class="num text-slate-900">{{ (Number(symbol.effective_capital ?? symbol.capital_usdt) * 3).toFixed(2) }} USDT</b></span>
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full" :class="symbol.running ? 'bg-emerald-500' : 'bg-slate-300'" />
<span :class="symbol.running ? 'text-emerald-700' : ''">{{ symbol.running ? "交易中" : "已停止" }}</span>
<span v-if="symbol.running_changed_at" class="num">({{ formatTime(symbol.running_changed_at, "MM-DD HH:mm") }} 起)</span>
</span>
</div>
<div v-if="!analysis" class="border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2 text-slate-500"> <div v-if="!analysis" class="border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2 text-slate-500">
还没有分析。引擎停止时点「立即分析」为试运行:计划只记录,不下单。 还没有分析。该币停止交易时点「立即分析」为试运行:计划只记录,不下单。
</div> </div>
<div v-else-if="analysis.status === 'running'" class="flex items-center gap-2 border border-amber-200 rounded-lg border-solid bg-amber-50 px-3 py-2 text-amber-700"> <div v-else-if="analysis.status === 'running'" class="flex items-center gap-2 border border-amber-200 rounded-lg border-solid bg-amber-50 px-3 py-2 text-amber-700">
<span class="h-2 w-2 animate-pulse rounded-full bg-amber-500" /> <span class="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
+33 -47
View File
@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Effort, Provider } from "@/apis/settings" import type { Effort, Provider } from "@/apis/settings"
import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading" import type { Analysis, OrderRow, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
import { Clock, Edit, Plus } from "@element-plus/icons-vue" import { Clock, Plus } from "@element-plus/icons-vue"
import { EFFORTS, listProviders } from "@/apis/settings" import { EFFORTS, listProviders } from "@/apis/settings"
import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, manualAction, patchConfig, requestAnalysis, startTrading, stopTrading } from "@/apis/trading" import { deleteSymbol, getStatus, getSymbolPlan, isOwnOrder, listAnalyses, listEvents, listSymbols, manualAction, patchConfig, patchSymbol, requestAnalysis, startTrading, stopTrading } from "@/apis/trading"
import { usePersisted } from "@/utils/storage" import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text" import { formatTime } from "@/utils/text"
import AddSymbolDialog from "./components/AddSymbolDialog.vue" import AddSymbolDialog from "./components/AddSymbolDialog.vue"
@@ -35,11 +35,10 @@ const snapshot = computed(() => status.value?.snapshot ?? null)
const account = computed(() => snapshot.value?.account ?? null) const account = computed(() => snapshot.value?.account ?? null)
const positions = computed(() => snapshot.value?.positions ?? []) const positions = computed(() => snapshot.value?.positions ?? [])
const current = computed(() => symbols.value.find(s => s.symbol === selected.value)) const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
const engineText = computed(() => snapshot.value ? `在线 · ${status.value!.running ? "正在交易" : "已停止"}` : "离线") const runningCount = computed(() => symbols.value.filter(s => s.running).length)
const capitalShort = computed(() => { const engineText = computed(() => snapshot.value ? `在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : "离线")
const s = status.value! /** 各币交易资金合计,不超过账户权益 */
return s.effective_capital && s.capital_usdt && Number(s.effective_capital) < Number(s.capital_usdt) const allocated = computed(() => symbols.value.reduce((sum, s) => sum + Number(s.capital_usdt ?? 0), 0))
})
const symbolOrders = computed(() => { const symbolOrders = computed(() => {
const s = snapshot.value const s = snapshot.value
if (!s) if (!s)
@@ -140,7 +139,7 @@ async function analyze() {
requesting.value = true requesting.value = true
try { try {
await requestAnalysis(selected.value) await requestAnalysis(selected.value)
ElMessage.success(status.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单") ElMessage.success(current.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
await refresh() await refresh()
} finally { } finally {
requesting.value = false requesting.value = false
@@ -184,42 +183,45 @@ watch(selected, () => {
refreshAnalysis() refreshAnalysis()
}) })
async function toggleRunning() { async function toggleRunning(s: TradingSymbol) {
if (status.value!.running) { if (s.running) {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"停止后不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。", `停止后 ${s.symbol} 不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。`,
"停止行情交易", `停止 ${s.symbol} 行情交易`,
{ type: "warning", confirmButtonText: "停止" } { type: "warning", confirmButtonText: "停止" }
) )
} else { } else {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"开始后,引擎会按触发条件和例行节奏自动分析交易列表里的币,并在币安模拟盘下单。", `开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在币安模拟盘下单。`,
"开始行情交易", `开始 ${s.symbol} 行情交易`,
{ type: "info", confirmButtonText: "开始" } { type: "info", confirmButtonText: "开始" }
) )
} }
toggling.value = true toggling.value = true
try { try {
status.value = status.value!.running ? await stopTrading() : await startTrading() await (s.running ? stopTrading(s.symbol) : startTrading(s.symbol))
await refresh()
await refreshEvents() await refreshEvents()
} finally { } finally {
toggling.value = false toggling.value = false
} }
} }
async function editCapital() { async function editCapital(s: TradingSymbol) {
const others = allocated.value - Number(s.capital_usdt ?? 0)
const { value } = await ElMessageBox.prompt( const { value } = await ElMessageBox.prompt(
`仓位按交易资金计算:单笔风险 1%(置信度低时 0.5%),所有仓位名义价值合计不超过 3 倍(逐仓 3 倍时保证金合计不超过交易资金)。不能超过账户权益 ${money(account.value?.margin_balance)} USDT;账户权益低于它时按权益计算。`, `${s.symbol} 的仓位按它的交易资金计算:单笔风险 1%(置信度低时 0.5%),名义仓位不超过 3 倍交易资金。各币交易资金合计不能超过账户权益 ${money(account.value?.margin_balance)} USDT(其他币已分配 ${others.toFixed(2)});账户权益低于它时按权益计算。`,
"设置交易资金(USDT)", `设置 ${s.symbol} 交易资金(USDT)`,
{ {
inputValue: status.value!.capital_usdt ? num(status.value!.capital_usdt) : "", inputValue: s.capital_usdt ? num(s.capital_usdt) : "",
inputPattern: /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/, inputPattern: /^(?:[1-9]\d*|0)(?:\.\d{1,2})?$/,
inputErrorMessage: "请输入正数,最多两位小数", inputErrorMessage: "请输入正数,最多两位小数",
confirmButtonText: "保存" confirmButtonText: "保存"
} }
) )
status.value = await patchConfig({ capital_usdt: value }) await patchSymbol(s.symbol, { capital_usdt: value })
ElMessage.success("已保存") ElMessage.success("已保存")
await refresh()
await refreshEvents() await refreshEvents()
} }
@@ -233,7 +235,7 @@ async function changeEffort(effort: Effort | null) {
async function closePosition(symbol: string) { async function closePosition(symbol: string) {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止行情交易(所有币不再做新的分析)。`, `撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止 ${symbol} 的行情交易(其他币不受影响)。`,
"一键平仓", "一键平仓",
{ type: "warning", confirmButtonText: "平仓" } { type: "warning", confirmButtonText: "平仓" }
) )
@@ -290,32 +292,16 @@ onBeforeUnmount(() => clearInterval(timer))
</div> </div>
</div> </div>
</div> </div>
<el-button
:type="status.running ? 'danger' : 'primary'" :plain="status.running" :loading="toggling" size="small"
:disabled="!status.running && (!status.configured || !status.capital_usdt)"
@click="toggleRunning"
>
{{ status.running ? "停止行情交易" : "开始行情交易" }}
</el-button>
</div> </div>
<div class="h-12 w-px bg-slate-200" /> <div class="h-12 w-px bg-slate-200" />
<div class="metric"> <div class="metric">
<div class="metric-label"> <div class="metric-label" title="各币交易资金合计,在每个币的计划卡片上设置">
交易资金 已分配资金
<el-icon class="cursor-pointer text-[var(--el-color-primary)]" title="设置交易资金" @click="editCapital">
<Edit />
</el-icon>
</div> </div>
<div v-if="status.capital_usdt" class="metric-value"> <div class="metric-value">
{{ money(status.capital_usdt) }}<small>USDT</small> {{ allocated.toFixed(2) }}<small>USDT</small>
</div>
<div v-else class="metric-value text-rose-600!">
未设置
</div>
<div v-if="capitalShort" class="text-xs text-amber-600">
权益不足,按 {{ money(status.effective_capital!) }}
</div> </div>
</div> </div>
<div class="metric"> <div class="metric">
@@ -386,9 +372,6 @@ onBeforeUnmount(() => clearInterval(timer))
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon> <el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
还没有填写币安模拟盘 key,请先到「环境变量」页填写。 还没有填写币安模拟盘 key,请先到「环境变量」页填写。
</el-alert> </el-alert>
<el-alert v-else-if="!status.capital_usdt" type="warning" :closable="false" show-icon>
还没有设置交易资金。引擎只按交易资金计算仓位,不会动用账户的全部余额;设置后才能开始行情交易。
</el-alert>
<el-alert v-else-if="snapshot?.error" type="error" :closable="false" show-icon> <el-alert v-else-if="snapshot?.error" type="error" :closable="false" show-icon>
同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }} 同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}
</el-alert> </el-alert>
@@ -505,7 +488,10 @@ onBeforeUnmount(() => clearInterval(timer))
> >
<span class="absolute inset-y-0 left-0 w-1" :style="{ background: `var(--el-color-${symbolState(s).type})` }" /> <span class="absolute inset-y-0 left-0 w-1" :style="{ background: `var(--el-color-${symbolState(s).type})` }" />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="num text-slate-900 font-semibold">{{ s.symbol }}</span> <span class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" :class="s.running ? 'bg-emerald-500' : 'bg-slate-300'" :title="s.running ? '交易中' : '已停止'" />
<span class="num text-slate-900 font-semibold">{{ s.symbol }}</span>
</span>
<el-tag :type="symbolState(s).type" size="small"> <el-tag :type="symbolState(s).type" size="small">
{{ symbolState(s).label }} {{ symbolState(s).label }}
</el-tag> </el-tag>
@@ -526,8 +512,8 @@ onBeforeUnmount(() => clearInterval(timer))
<!-- 中:当前计划 --> <!-- 中:当前计划 -->
<PlanCard <PlanCard
v-if="current" :symbol="current" :analysis="livePlan ?? latestAnalysis" :latest="latestAnalysis" :orders="planOrders" v-if="current" :symbol="current" :analysis="livePlan ?? latestAnalysis" :latest="latestAnalysis" :orders="planOrders"
:requesting="requesting" :requesting="requesting" :toggling="toggling"
@analyze="analyze" @detail="openAnalysis" @analyze="analyze" @detail="openAnalysis" @toggle="toggleRunning(current)" @capital="editCapital(current)"
/> />
<el-card v-else shadow="never"> <el-card v-else shadow="never">
<el-empty description="先在左侧新增交易币种" :image-size="60" /> <el-empty description="先在左侧新增交易币种" :image-size="60" />