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 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")
|
||||||
|
|||||||
@@ -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} 已在分析或排队中")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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}。"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in new issue
Block a user