0cf3756b09
Prevent duplicate opens by atomically claiming an opening slot, verifying exchange perp is flat before live orders, setting leverage from ledger, and preferring exchange position size when closing perps. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
5.9 KiB
Python
211 lines
5.9 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from .api import router as api_router
|
||
from .config import get_settings
|
||
from .models.db import Database, set_db
|
||
from .strategy import StrategyEngine, set_engine
|
||
from .strategy.session import bootstrap_session
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||
)
|
||
logger = logging.getLogger("eth_hedge_sim")
|
||
|
||
|
||
def resolve_frontend_dist() -> Path:
|
||
here = Path(__file__).resolve()
|
||
repo_root = here.parents[2]
|
||
return repo_root / "frontend" / "dist"
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
db = Database()
|
||
set_db(db)
|
||
# 必须在 set_db 之后读 DB 覆盖,否则会按 .env 默认 okx 起会话,
|
||
# 而 health 显示 DB 里的 binance → 合约号/盘口错乱(期权一直 -/-)
|
||
from .exchange.runtime import load_runtime_settings
|
||
|
||
settings = load_runtime_settings()
|
||
engine = StrategyEngine()
|
||
set_engine(engine)
|
||
# LIVE:进程启动后不自动真下单,须人工点「启动」
|
||
if not get_settings().is_sim:
|
||
try:
|
||
db._conn.execute(
|
||
"UPDATE strategy_state SET running=0, phase=? WHERE id=1",
|
||
("paused",),
|
||
)
|
||
db._conn.commit()
|
||
logger.info("LIVE startup: forced strategy pause (manual start required)")
|
||
except Exception:
|
||
logger.exception("LIVE startup force-pause failed")
|
||
try:
|
||
from .live import get_executor
|
||
from .live.reconcile import log_exchange_db_mismatch
|
||
|
||
log_exchange_db_mismatch(get_executor(db))
|
||
except Exception:
|
||
logger.exception("LIVE startup reconcile log failed")
|
||
engine.ensure_loop()
|
||
|
||
session = bootstrap_session(settings)
|
||
try:
|
||
await session.start()
|
||
logger.info(
|
||
"exchange=%s strategy session started mode=%s",
|
||
settings.exchange,
|
||
"SIM" if get_settings().is_sim else "LIVE",
|
||
)
|
||
except Exception:
|
||
logger.exception("strategy session failed to start")
|
||
|
||
yield
|
||
|
||
await engine.pause()
|
||
if engine._task and not engine._task.done():
|
||
engine._task.cancel()
|
||
try:
|
||
await engine._task
|
||
except Exception:
|
||
pass
|
||
await session.stop()
|
||
from .exchange import set_exchange
|
||
from .strategy.session import set_session
|
||
|
||
set_session(None)
|
||
set_exchange(None)
|
||
set_engine(None)
|
||
db.close()
|
||
set_db(None)
|
||
|
||
|
||
app = FastAPI(
|
||
title="比特骆驼自动化对冲系统",
|
||
version="0.3.1",
|
||
description="比特骆驼自动化对冲系统(eth_hedge_sim)",
|
||
lifespan=lifespan,
|
||
docs_url=None if get_settings().disable_api_docs else "/docs",
|
||
redoc_url=None if get_settings().disable_api_docs else "/redoc",
|
||
openapi_url=None if get_settings().disable_api_docs else "/openapi.json",
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[],
|
||
allow_credentials=False,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
app.include_router(api_router)
|
||
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict:
|
||
from .strategy import get_engine
|
||
from .strategy.session import get_session
|
||
|
||
settings = get_settings()
|
||
try:
|
||
from .exchange.runtime import load_runtime_settings
|
||
|
||
rt = load_runtime_settings()
|
||
exchange_name = rt.exchange
|
||
except Exception:
|
||
exchange_name = settings.exchange
|
||
sess = get_session()
|
||
snap = sess.snapshot()
|
||
try:
|
||
st = get_engine().state()
|
||
except Exception:
|
||
st = None
|
||
return {
|
||
"ok": True,
|
||
"mode": settings.mode,
|
||
"env_name": settings.env_name,
|
||
"exchange": exchange_name,
|
||
"sim": settings.is_sim,
|
||
"market_connected": snap.connected,
|
||
"pair": snap.pair.to_dict() if snap.pair else None,
|
||
"updated_at_ms": snap.updated_at_ms,
|
||
"strategy": {
|
||
"running": st.get("running") if st else None,
|
||
"phase": st.get("phase") if st else None,
|
||
"rounds_done": st.get("rounds_done") if st else None,
|
||
},
|
||
}
|
||
|
||
|
||
_DIST = resolve_frontend_dist()
|
||
if (_DIST / "assets").is_dir():
|
||
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
|
||
|
||
_ICONS = _DIST / "icons"
|
||
if _ICONS.is_dir():
|
||
app.mount("/icons", StaticFiles(directory=str(_ICONS)), name="icons")
|
||
|
||
|
||
def _dist_file(name: str) -> Path:
|
||
return _DIST / name
|
||
|
||
|
||
@app.get("/manifest.webmanifest")
|
||
async def web_manifest():
|
||
path = _dist_file("manifest.webmanifest")
|
||
if not path.exists():
|
||
raise HTTPException(status_code=404, detail="manifest missing")
|
||
return FileResponse(
|
||
path,
|
||
media_type="application/manifest+json",
|
||
headers={"Cache-Control": "no-cache"},
|
||
)
|
||
|
||
|
||
@app.get("/sw.js")
|
||
async def service_worker():
|
||
path = _dist_file("sw.js")
|
||
if not path.exists():
|
||
raise HTTPException(status_code=404, detail="service worker missing")
|
||
return FileResponse(
|
||
path,
|
||
media_type="application/javascript",
|
||
headers={
|
||
"Cache-Control": "no-cache",
|
||
"Service-Worker-Allowed": "/",
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/")
|
||
async def index_page():
|
||
index = _DIST / "index.html"
|
||
if index.exists():
|
||
return FileResponse(index)
|
||
return {
|
||
"ok": True,
|
||
"msg": "frontend not built yet; run: cd frontend && npm ci && npm run build",
|
||
"health": "/health",
|
||
}
|
||
|
||
|
||
@app.get("/app/{full_path:path}")
|
||
@app.get("/plan")
|
||
@app.get("/trades")
|
||
@app.get("/stats")
|
||
@app.get("/settings")
|
||
@app.get("/login")
|
||
async def spa_pages(full_path: str = ""):
|
||
index = _DIST / "index.html"
|
||
if not index.exists():
|
||
raise HTTPException(status_code=404, detail="frontend not built")
|
||
return FileResponse(index)
|