Run demo trading and allocate capital per symbol

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

No files matched your search

+14 -13
View File
@@ -64,7 +64,7 @@ INSTRUCTIONS = f"""你是 AlphaPulse 的模拟盘交易分析器:为一个币
- 止损:区间远端外 {rules.STOP_ATR_FACTOR}×ATR14(5m),做多放在 zone_low 下方,做空放在 zone_high 上方;且距挂单价至少 {rules.MIN_STOP_ATR}×ATR14(5m),挂单价贴近远端时按这条算。
- 止盈:按成交均价,做多 {_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
+1 -1
View File
@@ -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}")
+5 -11
View File
@@ -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}。"
+4 -8
View File
@@ -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(