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:
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')
|
||||
@@ -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")
|
||||
|
||||
@@ -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} 已在分析或排队中")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}。"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -67,6 +67,7 @@ export function testProvider(id: number): Promise<TestResult> {
|
||||
export interface Exchange {
|
||||
configured: boolean
|
||||
api_key_hint: string | null
|
||||
/** 是否有币种在交易:交易中不能修改 key */
|
||||
running: boolean
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
+18
-12
@@ -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<TradingStatus> {
|
||||
return http.get("/trading/status")
|
||||
}
|
||||
|
||||
export function startTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/start")
|
||||
export function startTrading(symbol: string): Promise<void> {
|
||||
return http.post(`/trading/symbols/${symbol}/start`)
|
||||
}
|
||||
|
||||
export function stopTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/stop")
|
||||
export function stopTrading(symbol: string): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -321,7 +327,7 @@ export function requestAnalysis(symbol: string): Promise<void> {
|
||||
return http.post(`/trading/symbols/${symbol}/analyze`)
|
||||
}
|
||||
|
||||
/** 一键平仓(同时停止行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
|
||||
/** 一键平仓(同时停止该币的行情交易)、清除挂单:由执行器在几秒内执行,结果写入时间线 */
|
||||
export function manualAction(symbol: string, action: "close" | "cancel"): Promise<void> {
|
||||
return http.post(`/trading/symbols/${symbol}/manual/${action}`)
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ loadExchange()
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag v-if="exchange.running" type="success" size="small">
|
||||
交易中(停止后才能修改 key)
|
||||
有币种在交易(全部停止后才能修改 key)
|
||||
</el-tag>
|
||||
<span v-else>未运行</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
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 { formatTime } from "@/utils/text"
|
||||
|
||||
/** analysis:执行中的计划,没有时为最近一次分析;latest:最近一次分析(与 analysis 不同时另行提示) */
|
||||
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean }>()
|
||||
const emit = defineEmits<{ analyze: [], detail: [id: number] }>()
|
||||
const props = defineProps<{ symbol: TradingSymbol, analysis: Analysis | null, latest: Analysis | null, orders: OrderRow[], requesting: boolean, toggling: boolean }>()
|
||||
const emit = defineEmits<{ analyze: [], detail: [id: number], toggle: [], capital: [] }>()
|
||||
|
||||
const ACTION_LABELS = { plan: "新计划", keep: "维持当前计划", exit: "结束当前计划", wait: "观望" } 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] }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 font-normal">
|
||||
<div class="truncate text-xs text-slate-500 font-normal" title="短线做波动(区间内挂限价单,最长持仓 4 小时)">
|
||||
短线做波动(区间内挂限价单,最长持仓 4 小时)
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -88,8 +95,28 @@ const ladder = computed(() => sizing.value?.entry && entryRow.value
|
||||
</div>
|
||||
</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>
|
||||
<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" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
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 { 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 { formatTime } from "@/utils/text"
|
||||
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 positions = computed(() => snapshot.value?.positions ?? [])
|
||||
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
|
||||
const engineText = computed(() => snapshot.value ? `在线 · ${status.value!.running ? "正在交易" : "已停止"}` : "离线")
|
||||
const capitalShort = computed(() => {
|
||||
const s = status.value!
|
||||
return s.effective_capital && s.capital_usdt && Number(s.effective_capital) < Number(s.capital_usdt)
|
||||
})
|
||||
const runningCount = computed(() => symbols.value.filter(s => s.running).length)
|
||||
const engineText = computed(() => snapshot.value ? `在线 · ${runningCount.value ? `${runningCount.value} 个币交易中` : "全部停止"}` : "离线")
|
||||
/** 各币交易资金合计,不超过账户权益 */
|
||||
const allocated = computed(() => symbols.value.reduce((sum, s) => sum + Number(s.capital_usdt ?? 0), 0))
|
||||
const symbolOrders = computed(() => {
|
||||
const s = snapshot.value
|
||||
if (!s)
|
||||
@@ -140,7 +139,7 @@ async function analyze() {
|
||||
requesting.value = true
|
||||
try {
|
||||
await requestAnalysis(selected.value)
|
||||
ElMessage.success(status.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
|
||||
ElMessage.success(current.value?.running ? "已排队分析" : "已排队试运行分析:计划只记录,不下单")
|
||||
await refresh()
|
||||
} finally {
|
||||
requesting.value = false
|
||||
@@ -184,42 +183,45 @@ watch(selected, () => {
|
||||
refreshAnalysis()
|
||||
})
|
||||
|
||||
async function toggleRunning() {
|
||||
if (status.value!.running) {
|
||||
async function toggleRunning(s: TradingSymbol) {
|
||||
if (s.running) {
|
||||
await ElMessageBox.confirm(
|
||||
"停止后不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。",
|
||||
"停止行情交易",
|
||||
`停止后 ${s.symbol} 不再做新的分析,并撤销未成交的限价单。已有持仓保留,止损、止盈、到期平仓等程序规则继续执行。`,
|
||||
`停止 ${s.symbol} 行情交易`,
|
||||
{ type: "warning", confirmButtonText: "停止" }
|
||||
)
|
||||
} else {
|
||||
await ElMessageBox.confirm(
|
||||
"开始后,引擎会按触发条件和例行节奏自动分析交易列表里的币,并在币安模拟盘下单。",
|
||||
"开始行情交易",
|
||||
`开始后,引擎会按触发条件和例行节奏自动分析 ${s.symbol},并按它的交易资金在币安模拟盘下单。`,
|
||||
`开始 ${s.symbol} 行情交易`,
|
||||
{ type: "info", confirmButtonText: "开始" }
|
||||
)
|
||||
}
|
||||
toggling.value = true
|
||||
try {
|
||||
status.value = status.value!.running ? await stopTrading() : await startTrading()
|
||||
await (s.running ? stopTrading(s.symbol) : startTrading(s.symbol))
|
||||
await refresh()
|
||||
await refreshEvents()
|
||||
} finally {
|
||||
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(
|
||||
`仓位按交易资金计算:单笔风险 1%(置信度低时 0.5%),所有仓位名义价值合计不超过 3 倍(逐仓 3 倍时保证金合计不超过交易资金)。不能超过账户权益 ${money(account.value?.margin_balance)} USDT;账户权益低于它时按权益计算。`,
|
||||
"设置交易资金(USDT)",
|
||||
`${s.symbol} 的仓位按它的交易资金计算:单笔风险 1%(置信度低时 0.5%),名义仓位不超过 3 倍交易资金。各币交易资金合计不能超过账户权益 ${money(account.value?.margin_balance)} USDT(其他币已分配 ${others.toFixed(2)});账户权益低于它时按权益计算。`,
|
||||
`设置 ${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})?$/,
|
||||
inputErrorMessage: "请输入正数,最多两位小数",
|
||||
confirmButtonText: "保存"
|
||||
}
|
||||
)
|
||||
status.value = await patchConfig({ capital_usdt: value })
|
||||
await patchSymbol(s.symbol, { capital_usdt: value })
|
||||
ElMessage.success("已保存")
|
||||
await refresh()
|
||||
await refreshEvents()
|
||||
}
|
||||
|
||||
@@ -233,7 +235,7 @@ async function changeEffort(effort: Effort | null) {
|
||||
|
||||
async function closePosition(symbol: string) {
|
||||
await ElMessageBox.confirm(
|
||||
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止行情交易(所有币不再做新的分析)。`,
|
||||
`撤销 ${symbol} 的全部挂单(含止损、止盈和手动挂的单),市价平掉持仓,并停止 ${symbol} 的行情交易(其他币不受影响)。`,
|
||||
"一键平仓",
|
||||
{ type: "warning", confirmButtonText: "平仓" }
|
||||
)
|
||||
@@ -290,32 +292,16 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</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 class="h-12 w-px bg-slate-200" />
|
||||
|
||||
<div class="metric">
|
||||
<div class="metric-label">
|
||||
交易资金
|
||||
<el-icon class="cursor-pointer text-[var(--el-color-primary)]" title="设置交易资金" @click="editCapital">
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<div class="metric-label" title="各币交易资金合计,在每个币的计划卡片上设置">
|
||||
已分配资金
|
||||
</div>
|
||||
<div v-if="status.capital_usdt" class="metric-value">
|
||||
{{ money(status.capital_usdt) }}<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 class="metric-value">
|
||||
{{ allocated.toFixed(2) }}<small>USDT</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
@@ -386,9 +372,6 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
|
||||
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
|
||||
</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>
|
||||
同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}
|
||||
</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})` }" />
|
||||
<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">
|
||||
{{ symbolState(s).label }}
|
||||
</el-tag>
|
||||
@@ -526,8 +512,8 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<!-- 中:当前计划 -->
|
||||
<PlanCard
|
||||
v-if="current" :symbol="current" :analysis="livePlan ?? latestAnalysis" :latest="latestAnalysis" :orders="planOrders"
|
||||
:requesting="requesting"
|
||||
@analyze="analyze" @detail="openAnalysis"
|
||||
:requesting="requesting" :toggling="toggling"
|
||||
@analyze="analyze" @detail="openAnalysis" @toggle="toggleRunning(current)" @capital="editCapital(current)"
|
||||
/>
|
||||
<el-card v-else shadow="never">
|
||||
<el-empty description="先在左侧新增交易币种" :image-size="60" />
|
||||
|
||||
Reference in new issue
Block a user