Add demo trading page, trader snapshot service and trading tables
Add trading_symbols, trading_analyses, trading_orders and trading_events. The new trader service publishes account, position and open-order snapshots of the Binance demo account to Redis every 3s. The /trading page shows engine status, start/stop, balances, positions, the symbol list with angle selection, open orders and the event timeline.
This commit is contained in:
1 parent
154fe2b687
commit
005943a54c
12 files changed
+1289
-3
No files matched your search
@@ -0,0 +1,114 @@
|
||||
"""trading tables
|
||||
|
||||
Revision ID: a54081406f23
|
||||
Revises: a7d3e9c41f02
|
||||
Create Date: 2026-10-15 01:44:51.540362
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a54081406f23'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a7d3e9c41f02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('trading_symbols',
|
||||
sa.Column('symbol', sa.String(length=32), nullable=False),
|
||||
sa.Column('angle', sa.String(length=8), nullable=False),
|
||||
sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('analysis_requested_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('analysis_reasons', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.PrimaryKeyConstraint('symbol')
|
||||
)
|
||||
op.create_table('trading_analyses',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=32), nullable=False),
|
||||
sa.Column('angle', sa.String(length=8), nullable=False),
|
||||
sa.Column('reasons', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('dry_run', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('action', sa.String(length=8), nullable=True),
|
||||
sa.Column('decision', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('sizing', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('text', sa.Text(), nullable=True),
|
||||
sa.Column('transcript', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('provider_id', sa.Integer(), nullable=True),
|
||||
sa.Column('model', sa.String(length=128), nullable=True),
|
||||
sa.Column('reasoning_effort', sa.String(length=8), nullable=True),
|
||||
sa.Column('usage', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('plan_status', sa.String(length=16), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('first_fill_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('close_deadline', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('close_reason', sa.String(length=32), nullable=True),
|
||||
sa.Column('result', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['provider_id'], ['ai_providers.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_trading_analyses_symbol_started', 'trading_analyses', ['symbol', 'started_at'], unique=False)
|
||||
op.create_table('trading_events',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=32), nullable=True),
|
||||
sa.Column('analysis_id', sa.Integer(), nullable=True),
|
||||
sa.Column('kind', sa.String(length=32), nullable=False),
|
||||
sa.Column('level', sa.String(length=8), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(['analysis_id'], ['trading_analyses.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_trading_events_symbol_id', 'trading_events', ['symbol', 'id'], unique=False)
|
||||
op.create_table('trading_orders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('analysis_id', sa.Integer(), nullable=True),
|
||||
sa.Column('symbol', sa.String(length=32), nullable=False),
|
||||
sa.Column('purpose', sa.String(length=8), nullable=False),
|
||||
sa.Column('leg', sa.Integer(), nullable=True),
|
||||
sa.Column('is_algo', sa.Boolean(), nullable=False),
|
||||
sa.Column('side', sa.String(length=4), nullable=False),
|
||||
sa.Column('type', sa.String(length=24), nullable=False),
|
||||
sa.Column('price', sa.Numeric(), nullable=True),
|
||||
sa.Column('trigger_price', sa.Numeric(), nullable=True),
|
||||
sa.Column('quantity', sa.Numeric(), nullable=False),
|
||||
sa.Column('reduce_only', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('exchange_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('filled_qty', sa.Numeric(), nullable=False),
|
||||
sa.Column('avg_price', sa.Numeric(), nullable=True),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['analysis_id'], ['trading_analyses.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_trading_orders_analysis_id'), 'trading_orders', ['analysis_id'], unique=False)
|
||||
op.create_index(op.f('ix_trading_orders_symbol'), 'trading_orders', ['symbol'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_trading_orders_symbol'), table_name='trading_orders')
|
||||
op.drop_index(op.f('ix_trading_orders_analysis_id'), table_name='trading_orders')
|
||||
op.drop_table('trading_orders')
|
||||
op.drop_index('ix_trading_events_symbol_id', table_name='trading_events')
|
||||
op.drop_table('trading_events')
|
||||
op.drop_index('ix_trading_analyses_symbol_started', table_name='trading_analyses')
|
||||
op.drop_table('trading_analyses')
|
||||
op.drop_table('trading_symbols')
|
||||
# ### end Alembic commands ###
|
||||
@@ -2,7 +2,7 @@ from fastapi import Depends, FastAPI
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from alphapulse.api import auth, chat, crawler, market, news, settings, skills
|
||||
from alphapulse.api import auth, chat, crawler, market, news, settings, skills, trading
|
||||
from alphapulse.core.auth import require_admin
|
||||
|
||||
# 关闭默认文档路由,改为登录后才能访问
|
||||
@@ -15,6 +15,7 @@ app.include_router(market.ws_router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(trading.router)
|
||||
|
||||
|
||||
@app.get("/api/health", include_in_schema=False)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""模拟盘页:引擎状态、启停、交易币种、时间线。账户和持仓来自 trader 进程写入 Redis 的快照"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from alphapulse.ai import llm
|
||||
from alphapulse.api.crawler import LOCAL_TZ
|
||||
from alphapulse.api.market import call_binance, client as market_client
|
||||
from alphapulse.core.auth import require_admin
|
||||
from alphapulse.core.db import get_session
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import AiProvider, TradingAccount, TradingAnalysis, TradingEvent, TradingSymbol
|
||||
from alphapulse.trading.client import ExchangeError, public_get
|
||||
from alphapulse.trading.engine import DAILY_ANALYSIS_LIMIT, OWN_CLIENT_ID, STATUS_KEY
|
||||
|
||||
router = APIRouter(prefix="/api/trading", tags=["trading"], dependencies=[Depends(require_admin)])
|
||||
|
||||
Angle = Literal["short", "long"]
|
||||
ANGLE_LABELS = {"short": "短线", "long": "长线"}
|
||||
SYMBOLS_CACHE_KEY = f"{KEY_PREFIX}trading:symbols"
|
||||
SYMBOLS_CACHE_SECONDS = 3600
|
||||
# 删除或切换角度前要求快照足够新,否则无法确认没有仓位和挂单
|
||||
SNAPSHOT_FRESH_SECONDS = 30
|
||||
|
||||
|
||||
class TodayUsage(BaseModel):
|
||||
analyses: int
|
||||
limit: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
configured: bool
|
||||
running: bool
|
||||
running_changed_at: datetime | None
|
||||
provider_id: int | None
|
||||
reasoning_effort: str | None
|
||||
# trader 进程的心跳快照;进程离线时为空
|
||||
snapshot: dict | None
|
||||
today: TodayUsage
|
||||
|
||||
|
||||
class ConfigIn(BaseModel):
|
||||
provider_id: int | None = None
|
||||
reasoning_effort: llm.Effort | None = None
|
||||
|
||||
|
||||
class SymbolOption(BaseModel):
|
||||
symbol: str
|
||||
base: str
|
||||
|
||||
|
||||
class SymbolOut(BaseModel):
|
||||
symbol: str
|
||||
angle: str
|
||||
added_at: datetime
|
||||
analysis_requested_at: datetime | None
|
||||
analyzing: bool
|
||||
# 当前计划的状态:pending / open,没有计划为空
|
||||
plan_status: str | None
|
||||
|
||||
|
||||
class SymbolIn(BaseModel):
|
||||
symbol: str
|
||||
angle: Angle
|
||||
|
||||
|
||||
class SymbolPatch(BaseModel):
|
||||
angle: Angle
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
id: int
|
||||
created_at: datetime
|
||||
symbol: str | None
|
||||
analysis_id: int | None
|
||||
kind: str
|
||||
level: str
|
||||
message: str
|
||||
data: dict | None
|
||||
|
||||
|
||||
async def get_account(session: AsyncSession) -> TradingAccount:
|
||||
return await session.get(TradingAccount, 1) or TradingAccount(id=1, running=False)
|
||||
|
||||
|
||||
async def read_snapshot() -> dict | None:
|
||||
raw = await redis.get(STATUS_KEY)
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(session: Annotated[AsyncSession, Depends(get_session)]) -> StatusOut:
|
||||
account = await get_account(session)
|
||||
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
count, input_tokens, output_tokens = (await session.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(TradingAnalysis.usage["input_tokens"].as_integer()), 0),
|
||||
func.coalesce(func.sum(TradingAnalysis.usage["output_tokens"].as_integer()), 0),
|
||||
).where(TradingAnalysis.started_at >= today)
|
||||
)).one()
|
||||
return StatusOut(
|
||||
configured=account.api_key_encrypted is not None, running=account.running,
|
||||
running_changed_at=account.running_changed_at, 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 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, "模型配置不存在")
|
||||
for key, value in fields.items():
|
||||
setattr(account, key, value)
|
||||
session.add(account)
|
||||
await session.commit()
|
||||
return await get_status(session)
|
||||
|
||||
|
||||
async def symbol_options() -> list[dict]:
|
||||
"""可交易的币:模拟盘和实盘都在交易的 USDT 永续,只含加密货币(排除美股、商品等 TradFi)"""
|
||||
if cached := await redis.get(SYMBOLS_CACHE_KEY):
|
||||
return json.loads(cached)
|
||||
|
||||
def crypto_perpetuals(info: dict) -> dict[str, str]:
|
||||
return {
|
||||
s["symbol"]: s["baseAsset"] for s in info["symbols"]
|
||||
if s["contractType"] == "PERPETUAL" and s.get("underlyingType") == "COIN"
|
||||
and s["status"] == "TRADING" and s["quoteAsset"] == "USDT"
|
||||
}
|
||||
|
||||
try:
|
||||
demo = crypto_perpetuals(await public_get("/fapi/v1/exchangeInfo"))
|
||||
except ExchangeError as e:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(e)) from None
|
||||
# 分析用实盘行情,执行在模拟盘,两边都要有
|
||||
mainnet = crypto_perpetuals(await call_binance(market_client.get("um", "exchange_info")))
|
||||
options = [{"symbol": s, "base": b} for s, b in sorted(demo.items()) if s in mainnet]
|
||||
await redis.set(SYMBOLS_CACHE_KEY, json.dumps(options), ex=SYMBOLS_CACHE_SECONDS)
|
||||
return options
|
||||
|
||||
|
||||
@router.get("/symbol-options")
|
||||
async def list_symbol_options() -> list[SymbolOption]:
|
||||
return [SymbolOption.model_validate(o) for o in await symbol_options()]
|
||||
|
||||
|
||||
@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()
|
||||
analyzing = set((await session.scalars(select(TradingAnalysis.symbol).where(TradingAnalysis.status == "running"))).all())
|
||||
plans = dict((await session.execute(
|
||||
select(TradingAnalysis.symbol, TradingAnalysis.plan_status)
|
||||
.where(TradingAnalysis.plan_status.in_(("pending", "open")), TradingAnalysis.dry_run.is_(False))
|
||||
)).all())
|
||||
return [
|
||||
SymbolOut(
|
||||
symbol=s.symbol, angle=s.angle, added_at=s.added_at, analysis_requested_at=s.analysis_requested_at,
|
||||
analyzing=s.symbol in analyzing, plan_status=plans.get(s.symbol),
|
||||
)
|
||||
for s in symbols
|
||||
]
|
||||
|
||||
|
||||
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
|
||||
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
||||
symbol = body.symbol.strip().upper()
|
||||
if not any(o["symbol"] == symbol for o in await symbol_options()):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{symbol} 不是模拟盘和实盘都在交易的加密货币 USDT 永续合约")
|
||||
session.add(TradingSymbol(symbol=symbol, angle=body.angle))
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_added", message=f"加入交易列表,角度:{ANGLE_LABELS[body.angle]}"))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已在交易列表中") from None
|
||||
|
||||
|
||||
async def ensure_idle(session: AsyncSession, symbol: str, action: str) -> TradingSymbol:
|
||||
"""只有空仓、没有本程序的挂单、没有进行中的计划和分析时,才能删除或切换角度"""
|
||||
s = await session.get(TradingSymbol, symbol)
|
||||
if s is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"{symbol} 不在交易列表中")
|
||||
busy = await session.scalar(select(func.count()).where(
|
||||
TradingAnalysis.symbol == symbol,
|
||||
or_(TradingAnalysis.status == "running",
|
||||
TradingAnalysis.plan_status.in_(("pending", "open")) & TradingAnalysis.dry_run.is_(False)),
|
||||
))
|
||||
if busy:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有进行中的分析或计划,不能{action}")
|
||||
snapshot = await read_snapshot()
|
||||
synced_at = snapshot and snapshot["synced_at"] and datetime.fromisoformat(snapshot["synced_at"])
|
||||
if not synced_at or datetime.now(LOCAL_TZ) - synced_at > timedelta(seconds=SNAPSHOT_FRESH_SECONDS):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"无法确认 {symbol} 的仓位和挂单(引擎离线或连接币安失败),暂时不能{action}")
|
||||
if any(p["symbol"] == symbol for p in snapshot["positions"]):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有持仓,不能{action}")
|
||||
if any(o["symbol"] == symbol and OWN_CLIENT_ID.match(o["client_id"]) for o in snapshot["orders"] + snapshot["algo_orders"]):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 有本程序的挂单,不能{action}")
|
||||
return s
|
||||
|
||||
|
||||
@router.patch("/symbols/{symbol}")
|
||||
async def patch_symbol(symbol: str, body: SymbolPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
||||
s = await ensure_idle(session, symbol, "切换角度")
|
||||
if s.angle != body.angle:
|
||||
session.add(TradingEvent(symbol=symbol, kind="angle_changed",
|
||||
message=f"角度从{ANGLE_LABELS[s.angle]}改为{ANGLE_LABELS[body.angle]}"))
|
||||
s.angle = body.angle
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.delete("/symbols/{symbol}")
|
||||
async def delete_symbol(symbol: str, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
|
||||
"""只移出列表;分析、订单流水和时间线保留"""
|
||||
s = await ensure_idle(session, symbol, "删除")
|
||||
await session.delete(s)
|
||||
session.add(TradingEvent(symbol=symbol, kind="symbol_removed", message="移出交易列表"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
symbol: str | None = None,
|
||||
before: int | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> list[EventOut]:
|
||||
"""按 id 倒序分页;指定 symbol 时同时返回账户级事件(启停等)"""
|
||||
query = select(TradingEvent).order_by(TradingEvent.id.desc()).limit(limit)
|
||||
if symbol:
|
||||
query = query.where(or_(TradingEvent.symbol == symbol, TradingEvent.symbol.is_(None)))
|
||||
if before:
|
||||
query = query.where(TradingEvent.id < before)
|
||||
return [EventOut.model_validate(e, from_attributes=True) for e in await session.scalars(query)]
|
||||
@@ -2,7 +2,7 @@ from alphapulse.core.db import Base
|
||||
from alphapulse.models.ai import AiProvider, ChatMessage, ChatSession, ContextVersion, SkillNode, SkillVersion
|
||||
from alphapulse.models.market import Kline, KlineSyncState, MarketSymbol
|
||||
from alphapulse.models.news import CrawlLog, CrawlRun, NewsItem, NewsRevision, RawBody, RawResponse, Source
|
||||
from alphapulse.models.trading import TradingAccount
|
||||
from alphapulse.models.trading import TradingAccount, TradingAnalysis, TradingEvent, TradingOrder, TradingSymbol
|
||||
|
||||
__all__ = [
|
||||
"AiProvider",
|
||||
@@ -23,4 +23,8 @@ __all__ = [
|
||||
"SkillVersion",
|
||||
"Source",
|
||||
"TradingAccount",
|
||||
"TradingAnalysis",
|
||||
"TradingEvent",
|
||||
"TradingOrder",
|
||||
"TradingSymbol",
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, LargeBinary, String, func
|
||||
from sqlalchemy import BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from alphapulse.core.db import Base
|
||||
@@ -22,3 +24,104 @@ class TradingAccount(Base):
|
||||
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TradingSymbol(Base):
|
||||
__tablename__ = "trading_symbols"
|
||||
|
||||
symbol: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
# short:24h 内平仓;long:12–48h 平仓
|
||||
angle: Mapped[str] = mapped_column(String(8))
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
# 待执行的分析请求:多个触发在执行前合并为一次
|
||||
analysis_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
analysis_reasons: Mapped[list[str]] = mapped_column(JSONB, default=list)
|
||||
|
||||
|
||||
class TradingAnalysis(Base):
|
||||
"""一次模型分析;action=plan 的行同时是一个交易计划,plan_* 等字段记录其生命周期"""
|
||||
|
||||
__tablename__ = "trading_analyses"
|
||||
__table_args__ = (Index("ix_trading_analyses_symbol_started", "symbol", "started_at"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
angle: Mapped[str] = mapped_column(String(8))
|
||||
reasons: Mapped[list[str]] = mapped_column(JSONB)
|
||||
# 停止状态下的分析只记录计划、不下单
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean)
|
||||
# running / done / failed
|
||||
status: Mapped[str] = mapped_column(String(16), default="running")
|
||||
# plan / keep / exit
|
||||
action: Mapped[str | None] = mapped_column(String(8))
|
||||
# submit_trade_plan 的参数原样保存
|
||||
decision: Mapped[dict | None] = mapped_column(JSONB)
|
||||
# 程序算出的各档数量、风险金额、盈亏比等
|
||||
sizing: Mapped[dict | None] = mapped_column(JSONB)
|
||||
text: Mapped[str | None] = mapped_column(Text)
|
||||
transcript: Mapped[list | None] = mapped_column(JSONB)
|
||||
provider_id: Mapped[int | None] = mapped_column(ForeignKey("ai_providers.id", ondelete="SET NULL"))
|
||||
model: Mapped[str | None] = mapped_column(String(128))
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
|
||||
usage: Mapped[dict | None] = mapped_column(JSONB)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# pending(挂单中)/ open(持仓中)/ closed / canceled(未成交即结束)/ replaced(被新计划替换)
|
||||
plan_status: Mapped[str | None] = mapped_column(String(16))
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
first_fill_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
close_deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
# 平仓后的已实现盈亏、手续费
|
||||
result: Mapped[dict | None] = mapped_column(JSONB)
|
||||
|
||||
|
||||
class TradingOrder(Base):
|
||||
"""订单流水:先写行再发单,clientOrderId / clientAlgoId 为 ap{id},超时后按它查询而不是换 id 重发"""
|
||||
|
||||
__tablename__ = "trading_orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
analysis_id: Mapped[int | None] = mapped_column(ForeignKey("trading_analyses.id"), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32), index=True)
|
||||
# entry / tp / stop / close
|
||||
purpose: Mapped[str] = mapped_column(String(8))
|
||||
# 入场或止盈的档位序号,从 0 开始
|
||||
leg: Mapped[int | None] = mapped_column(Integer)
|
||||
# 条件单走 /fapi/v1/algoOrder
|
||||
is_algo: Mapped[bool] = mapped_column(Boolean)
|
||||
side: Mapped[str] = mapped_column(String(4))
|
||||
type: Mapped[str] = mapped_column(String(24))
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
trigger_price: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric)
|
||||
reduce_only: Mapped[bool] = mapped_column(Boolean)
|
||||
# submitting / new / partially_filled / filled / canceled / expired / rejected / finished / failed
|
||||
status: Mapped[str] = mapped_column(String(16), default="submitting")
|
||||
# orderId 或 algoId
|
||||
exchange_id: Mapped[str | None] = mapped_column(String(32))
|
||||
filled_qty: Mapped[Decimal] = mapped_column(Numeric, default=0)
|
||||
avg_price: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TradingEvent(Base):
|
||||
"""只追加的时间线"""
|
||||
|
||||
__tablename__ = "trading_events"
|
||||
__table_args__ = (Index("ix_trading_events_symbol_id", "symbol", "id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
# 账户级事件(启停等)为空
|
||||
symbol: Mapped[str | None] = mapped_column(String(32))
|
||||
analysis_id: Mapped[int | None] = mapped_column(ForeignKey("trading_analyses.id"))
|
||||
kind: Mapped[str] = mapped_column(String(32))
|
||||
# info / warn / error
|
||||
level: Mapped[str] = mapped_column(String(8), default="info")
|
||||
message: Mapped[str] = mapped_column(Text)
|
||||
data: Mapped[dict | None] = mapped_column(JSONB)
|
||||
@@ -36,6 +36,18 @@ class ExchangeError(Exception):
|
||||
return f"币安返回 {self.status}({self.code}):{self.message}"
|
||||
|
||||
|
||||
async def public_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""模拟盘公开接口(exchangeInfo 等),不需要 key"""
|
||||
async with httpx.AsyncClient(base_url=BASE_URL, timeout=TIMEOUT_SECONDS) as http:
|
||||
try:
|
||||
resp = await http.get(path, params=params)
|
||||
except httpx.TransportError as e:
|
||||
raise ExchangeError(None, None, f"无法连接币安模拟盘:{e!r}") from e
|
||||
if resp.status_code >= 400:
|
||||
raise ExchangeError(resp.status_code, None, resp.text[:200])
|
||||
return resp.json()
|
||||
|
||||
|
||||
class DemoClient:
|
||||
def __init__(self, api_key: str, api_secret: str) -> None:
|
||||
self.secret = api_secret.encode()
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""模拟盘交易进程:定时把账户、持仓、挂单快照写入 Redis,供页面读取。
|
||||
|
||||
用法:python -m alphapulse.trading.engine
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import KEY_PREFIX, redis
|
||||
from alphapulse.models import TradingAccount, TradingSymbol
|
||||
from alphapulse.trading.client import DemoClient, ExchangeError
|
||||
|
||||
logger = logging.getLogger("alphapulse.trading.engine")
|
||||
|
||||
# 进程挂掉后快照自动过期,页面据此显示「引擎离线」
|
||||
STATUS_KEY = f"{KEY_PREFIX}trader:status"
|
||||
STATUS_TTL_SECONDS = 15
|
||||
SNAPSHOT_SECONDS = 3
|
||||
# 杠杆和保证金模式很少变,按币缓存
|
||||
SYMBOL_CONFIG_SECONDS = 60
|
||||
# 所有币合计每天(北京时间)最多自动分析的次数
|
||||
DAILY_ANALYSIS_LIMIT = 60
|
||||
# 本程序下的单 clientOrderId / clientAlgoId 为 ap{流水 id};其他(含用户手动下的)不动
|
||||
OWN_CLIENT_ID = re.compile(r"^ap\d+$")
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self) -> None:
|
||||
self.client: DemoClient | None = None
|
||||
self.key_ciphertext: bytes | None = None
|
||||
# 最近一次成功同步的数据;同步失败时保留并标注错误,避免页面闪空
|
||||
self.data: dict | None = None
|
||||
self.synced_at: datetime | None = None
|
||||
self.symbol_config: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
async def ensure_client(self, account: TradingAccount | None) -> DemoClient | None:
|
||||
ciphertext = account.api_key_encrypted if account else None
|
||||
if ciphertext != self.key_ciphertext:
|
||||
# key 被修改或删除:旧连接作废,旧数据属于另一个账户,一并清掉
|
||||
if self.client is not None:
|
||||
await self.client.http.aclose()
|
||||
self.client = None
|
||||
self.data = self.synced_at = None
|
||||
self.symbol_config.clear()
|
||||
self.key_ciphertext = ciphertext
|
||||
if account is not None and ciphertext is not None:
|
||||
self.client = DemoClient(decrypt(ciphertext), decrypt(account.api_secret_encrypted))
|
||||
return self.client
|
||||
|
||||
async def leverage_and_margin(self, client: DemoClient, symbol: str) -> dict:
|
||||
cached = self.symbol_config.get(symbol)
|
||||
if cached is None or time.monotonic() - cached[0] > SYMBOL_CONFIG_SECONDS:
|
||||
config = (await client.request("GET", "/fapi/v1/symbolConfig", {"symbol": symbol}))[0]
|
||||
cached = (time.monotonic(), {"leverage": config["leverage"], "margin_type": config["marginType"]})
|
||||
self.symbol_config[symbol] = cached
|
||||
return cached[1]
|
||||
|
||||
async def fetch(self, client: DemoClient, symbols: list[str]) -> dict:
|
||||
account = await client.request("GET", "/fapi/v3/account")
|
||||
positions = [p for p in await client.request("GET", "/fapi/v3/positionRisk") if float(p["positionAmt"]) != 0]
|
||||
# 挂单只查交易列表里的币和有持仓的币:不带 symbol 查全部的权重是 40,3 秒一次太贵
|
||||
watched = sorted(set(symbols) | {p["symbol"] for p in positions})
|
||||
orders, algo_orders = [], []
|
||||
for symbol in watched:
|
||||
orders += await client.request("GET", "/fapi/v1/openOrders", {"symbol": symbol})
|
||||
algo_orders += await client.request("GET", "/fapi/v1/openAlgoOrders", {"symbol": symbol})
|
||||
return {
|
||||
"account": {
|
||||
"wallet_balance": account["totalWalletBalance"],
|
||||
"margin_balance": account["totalMarginBalance"],
|
||||
"available_balance": account["availableBalance"],
|
||||
"unrealized_pnl": account["totalUnrealizedProfit"],
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
"symbol": p["symbol"],
|
||||
"side": "LONG" if float(p["positionAmt"]) > 0 else "SHORT",
|
||||
"amount": p["positionAmt"].lstrip("-"),
|
||||
"entry_price": p["entryPrice"],
|
||||
"break_even_price": p["breakEvenPrice"],
|
||||
"mark_price": p["markPrice"],
|
||||
"unrealized_pnl": p["unRealizedProfit"],
|
||||
"liquidation_price": p["liquidationPrice"],
|
||||
"notional": p["notional"].lstrip("-"),
|
||||
"margin": p["isolatedMargin"] if float(p["isolatedMargin"]) else p["initialMargin"],
|
||||
**await self.leverage_and_margin(client, p["symbol"]),
|
||||
}
|
||||
for p in positions
|
||||
],
|
||||
"orders": [
|
||||
{
|
||||
"symbol": o["symbol"], "client_id": o["clientOrderId"], "side": o["side"], "type": o["type"],
|
||||
"price": o["price"], "quantity": o["origQty"], "filled": o["executedQty"], "reduce_only": o["reduceOnly"],
|
||||
"time": o["time"],
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
"algo_orders": [
|
||||
{
|
||||
"symbol": o["symbol"], "client_id": o["clientAlgoId"], "side": o["side"], "type": o["orderType"],
|
||||
"trigger_price": o["triggerPrice"], "quantity": o["quantity"], "close_position": o["closePosition"],
|
||||
"reduce_only": o["reduceOnly"], "time": o["createTime"],
|
||||
}
|
||||
for o in algo_orders
|
||||
],
|
||||
}
|
||||
|
||||
async def publish(self) -> None:
|
||||
async with SessionLocal() as session:
|
||||
account = await session.get(TradingAccount, 1)
|
||||
symbols = list((await session.scalars(select(TradingSymbol.symbol))).all())
|
||||
error = None
|
||||
client = await self.ensure_client(account)
|
||||
if client is None:
|
||||
error = "还没有填写模拟盘 key"
|
||||
else:
|
||||
try:
|
||||
self.data = await self.fetch(client, symbols)
|
||||
self.synced_at = datetime.now(UTC)
|
||||
except ExchangeError as e:
|
||||
logger.warning("同步模拟盘失败:%s", e)
|
||||
error = str(e)
|
||||
status = {
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"synced_at": self.synced_at.isoformat() if self.synced_at else None,
|
||||
"error": error,
|
||||
**(self.data or {"account": None, "positions": [], "orders": [], "algo_orders": []}),
|
||||
}
|
||||
await redis.set(STATUS_KEY, json.dumps(status), ex=STATUS_TTL_SECONDS)
|
||||
|
||||
|
||||
async def snapshot_loop() -> None:
|
||||
snapshot = Snapshot()
|
||||
while True:
|
||||
try:
|
||||
await snapshot.publish()
|
||||
except Exception:
|
||||
logger.exception("发布快照失败,稍后重试")
|
||||
await asyncio.sleep(SNAPSHOT_SECONDS)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# 分析器和执行器在后续里程碑加入同一个 TaskGroup
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(snapshot_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())
|
||||
@@ -24,6 +24,10 @@ services:
|
||||
<<: *backend
|
||||
command: [python, -m, alphapulse.market.worker]
|
||||
|
||||
trader:
|
||||
<<: *backend
|
||||
command: [python, -m, alphapulse.trading.engine]
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../web
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Effort } from "@/apis/settings"
|
||||
import { http } from "@/http"
|
||||
|
||||
export type Angle = "short" | "long"
|
||||
|
||||
export const ANGLE_LABELS: Record<Angle, string> = { short: "短线(24h 内平仓)", long: "长线(12–48h 平仓)" }
|
||||
|
||||
/** 以下数值字段都是币安返回的十进制字符串,避免浮点误差 */
|
||||
export interface SnapshotPosition {
|
||||
symbol: string
|
||||
side: "LONG" | "SHORT"
|
||||
amount: string
|
||||
entry_price: string
|
||||
break_even_price: string
|
||||
mark_price: string
|
||||
unrealized_pnl: string
|
||||
liquidation_price: string
|
||||
notional: string
|
||||
margin: string
|
||||
leverage: number
|
||||
margin_type: string
|
||||
}
|
||||
|
||||
export interface SnapshotOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
price: string
|
||||
quantity: string
|
||||
filled: string
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface SnapshotAlgoOrder {
|
||||
symbol: string
|
||||
client_id: string
|
||||
side: "BUY" | "SELL"
|
||||
type: string
|
||||
trigger_price: string
|
||||
quantity: string
|
||||
close_position: boolean
|
||||
reduce_only: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
updated_at: string
|
||||
/** 最近一次成功同步币安的时间 */
|
||||
synced_at: string | null
|
||||
error: string | null
|
||||
account: { wallet_balance: string, margin_balance: string, available_balance: string, unrealized_pnl: string } | null
|
||||
positions: SnapshotPosition[]
|
||||
orders: SnapshotOrder[]
|
||||
algo_orders: SnapshotAlgoOrder[]
|
||||
}
|
||||
|
||||
export interface TradingStatus {
|
||||
configured: boolean
|
||||
running: boolean
|
||||
running_changed_at: string | null
|
||||
provider_id: number | null
|
||||
reasoning_effort: Effort | null
|
||||
/** trader 进程离线时为空 */
|
||||
snapshot: Snapshot | null
|
||||
today: { analyses: number, limit: number, input_tokens: number, output_tokens: number }
|
||||
}
|
||||
|
||||
export interface TradingSymbol {
|
||||
symbol: string
|
||||
angle: Angle
|
||||
added_at: string
|
||||
analysis_requested_at: string | null
|
||||
analyzing: boolean
|
||||
plan_status: "pending" | "open" | null
|
||||
}
|
||||
|
||||
export interface SymbolOption {
|
||||
symbol: string
|
||||
base: string
|
||||
}
|
||||
|
||||
export interface TradingEvent {
|
||||
id: number
|
||||
created_at: string
|
||||
symbol: string | null
|
||||
analysis_id: number | null
|
||||
kind: string
|
||||
level: "info" | "warn" | "error"
|
||||
message: string
|
||||
data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/** 本程序下的单;其他是用户手动下的 */
|
||||
export function isOwnOrder(clientId: string) {
|
||||
return /^ap\d+$/.test(clientId)
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<TradingStatus> {
|
||||
return http.get("/trading/status")
|
||||
}
|
||||
|
||||
export function startTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/start")
|
||||
}
|
||||
|
||||
export function stopTrading(): Promise<TradingStatus> {
|
||||
return http.post("/trading/stop")
|
||||
}
|
||||
|
||||
export function patchConfig(body: { provider_id?: number | null, reasoning_effort?: Effort | null }): Promise<TradingStatus> {
|
||||
return http.patch("/trading/config", body)
|
||||
}
|
||||
|
||||
export function listSymbolOptions(): Promise<SymbolOption[]> {
|
||||
return http.get("/trading/symbol-options")
|
||||
}
|
||||
|
||||
export function listSymbols(): Promise<TradingSymbol[]> {
|
||||
return http.get("/trading/symbols")
|
||||
}
|
||||
|
||||
export function addSymbol(symbol: string, angle: Angle): Promise<void> {
|
||||
return http.post("/trading/symbols", { symbol, angle })
|
||||
}
|
||||
|
||||
export function patchSymbol(symbol: string, angle: Angle): Promise<void> {
|
||||
return http.patch(`/trading/symbols/${symbol}`, { angle })
|
||||
}
|
||||
|
||||
export function deleteSymbol(symbol: string): Promise<void> {
|
||||
return http.delete(`/trading/symbols/${symbol}`)
|
||||
}
|
||||
|
||||
export function listEvents(params: { symbol?: string, before?: number, limit?: number }): Promise<TradingEvent[]> {
|
||||
return http.get("/trading/events", { params })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Angle, SymbolOption } from "@/apis/trading"
|
||||
import { addSymbol, ANGLE_LABELS, listSymbolOptions } from "@/apis/trading"
|
||||
|
||||
const emit = defineEmits<{ added: [symbol: string] }>()
|
||||
const visible = defineModel<boolean>({ required: true })
|
||||
|
||||
const symbol = ref("")
|
||||
const angle = ref<Angle>("long")
|
||||
const symbolOptions = ref<SymbolOption[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const options = computed(() => symbolOptions.value.map(s => ({ value: s.symbol, label: `${s.base}/USDT 永续` })))
|
||||
|
||||
watch(visible, async (open) => {
|
||||
if (!open)
|
||||
return
|
||||
symbol.value = ""
|
||||
loading.value = true
|
||||
try {
|
||||
symbolOptions.value = await listSymbolOptions()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await addSymbol(symbol.value, angle.value)
|
||||
ElMessage.success(`已加入 ${symbol.value}`)
|
||||
emit("added", symbol.value)
|
||||
visible.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="新增交易币种" width="480px">
|
||||
<el-form label-width="64px">
|
||||
<el-form-item label="币种">
|
||||
<el-select-v2
|
||||
v-model="symbol" :options="options" :loading="loading" filterable placeholder="搜索,如 BTCUSDT"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
只列出模拟盘和实盘都在交易的加密货币 USDT 永续合约
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角度">
|
||||
<el-radio-group v-model="angle">
|
||||
<el-radio v-for="(label, value) in ANGLE_LABELS" :key="value" :value="value">
|
||||
{{ label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :disabled="!symbol" :loading="submitting" @click="submit">
|
||||
加入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,409 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Effort, Provider } from "@/apis/settings"
|
||||
import type { Angle, SnapshotPosition, TradingEvent, TradingStatus, TradingSymbol } from "@/apis/trading"
|
||||
import { EFFORTS, listProviders } from "@/apis/settings"
|
||||
import { ANGLE_LABELS, deleteSymbol, getStatus, isOwnOrder, listEvents, listSymbols, patchConfig, patchSymbol, startTrading, stopTrading } from "@/apis/trading"
|
||||
import { usePersisted } from "@/utils/storage"
|
||||
import { formatTime } from "@/utils/text"
|
||||
import AddSymbolDialog from "./components/AddSymbolDialog.vue"
|
||||
|
||||
const STATUS_REFRESH_MS = 3000
|
||||
const EVENTS_PAGE = 50
|
||||
const SHORT_ANGLE_LABELS: Record<Angle, string> = { short: "短线", long: "长线" }
|
||||
const LEVEL_TYPES = { info: "primary", warn: "warning", error: "danger" } as const
|
||||
|
||||
const status = ref<TradingStatus>()
|
||||
const symbols = ref<TradingSymbol[]>([])
|
||||
const providers = ref<Provider[]>([])
|
||||
const selected = usePersisted("trading.symbol", "")
|
||||
const events = ref<TradingEvent[]>([])
|
||||
const eventsExhausted = ref(false)
|
||||
const addVisible = ref(false)
|
||||
const toggling = ref(false)
|
||||
|
||||
const snapshot = computed(() => status.value?.snapshot ?? null)
|
||||
const account = computed(() => snapshot.value?.account ?? null)
|
||||
const positions = computed(() => snapshot.value?.positions ?? [])
|
||||
const current = computed(() => symbols.value.find(s => s.symbol === selected.value))
|
||||
const symbolOrders = computed(() => {
|
||||
const s = snapshot.value
|
||||
if (!s)
|
||||
return []
|
||||
return [
|
||||
...s.orders.filter(o => o.symbol === selected.value).map(o => ({
|
||||
...o,
|
||||
conditional: false,
|
||||
priceText: num(o.price)
|
||||
})),
|
||||
...s.algo_orders.filter(o => o.symbol === selected.value).map(o => ({
|
||||
...o,
|
||||
conditional: true,
|
||||
priceText: `触发 ${num(o.trigger_price)}`,
|
||||
filled: "0",
|
||||
quantity: o.close_position ? "全部持仓" : o.quantity
|
||||
}))
|
||||
].sort((a, b) => b.time - a.time)
|
||||
})
|
||||
|
||||
function num(value: string) {
|
||||
return String(Number(value))
|
||||
}
|
||||
|
||||
function money(value: string | undefined) {
|
||||
return value === undefined ? "-" : Number(value).toFixed(2)
|
||||
}
|
||||
|
||||
function pnlClass(value: string) {
|
||||
const n = Number(value)
|
||||
return n > 0 ? "text-[var(--el-color-success)]" : n < 0 ? "text-[var(--el-color-danger)]" : ""
|
||||
}
|
||||
|
||||
/** 持仓当前生效的止损、止盈触发价:取交易所上平仓方向的挂单 */
|
||||
function exitPrices(p: SnapshotPosition) {
|
||||
const s = snapshot.value!
|
||||
const closeSide = p.side === "LONG" ? "SELL" : "BUY"
|
||||
const stops = s.algo_orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.type.startsWith("STOP")).map(o => Number(o.trigger_price))
|
||||
const targets = [
|
||||
...s.orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.reduce_only && o.type === "LIMIT").map(o => Number(o.price)),
|
||||
...s.algo_orders.filter(o => o.symbol === p.symbol && o.side === closeSide && o.type.startsWith("TAKE_PROFIT")).map(o => Number(o.trigger_price))
|
||||
]
|
||||
const mark = Number(p.mark_price)
|
||||
const nearest = (prices: number[]) => prices.length ? String(prices.reduce((a, b) => Math.abs(a - mark) < Math.abs(b - mark) ? a : b)) : "无"
|
||||
return { stop: nearest(stops), target: nearest(targets) }
|
||||
}
|
||||
|
||||
function symbolState(s: TradingSymbol) {
|
||||
if (s.analyzing)
|
||||
return { label: "分析中", type: "warning" } as const
|
||||
if (positions.value.some(p => p.symbol === s.symbol))
|
||||
return { label: "持仓中", type: "success" } as const
|
||||
if (s.plan_status === "pending")
|
||||
return { label: "挂单中", type: "primary" } as const
|
||||
return { label: "空仓", type: "info" } as const
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
[status.value, symbols.value] = await Promise.all([getStatus(), listSymbols()])
|
||||
if (!current.value && symbols.value.length)
|
||||
selected.value = symbols.value[0].symbol
|
||||
}
|
||||
|
||||
/** 拉取最新一页,与已加载的合并(保留已翻页加载的旧事件) */
|
||||
async function refreshEvents() {
|
||||
const symbol = selected.value
|
||||
const latest = await listEvents({ symbol: symbol || undefined, limit: EVENTS_PAGE })
|
||||
// 请求期间切换了币种:丢弃旧币种的结果
|
||||
if (symbol !== selected.value)
|
||||
return
|
||||
const newestId = events.value[0]?.id ?? 0
|
||||
const fresh = latest.filter(e => e.id > newestId)
|
||||
if (!events.value.length)
|
||||
eventsExhausted.value = latest.length < EVENTS_PAGE
|
||||
events.value = [...fresh, ...events.value]
|
||||
}
|
||||
|
||||
async function loadMoreEvents() {
|
||||
const symbol = selected.value
|
||||
const older = await listEvents({ symbol: symbol || undefined, before: events.value.at(-1)?.id, limit: EVENTS_PAGE })
|
||||
if (symbol !== selected.value)
|
||||
return
|
||||
eventsExhausted.value = older.length < EVENTS_PAGE
|
||||
events.value = [...events.value, ...older]
|
||||
}
|
||||
|
||||
watch(selected, () => {
|
||||
events.value = []
|
||||
refreshEvents()
|
||||
})
|
||||
|
||||
async function toggleRunning() {
|
||||
if (status.value!.running) {
|
||||
await ElMessageBox.confirm(
|
||||
"停止后不再做新的分析,并撤销未成交的入场单。已有持仓保留,止损、止盈、到期平仓、短线失效平仓等程序规则继续执行。",
|
||||
"停止行情交易",
|
||||
{ type: "warning", confirmButtonText: "停止" }
|
||||
)
|
||||
} else {
|
||||
await ElMessageBox.confirm(
|
||||
"开始后,引擎会按触发条件和例行节奏自动分析交易列表里的币,并在币安模拟盘下单。",
|
||||
"开始行情交易",
|
||||
{ type: "info", confirmButtonText: "开始" }
|
||||
)
|
||||
}
|
||||
toggling.value = true
|
||||
try {
|
||||
status.value = status.value!.running ? await stopTrading() : await startTrading()
|
||||
await refreshEvents()
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changeProvider(providerId: number | null) {
|
||||
status.value = await patchConfig({ provider_id: providerId })
|
||||
}
|
||||
|
||||
async function changeEffort(effort: Effort | null) {
|
||||
status.value = await patchConfig({ reasoning_effort: effort })
|
||||
}
|
||||
|
||||
async function switchAngle(s: TradingSymbol) {
|
||||
const angle: Angle = s.angle === "short" ? "long" : "short"
|
||||
await ElMessageBox.confirm(`把 ${s.symbol} 从${SHORT_ANGLE_LABELS[s.angle]}改为${SHORT_ANGLE_LABELS[angle]}?`, "切换角度")
|
||||
await patchSymbol(s.symbol, angle)
|
||||
await Promise.all([refresh(), refreshEvents()])
|
||||
}
|
||||
|
||||
async function remove(s: TradingSymbol) {
|
||||
await ElMessageBox.confirm(`把 ${s.symbol} 移出交易列表?历史分析和时间线会保留。`, "删除确认", { type: "warning" })
|
||||
await deleteSymbol(s.symbol)
|
||||
ElMessage.success("已移出")
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function onAdded(symbol: string) {
|
||||
await refresh()
|
||||
selected.value = symbol
|
||||
}
|
||||
|
||||
refresh()
|
||||
refreshEvents()
|
||||
listProviders().then(list => providers.value = list)
|
||||
const timer = setInterval(() => {
|
||||
refresh()
|
||||
refreshEvents()
|
||||
}, STATUS_REFRESH_MS)
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status" class="h-full flex flex-col gap-3">
|
||||
<!-- 顶部:引擎状态、启停、账户 -->
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 border border-[var(--el-border-color)] rounded border-solid px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag v-if="snapshot" type="success" effect="dark">
|
||||
引擎在线
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" effect="dark">
|
||||
引擎离线
|
||||
</el-tag>
|
||||
<el-tag :type="status.running ? 'success' : 'info'">
|
||||
{{ status.running ? "交易中" : "已停止" }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
:type="status.running ? 'danger' : 'primary'" :loading="toggling" :disabled="!status.configured"
|
||||
@click="toggleRunning"
|
||||
>
|
||||
{{ status.running ? "停止" : "开始行情交易" }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>权益</span>{{ money(account?.margin_balance) }} USDT
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>可用</span>{{ money(account?.available_balance) }} USDT
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>未实现盈亏</span>
|
||||
<b :class="pnlClass(account?.unrealized_pnl ?? '0')">{{ money(account?.unrealized_pnl) }}</b>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>今日分析</span>{{ status.today.analyses }} / {{ status.today.limit }}
|
||||
<span class="ml-1">token</span>{{ status.today.input_tokens.toLocaleString() }} / {{ status.today.output_tokens.toLocaleString() }}
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<el-select
|
||||
:model-value="status.provider_id" class="w-56" clearable :value-on-clear="null" placeholder="模型:跟随默认配置"
|
||||
@update:model-value="changeProvider"
|
||||
>
|
||||
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="status.reasoning_effort" class="w-40" clearable :value-on-clear="null" placeholder="思考强度:跟随配置"
|
||||
@update:model-value="changeEffort"
|
||||
>
|
||||
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="!status.configured" type="warning" :closable="false" show-icon>
|
||||
还没有填写币安模拟盘 key,请先到「环境变量」页填写。
|
||||
</el-alert>
|
||||
<el-alert v-else-if="snapshot?.error" type="error" :closable="false" show-icon>
|
||||
同步模拟盘失败:{{ snapshot.error }}{{ snapshot.synced_at ? `(以下为 ${formatTime(snapshot.synced_at, "HH:mm:ss")} 的数据)` : "" }}
|
||||
</el-alert>
|
||||
|
||||
<!-- 持仓 -->
|
||||
<el-table :data="positions" border size="small" empty-text="没有持仓">
|
||||
<el-table-column prop="symbol" label="币种" min-width="110" />
|
||||
<el-table-column label="方向" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.side === 'LONG' ? 'success' : 'danger'" size="small">
|
||||
{{ row.side === "LONG" ? "多" : "空" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" min-width="90">
|
||||
<template #default="{ row }">
|
||||
{{ num(row.amount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开仓价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ num(row.entry_price) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保本价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.break_even_price).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标记价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.mark_price).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="未实现盈亏" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="pnlClass(row.unrealized_pnl)">{{ money(row.unrealized_pnl) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="强平价" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.liquidation_price) ? Number(row.liquidation_price).toFixed(2) : "-" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="杠杆" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.leverage }}x {{ row.margin_type === "ISOLATED" ? "逐仓" : "全仓" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="止损 / 止盈" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ exitPrices(row as SnapshotPosition).stop }} / {{ exitPrices(row as SnapshotPosition).target }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="min-h-0 flex flex-1 gap-3">
|
||||
<!-- 侧边栏:交易币种 -->
|
||||
<div class="w-60 flex flex-col border border-[var(--el-border-color)] rounded border-solid">
|
||||
<div class="flex items-center justify-between border-b border-b-[var(--el-border-color)] border-b-solid px-3 py-2">
|
||||
<span class="font-bold">交易币种</span>
|
||||
<el-button size="small" type="primary" @click="addVisible = true">
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="flex-1">
|
||||
<div
|
||||
v-for="s in symbols" :key="s.symbol"
|
||||
class="cursor-pointer border-b border-b-[var(--el-border-color-lighter)] border-b-solid px-3 py-2"
|
||||
:class="s.symbol === selected ? 'bg-[var(--el-color-primary-light-9)]' : ''"
|
||||
@click="selected = s.symbol"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-bold">{{ s.symbol }}</span>
|
||||
<el-tag :type="symbolState(s).type" size="small">
|
||||
{{ symbolState(s).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-[var(--el-text-color-secondary)]">
|
||||
<span>{{ ANGLE_LABELS[s.angle] }}</span>
|
||||
<span>
|
||||
<el-link type="primary" underline="never" class="text-xs!" @click.stop="switchAngle(s)">切换角度</el-link>
|
||||
<el-link type="danger" underline="never" class="ml-2 text-xs!" @click.stop="remove(s)">删除</el-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!symbols.length" description="还没有交易币种" :image-size="60" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 主区域:当前计划、挂单、时间线 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<div class="flex flex-col gap-3 pr-2">
|
||||
<div v-if="current" class="border border-[var(--el-border-color)] rounded border-solid p-3">
|
||||
<div class="mb-2 font-bold">
|
||||
{{ current.symbol }} 当前计划
|
||||
<span class="ml-2 text-xs text-[var(--el-text-color-secondary)] font-normal">{{ ANGLE_LABELS[current.angle] }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--el-text-color-secondary)]">
|
||||
暂无计划
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="current">
|
||||
<div class="mb-2 font-bold">
|
||||
当前挂单
|
||||
</div>
|
||||
<el-table :data="symbolOrders" border size="small" empty-text="没有挂单">
|
||||
<el-table-column label="类型" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.type }}
|
||||
<el-tag v-if="row.conditional" size="small" class="ml-1">
|
||||
条件单
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.side === "BUY" ? "买" : "卖" }}{{ row.reduce_only ? "(只减仓)" : "" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priceText" label="价格" min-width="130" />
|
||||
<el-table-column label="数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity === "全部持仓" ? row.quantity : num(row.quantity) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ isOwnOrder(row.client_id) ? "本程序" : "手动" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(new Date(row.time).toISOString(), "MM-DD HH:mm:ss") }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 font-bold">
|
||||
时间线
|
||||
</div>
|
||||
<el-timeline v-if="events.length" class="pl-1">
|
||||
<el-timeline-item
|
||||
v-for="e in events" :key="e.id" :timestamp="formatTime(e.created_at)" :type="LEVEL_TYPES[e.level]"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="info" class="mr-2">
|
||||
{{ e.symbol ?? "账户" }}
|
||||
</el-tag>
|
||||
<span :class="e.level === 'error' ? 'text-[var(--el-color-danger)]' : ''">{{ e.message }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="还没有事件" :image-size="60" />
|
||||
<el-button v-if="events.length && !eventsExhausted" link type="primary" @click="loadMoreEvents">
|
||||
加载更早的事件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<AddSymbolDialog v-model="addVisible" @added="onAdded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat {
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat span {
|
||||
margin-right: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ export const menuRoutes = [
|
||||
{ path: "/news", name: "news", component: () => import("@/pages/news/index.vue"), meta: { title: "新闻流" } },
|
||||
{ path: "/crawler", name: "crawler", component: () => import("@/pages/crawler/index.vue"), meta: { title: "采集控制台" } },
|
||||
{ path: "/market", name: "market", component: () => import("@/pages/market/index.vue"), meta: { title: "行情" } },
|
||||
{ path: "/trading", name: "trading", component: () => import("@/pages/trading/index.vue"), meta: { title: "模拟盘" } },
|
||||
{ path: "/chat", name: "chat", component: () => import("@/pages/chat/index.vue"), meta: { title: "Chat" } },
|
||||
{ path: "/skills", name: "skills", component: () => import("@/pages/skills/index.vue"), meta: { title: "Skills" } },
|
||||
{ path: "/settings", name: "settings", component: () => import("@/pages/settings/index.vue"), meta: { title: "环境变量" } }
|
||||
|
||||
Reference in new issue
Block a user