- Executor reconciles each symbol to its desired orders: journaled ap{id}
client ids, reduce-only quantity stop placed before entries, make-before-
break stop moves, breakeven after TP1, orphan cancel, -2021 market close
- Plan lifecycle: proposed plans are adopted only if the position is
unchanged; holding-mode plans may only tighten the stop
- Program rules: time stop, short-term invalidation close, long-term 4h
review, reevaluate conditions, takeover of unmanaged positions
- Trigger and routine analysis scheduling with a 10 min gap, daily cap and
bounded retries
- Plan endpoint and plan status, fills and countdowns on the trading page
83 lines
3.1 KiB
Python
83 lines
3.1 KiB
Python
"""模拟盘交易进程:同步账户快照、执行器对账、调度分析。
|
|
|
|
用法:python -m alphapulse.trading.engine
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import select, update
|
|
|
|
from alphapulse.core.db import SessionLocal
|
|
from alphapulse.models import TradingAnalysis, TradingSymbol
|
|
from alphapulse.trading.analyst import run_analysis
|
|
from alphapulse.trading.executor import executor_loop
|
|
from alphapulse.trading.schedule import routine_requests
|
|
from alphapulse.trading.snapshot import snapshot_loop
|
|
|
|
logger = logging.getLogger("alphapulse.trading.engine")
|
|
|
|
ANALYSIS_POLL_SECONDS = 5
|
|
# 模型调用耗时长,并发过多会撞上游限流
|
|
MAX_CONCURRENT_ANALYSES = 2
|
|
|
|
|
|
async def fail_interrupted() -> None:
|
|
"""上次进程退出时还在进行的分析不会再有结果"""
|
|
async with SessionLocal() as session:
|
|
await session.execute(
|
|
update(TradingAnalysis).where(TradingAnalysis.status == "running")
|
|
.values(status="failed", error="引擎重启,分析中断", finished_at=datetime.now(UTC))
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def claim_requests(limit: int, busy: set[str]) -> list[tuple[str, list[str]]]:
|
|
"""取出到期的分析请求并清空,同一请求里的多个触发原因合并为一次分析"""
|
|
async with SessionLocal() as session:
|
|
symbols = (await session.scalars(
|
|
select(TradingSymbol)
|
|
.where(TradingSymbol.analysis_requested_at <= datetime.now(UTC), TradingSymbol.symbol.not_in(busy))
|
|
.order_by(TradingSymbol.analysis_requested_at).limit(limit)
|
|
)).all()
|
|
claimed = [(s.symbol, list(s.analysis_reasons)) for s in symbols]
|
|
for s in symbols:
|
|
s.analysis_requested_at = None
|
|
s.analysis_reasons = []
|
|
await session.commit()
|
|
return claimed
|
|
|
|
|
|
async def analysis_loop() -> None:
|
|
await fail_interrupted()
|
|
tasks: dict[str, asyncio.Task] = {}
|
|
while True:
|
|
try:
|
|
for symbol, task in list(tasks.items()):
|
|
if task.done():
|
|
del tasks[symbol]
|
|
if not task.cancelled() and task.exception():
|
|
logger.error("%s 分析任务异常退出", symbol, exc_info=task.exception())
|
|
await routine_requests()
|
|
free = MAX_CONCURRENT_ANALYSES - len(tasks)
|
|
if free > 0:
|
|
for symbol, reasons in await claim_requests(free, set(tasks)):
|
|
tasks[symbol] = asyncio.create_task(run_analysis(symbol, reasons))
|
|
except Exception:
|
|
logger.exception("调度分析出错,稍后重试")
|
|
await asyncio.sleep(ANALYSIS_POLL_SECONDS)
|
|
|
|
|
|
async def main() -> None:
|
|
async with asyncio.TaskGroup() as tg:
|
|
tg.create_task(snapshot_loop())
|
|
tg.create_task(executor_loop())
|
|
tg.create_task(analysis_loop())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
asyncio.run(main())
|