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():
|
||||
|
||||
Reference in new issue
Block a user