Implement P1 local matcher/ledger and P2 strategy engine.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,3 +33,7 @@ INITIAL_EQUITY=100000
|
|||||||
MAX_ROUNDS=3
|
MAX_ROUNDS=3
|
||||||
OPEN_HHMM=16:00
|
OPEN_HHMM=16:00
|
||||||
STOP_OPEN_HHMM=08:00
|
STOP_OPEN_HHMM=08:00
|
||||||
|
EXIT_MOVE_POINTS=30
|
||||||
|
REST_SECONDS=300
|
||||||
|
PERP_QTY_ETH=1
|
||||||
|
OPTION_QTY_ETH=2
|
||||||
|
|||||||
@@ -2,7 +2,17 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from .auth_routes import router as auth_router
|
from .auth_routes import router as auth_router
|
||||||
from .market import router as market_router
|
from .market import router as market_router
|
||||||
|
from .plan import router as plan_router
|
||||||
|
from .settings import router as settings_router
|
||||||
|
from .sim import router as sim_router
|
||||||
|
from .stats import router as stats_router
|
||||||
|
from .trades import router as trades_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(auth_router)
|
router.include_router(auth_router)
|
||||||
router.include_router(market_router)
|
router.include_router(market_router)
|
||||||
|
router.include_router(sim_router)
|
||||||
|
router.include_router(plan_router)
|
||||||
|
router.include_router(trades_router)
|
||||||
|
router.include_router(stats_router)
|
||||||
|
router.include_router(settings_router)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..strategy import get_engine
|
||||||
|
from .auth import require_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/plan", tags=["plan"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/state")
|
||||||
|
async def plan_state(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return get_engine().state()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def plan_start(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return await get_engine().start()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pause")
|
||||||
|
async def plan_pause(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return await get_engine().pause()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emergency-close")
|
||||||
|
async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return await get_engine().emergency_close()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..models.db import get_db
|
||||||
|
from ..sim.ledger import Ledger
|
||||||
|
from .auth import require_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
KEYS = ("fee_rate", "exit_move_points", "rest_seconds", "max_rounds", "initial_equity")
|
||||||
|
|
||||||
|
|
||||||
|
class StrategySettingsBody(BaseModel):
|
||||||
|
fee_rate: float | None = Field(default=None, ge=0, le=0.05)
|
||||||
|
exit_move_points: float | None = Field(default=None, ge=1, le=500)
|
||||||
|
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||||||
|
max_rounds: int | None = Field(default=None, ge=1, le=20)
|
||||||
|
initial_equity: float | None = Field(default=None, ge=1000)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/strategy")
|
||||||
|
async def get_strategy_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
db = get_db()
|
||||||
|
s = get_settings()
|
||||||
|
out = {
|
||||||
|
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
|
||||||
|
"exit_move_points": float(
|
||||||
|
db.get_setting("exit_move_points", str(s.exit_move_points)) or s.exit_move_points
|
||||||
|
),
|
||||||
|
"rest_seconds": int(
|
||||||
|
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||||||
|
),
|
||||||
|
"max_rounds": int(
|
||||||
|
float(db.get_setting("max_rounds", str(s.max_rounds)) or s.max_rounds)
|
||||||
|
),
|
||||||
|
"initial_equity": float(
|
||||||
|
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||||||
|
),
|
||||||
|
"ledger": Ledger(db).snapshot(),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/strategy")
|
||||||
|
async def put_strategy_settings(
|
||||||
|
body: StrategySettingsBody,
|
||||||
|
_user: Annotated[str, Depends(require_user)],
|
||||||
|
) -> dict:
|
||||||
|
db = get_db()
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
for k, v in data.items():
|
||||||
|
if k in KEYS:
|
||||||
|
db.set_setting(k, str(v))
|
||||||
|
s = get_settings()
|
||||||
|
return {
|
||||||
|
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
|
||||||
|
"exit_move_points": float(
|
||||||
|
db.get_setting("exit_move_points", str(s.exit_move_points)) or s.exit_move_points
|
||||||
|
),
|
||||||
|
"rest_seconds": int(
|
||||||
|
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||||||
|
),
|
||||||
|
"max_rounds": int(
|
||||||
|
float(db.get_setting("max_rounds", str(s.max_rounds)) or s.max_rounds)
|
||||||
|
),
|
||||||
|
"initial_equity": float(
|
||||||
|
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||||||
|
),
|
||||||
|
"ledger": Ledger(db).snapshot(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..market import get_gateway
|
||||||
|
from ..models.db import get_db
|
||||||
|
from ..sim.matcher import Matcher
|
||||||
|
from ..strategy.clock import window_key
|
||||||
|
from ..strategy.group import next_group_id
|
||||||
|
from ..strategy.signal import decide
|
||||||
|
from .auth import require_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/sim", tags=["sim"])
|
||||||
|
|
||||||
|
|
||||||
|
class ManualOpenBody(BaseModel):
|
||||||
|
"""可选强制方向;默认按卖一比价自动选。"""
|
||||||
|
force_option_side: str | None = Field(default=None, description="call|put")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger")
|
||||||
|
async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
return Ledger().snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/position")
|
||||||
|
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
m = Matcher()
|
||||||
|
return {"position": m.current_position(), "unrealized": m.unrealized()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/open-group")
|
||||||
|
async def sim_open_group(
|
||||||
|
_user: Annotated[str, Depends(require_user)],
|
||||||
|
body: ManualOpenBody | None = None,
|
||||||
|
) -> dict:
|
||||||
|
gw = get_gateway()
|
||||||
|
snap = gw.snapshot()
|
||||||
|
if not snap.pair or not snap.call or not snap.put:
|
||||||
|
raise HTTPException(status_code=503, detail="行情未就绪")
|
||||||
|
force = (body.force_option_side if body else None) or None
|
||||||
|
if force in ("call", "put"):
|
||||||
|
option_side = force
|
||||||
|
perp_side = "short" if force == "call" else "long"
|
||||||
|
bias = "manual_" + force
|
||||||
|
else:
|
||||||
|
sig = decide(snap.call.ask, snap.put.ask)
|
||||||
|
if sig is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Call/Put 卖一相等,跳过")
|
||||||
|
option_side = sig.option_side
|
||||||
|
perp_side = sig.perp_side
|
||||||
|
bias = sig.bias
|
||||||
|
|
||||||
|
option_inst = (
|
||||||
|
snap.pair.call_inst_id if option_side == "call" else snap.pair.put_inst_id
|
||||||
|
)
|
||||||
|
entry_idx = snap.index_px or (snap.perp.mark_px if snap.perp else None)
|
||||||
|
if entry_idx is None:
|
||||||
|
raise HTTPException(status_code=503, detail="无指数/标记价")
|
||||||
|
|
||||||
|
wkey = window_key()
|
||||||
|
db = get_db()
|
||||||
|
count = len(db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",)))
|
||||||
|
gid = next_group_id(count)
|
||||||
|
r = Matcher().open_group(
|
||||||
|
group_id=gid,
|
||||||
|
bias=bias,
|
||||||
|
option_side=option_side,
|
||||||
|
perp_side=perp_side,
|
||||||
|
option_inst_id=option_inst,
|
||||||
|
entry_index_px=float(entry_idx),
|
||||||
|
strike=snap.pair.strike,
|
||||||
|
expiry_ymd=snap.pair.expiry_ymd,
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
raise HTTPException(status_code=400, detail=r.detail)
|
||||||
|
return {"ok": True, **(r.data or {}), "detail": r.detail}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close-group")
|
||||||
|
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
r = Matcher().close_group(reason="manual")
|
||||||
|
if not r.ok and not r.liquidity_wait:
|
||||||
|
raise HTTPException(status_code=400, detail=r.detail)
|
||||||
|
return {
|
||||||
|
"ok": r.ok,
|
||||||
|
"liquidity_wait": r.liquidity_wait,
|
||||||
|
"detail": r.detail,
|
||||||
|
"data": r.data,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..models.db import get_db
|
||||||
|
from .auth import require_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def stats_summary(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
db = get_db()
|
||||||
|
rows = db.fetchall("SELECT * FROM groups WHERE status='closed'")
|
||||||
|
n = len(rows)
|
||||||
|
wins = sum(1 for r in rows if float(r["realized_pnl"] or 0) > 0)
|
||||||
|
total_pnl = sum(float(r["realized_pnl"] or 0) for r in rows)
|
||||||
|
total_fees = sum(float(r["fees"] or 0) for r in rows)
|
||||||
|
total_slip = sum(float(r["slip_cost"] or 0) for r in rows)
|
||||||
|
reasons: dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
k = str(r["close_reason"] or "unknown")
|
||||||
|
reasons[k] = reasons.get(k, 0) + 1
|
||||||
|
curve = [
|
||||||
|
{
|
||||||
|
"group_id": r["group_id"],
|
||||||
|
"realized_pnl": float(r["realized_pnl"] or 0),
|
||||||
|
"close_at_ms": r["close_at_ms"],
|
||||||
|
}
|
||||||
|
for r in sorted(rows, key=lambda x: int(x["close_at_ms"] or 0))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"groups": n,
|
||||||
|
"wins": wins,
|
||||||
|
"win_rate": (wins / n) if n else 0.0,
|
||||||
|
"total_pnl": total_pnl,
|
||||||
|
"total_fees": total_fees,
|
||||||
|
"total_slip": total_slip,
|
||||||
|
"close_reasons": reasons,
|
||||||
|
"equity_curve": curve,
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ..models.db import get_db
|
||||||
|
from .auth import require_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/trades", tags=["trades"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r: Any) -> dict:
|
||||||
|
return dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groups")
|
||||||
|
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
rows = get_db().fetchall(
|
||||||
|
"SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200"
|
||||||
|
)
|
||||||
|
return {"groups": [_row(x) for x in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groups/{group_id}")
|
||||||
|
async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user)]) -> dict:
|
||||||
|
db = get_db()
|
||||||
|
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
|
||||||
|
if g is None:
|
||||||
|
raise HTTPException(status_code=404, detail="group not found")
|
||||||
|
fills = db.fetchall(
|
||||||
|
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||||
|
)
|
||||||
|
return {"group": _row(g), "fills": [_row(x) for x in fills]}
|
||||||
@@ -41,6 +41,12 @@ class Settings(BaseSettings):
|
|||||||
max_rounds: int = 3
|
max_rounds: int = 3
|
||||||
open_hhmm: str = "16:00"
|
open_hhmm: str = "16:00"
|
||||||
stop_open_hhmm: str = "08:00"
|
stop_open_hhmm: str = "08:00"
|
||||||
|
exit_move_points: float = 30.0
|
||||||
|
rest_seconds: int = 300
|
||||||
|
perp_qty_eth: float = 1.0
|
||||||
|
option_qty_eth: float = 2.0
|
||||||
|
option_ct_mult_default: float = 0.01
|
||||||
|
db_path: str = "" # empty -> backend/data/hedge.db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_sim(self) -> bool:
|
def is_sim(self) -> bool:
|
||||||
|
|||||||
+31
-6
@@ -12,6 +12,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from .api import router as api_router
|
from .api import router as api_router
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .market import MarketGateway, set_gateway
|
from .market import MarketGateway, set_gateway
|
||||||
|
from .models.db import Database, set_db
|
||||||
|
from .strategy import StrategyEngine, set_engine
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -22,7 +24,6 @@ logger = logging.getLogger("eth_hedge_sim")
|
|||||||
|
|
||||||
def resolve_frontend_dist() -> Path:
|
def resolve_frontend_dist() -> Path:
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
# backend/app/main.py -> repo root is parents[2]
|
|
||||||
repo_root = here.parents[2]
|
repo_root = here.parents[2]
|
||||||
return repo_root / "frontend" / "dist"
|
return repo_root / "frontend" / "dist"
|
||||||
|
|
||||||
@@ -30,25 +31,39 @@ def resolve_frontend_dist() -> Path:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.is_sim:
|
db = Database()
|
||||||
logger.warning("MODE=%s — still read-only market in current phase", settings.mode)
|
set_db(db)
|
||||||
|
engine = StrategyEngine()
|
||||||
|
set_engine(engine)
|
||||||
|
|
||||||
gw = MarketGateway(settings)
|
gw = MarketGateway(settings)
|
||||||
set_gateway(gw)
|
set_gateway(gw)
|
||||||
try:
|
try:
|
||||||
await gw.start()
|
await gw.start()
|
||||||
logger.info("market gateway started (SIM read-only)")
|
logger.info("market gateway started (SIM)")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("market gateway failed to start")
|
logger.exception("market gateway failed to start")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
await engine.pause()
|
||||||
|
if engine._task and not engine._task.done():
|
||||||
|
engine._task.cancel()
|
||||||
|
try:
|
||||||
|
await engine._task
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await gw.stop()
|
await gw.stop()
|
||||||
set_gateway(None)
|
set_gateway(None)
|
||||||
|
set_engine(None)
|
||||||
|
db.close()
|
||||||
|
set_db(None)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="eth_hedge_sim",
|
title="eth_hedge_sim",
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
description="ETH 自动对冲模拟盘",
|
description="ETH 自动对冲模拟盘 P1/P2",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -64,10 +79,15 @@ app.include_router(api_router)
|
|||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
from .market import get_gateway
|
from .market import get_gateway
|
||||||
|
from .strategy import get_engine
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
gw = get_gateway()
|
gw = get_gateway()
|
||||||
snap = gw.snapshot()
|
snap = gw.snapshot()
|
||||||
|
try:
|
||||||
|
st = get_engine().state()
|
||||||
|
except Exception:
|
||||||
|
st = None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"mode": settings.mode,
|
"mode": settings.mode,
|
||||||
@@ -76,6 +96,11 @@ async def health() -> dict:
|
|||||||
"market_connected": snap.connected,
|
"market_connected": snap.connected,
|
||||||
"pair": snap.pair.to_dict() if snap.pair else None,
|
"pair": snap.pair.to_dict() if snap.pair else None,
|
||||||
"updated_at_ms": snap.updated_at_ms,
|
"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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# Placeholder: DB models (P1).
|
from .db import Database, get_db, set_db
|
||||||
|
|
||||||
|
__all__ = ["Database", "get_db", "set_db"]
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..config import Settings, get_settings
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
equity REAL NOT NULL,
|
||||||
|
available REAL NOT NULL,
|
||||||
|
reserved REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
group_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bias TEXT,
|
||||||
|
option_side TEXT,
|
||||||
|
perp_side TEXT,
|
||||||
|
option_inst_id TEXT,
|
||||||
|
perp_inst_id TEXT,
|
||||||
|
strike REAL,
|
||||||
|
expiry_ymd TEXT,
|
||||||
|
entry_index_px REAL,
|
||||||
|
initial_premium REAL DEFAULT 0,
|
||||||
|
open_at_ms INTEGER,
|
||||||
|
close_at_ms INTEGER,
|
||||||
|
close_reason TEXT,
|
||||||
|
realized_pnl REAL DEFAULT 0,
|
||||||
|
fees REAL DEFAULT 0,
|
||||||
|
slip_cost REAL DEFAULT 0,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id TEXT NOT NULL,
|
||||||
|
leg TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
inst_id TEXT NOT NULL,
|
||||||
|
qty_eth REAL NOT NULL,
|
||||||
|
qty_contracts REAL,
|
||||||
|
base_px REAL,
|
||||||
|
fill_px REAL NOT NULL,
|
||||||
|
fee REAL NOT NULL,
|
||||||
|
slip REAL NOT NULL,
|
||||||
|
notional REAL NOT NULL,
|
||||||
|
ts_ms INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(group_id) REFERENCES groups(group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS positions (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
group_id TEXT,
|
||||||
|
perp_side TEXT,
|
||||||
|
perp_qty_eth REAL DEFAULT 0,
|
||||||
|
perp_entry_px REAL,
|
||||||
|
option_inst_id TEXT,
|
||||||
|
option_side TEXT,
|
||||||
|
option_qty_eth REAL DEFAULT 0,
|
||||||
|
option_qty_contracts REAL DEFAULT 0,
|
||||||
|
option_entry_px REAL,
|
||||||
|
entry_index_px REAL,
|
||||||
|
initial_premium REAL DEFAULT 0,
|
||||||
|
status TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ledger_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
balance_after REAL NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
ts_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS strategy_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
running INTEGER NOT NULL DEFAULT 0,
|
||||||
|
phase TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
rounds_done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
window_key TEXT,
|
||||||
|
rest_until_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path(settings: Settings | None = None) -> Path:
|
||||||
|
s = settings or get_settings()
|
||||||
|
if s.db_path:
|
||||||
|
return Path(s.db_path)
|
||||||
|
root = Path(__file__).resolve().parents[2] # backend/
|
||||||
|
return root / "data" / "hedge.db"
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
self.path = path or default_db_path()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
self._conn.executescript(_SCHEMA)
|
||||||
|
self._conn.commit()
|
||||||
|
self._ensure_seed()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def _ensure_seed(self) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
with self._lock:
|
||||||
|
row = self._conn.execute("SELECT id FROM ledger_meta WHERE id=1").fetchone()
|
||||||
|
if row is None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO ledger_meta(id, equity, available, reserved, updated_at_ms) VALUES (1,?,?,0,?)",
|
||||||
|
(s.initial_equity, s.initial_equity, now),
|
||||||
|
)
|
||||||
|
pos = self._conn.execute("SELECT id FROM positions WHERE id=1").fetchone()
|
||||||
|
if pos is None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO positions(id, status) VALUES (1, 'flat')"
|
||||||
|
)
|
||||||
|
st = self._conn.execute("SELECT id FROM strategy_state WHERE id=1").fetchone()
|
||||||
|
if st is None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
defaults = {
|
||||||
|
"fee_rate": str(s.fee_rate),
|
||||||
|
"initial_equity": str(s.initial_equity),
|
||||||
|
"exit_move_points": str(s.exit_move_points),
|
||||||
|
"rest_seconds": str(s.rest_seconds),
|
||||||
|
"max_rounds": str(s.max_rounds),
|
||||||
|
}
|
||||||
|
for k, v in defaults.items():
|
||||||
|
exists = self._conn.execute(
|
||||||
|
"SELECT key FROM settings WHERE key=?", (k,)
|
||||||
|
).fetchone()
|
||||||
|
if exists is None:
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO settings(key, value) VALUES (?,?)", (k, v)
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> sqlite3.Cursor:
|
||||||
|
with self._lock:
|
||||||
|
cur = self._conn.execute(sql, params)
|
||||||
|
self._conn.commit()
|
||||||
|
return cur
|
||||||
|
|
||||||
|
def executemany(self, sql: str, seq: list[tuple[Any, ...]]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._conn.executemany(sql, seq)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def fetchone(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> sqlite3.Row | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._conn.execute(sql, params).fetchone()
|
||||||
|
|
||||||
|
def fetchall(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> list[sqlite3.Row]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._conn.execute(sql, params).fetchall())
|
||||||
|
|
||||||
|
def get_setting(self, key: str, default: str | None = None) -> str | None:
|
||||||
|
row = self.fetchone("SELECT value FROM settings WHERE key=?", (key,))
|
||||||
|
if row is None:
|
||||||
|
return default
|
||||||
|
return str(row["value"])
|
||||||
|
|
||||||
|
def set_setting(self, key: str, value: str) -> None:
|
||||||
|
self.execute(
|
||||||
|
"INSERT INTO settings(key, value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_db: Database | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Database:
|
||||||
|
global _db
|
||||||
|
if _db is None:
|
||||||
|
_db = Database()
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
|
def set_db(db: Database | None) -> None:
|
||||||
|
global _db
|
||||||
|
_db = db
|
||||||
@@ -1 +1,15 @@
|
|||||||
# Placeholder packages for later phases (P1–P5).
|
from .ledger import Ledger
|
||||||
|
from .liquidity import bid_covers_eth, contracts_for_eth
|
||||||
|
from .matcher import CloseResult, Matcher, OpenResult
|
||||||
|
from .pricing import option_fill, perp_fill
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CloseResult",
|
||||||
|
"Ledger",
|
||||||
|
"Matcher",
|
||||||
|
"OpenResult",
|
||||||
|
"bid_covers_eth",
|
||||||
|
"contracts_for_eth",
|
||||||
|
"option_fill",
|
||||||
|
"perp_fill",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..models.db import Database, get_db
|
||||||
|
|
||||||
|
|
||||||
|
class Ledger:
|
||||||
|
def __init__(self, db: Database | None = None) -> None:
|
||||||
|
self.db = db or get_db()
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
row = self.db.fetchone("SELECT * FROM ledger_meta WHERE id=1")
|
||||||
|
assert row is not None
|
||||||
|
return {
|
||||||
|
"equity": float(row["equity"]),
|
||||||
|
"available": float(row["available"]),
|
||||||
|
"reserved": float(row["reserved"]),
|
||||||
|
"updated_at_ms": int(row["updated_at_ms"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_cash(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
group_id: str | None = None,
|
||||||
|
note: str = "",
|
||||||
|
) -> float:
|
||||||
|
"""amount>0 入账;amount<0 出账。返回余额。"""
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
with self.db._lock:
|
||||||
|
row = self.db._conn.execute("SELECT * FROM ledger_meta WHERE id=1").fetchone()
|
||||||
|
assert row is not None
|
||||||
|
equity = float(row["equity"]) + float(amount)
|
||||||
|
available = float(row["available"]) + float(amount)
|
||||||
|
if available < -1e-9:
|
||||||
|
raise RuntimeError("可用资金不足")
|
||||||
|
self.db._conn.execute(
|
||||||
|
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
|
||||||
|
(equity, available, now),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
|
||||||
|
(group_id, kind, float(amount), equity, note, now),
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
return equity
|
||||||
|
|
||||||
|
def get_setting_float(self, key: str, default: float) -> float:
|
||||||
|
v = self.db.get_setting(key)
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_setting_int(self, key: str, default: int) -> int:
|
||||||
|
return int(self.get_setting_float(key, float(default)))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""期权买一流动性:张数 × ctMult 是否覆盖名义 ETH。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def contracts_for_eth(qty_eth: float, ct_mult: float) -> float:
|
||||||
|
m = float(ct_mult) if ct_mult and ct_mult > 0 else 0.01
|
||||||
|
return float(qty_eth) / m
|
||||||
|
|
||||||
|
|
||||||
|
def eth_from_contracts(contracts: float, ct_mult: float) -> float:
|
||||||
|
m = float(ct_mult) if ct_mult and ct_mult > 0 else 0.01
|
||||||
|
return float(contracts) * m
|
||||||
|
|
||||||
|
|
||||||
|
def bid_covers_eth(*, bid_sz_contracts: float | None, ct_mult: float, need_eth: float) -> bool:
|
||||||
|
if bid_sz_contracts is None or bid_sz_contracts <= 0:
|
||||||
|
return False
|
||||||
|
return eth_from_contracts(bid_sz_contracts, ct_mult) + 1e-12 >= float(need_eth)
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""本地模拟撮合:永续市价 + 期权只买开/卖平。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..market import get_gateway
|
||||||
|
from ..models.db import Database, get_db
|
||||||
|
from .ledger import Ledger
|
||||||
|
from .liquidity import bid_covers_eth, contracts_for_eth
|
||||||
|
from .pricing import option_fill, perp_fill
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class OpenResult:
|
||||||
|
ok: bool
|
||||||
|
group_id: str | None = None
|
||||||
|
detail: str = ""
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CloseResult:
|
||||||
|
ok: bool
|
||||||
|
detail: str = ""
|
||||||
|
liquidity_wait: bool = False
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Matcher:
|
||||||
|
def __init__(self, db: Database | None = None) -> None:
|
||||||
|
self.db = db or get_db()
|
||||||
|
self.ledger = Ledger(self.db)
|
||||||
|
|
||||||
|
def _fee_rate(self) -> float:
|
||||||
|
return self.ledger.get_setting_float("fee_rate", get_settings().fee_rate)
|
||||||
|
|
||||||
|
def _ct_mult(self, option_inst_id: str) -> float:
|
||||||
|
# 尝试 REST meta;失败用默认
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
gw = get_gateway()
|
||||||
|
rows = gw.rest.fetch_instruments(inst_type="OPTION", inst_family=s.option_inst_family)
|
||||||
|
for r in rows:
|
||||||
|
if str(r.get("instId")) == option_inst_id:
|
||||||
|
from ..market.instruments import safe_float
|
||||||
|
|
||||||
|
m = safe_float(r.get("ctMult"))
|
||||||
|
if m and m > 0:
|
||||||
|
return float(m)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return float(s.option_ct_mult_default)
|
||||||
|
|
||||||
|
def current_position(self) -> dict[str, Any]:
|
||||||
|
row = self.db.fetchone("SELECT * FROM positions WHERE id=1")
|
||||||
|
assert row is not None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def open_group(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
group_id: str,
|
||||||
|
bias: str,
|
||||||
|
option_side: str, # call|put
|
||||||
|
perp_side: str, # long|short
|
||||||
|
option_inst_id: str,
|
||||||
|
entry_index_px: float,
|
||||||
|
strike: float | None = None,
|
||||||
|
expiry_ymd: str | None = None,
|
||||||
|
) -> OpenResult:
|
||||||
|
s = get_settings()
|
||||||
|
pos = self.current_position()
|
||||||
|
if pos.get("status") == "open" and pos.get("group_id"):
|
||||||
|
return OpenResult(ok=False, detail="已有持仓组,请先平仓")
|
||||||
|
|
||||||
|
gw = get_gateway()
|
||||||
|
snap = gw.snapshot()
|
||||||
|
if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
|
||||||
|
return OpenResult(ok=False, detail="永续盘口不可用")
|
||||||
|
oq = snap.call if option_side == "call" else snap.put
|
||||||
|
if not oq or oq.ask is None:
|
||||||
|
return OpenResult(ok=False, detail="期权卖一不可用")
|
||||||
|
|
||||||
|
fee_rate = self._fee_rate()
|
||||||
|
perp_qty = float(s.perp_qty_eth)
|
||||||
|
opt_qty = float(s.option_qty_eth)
|
||||||
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
|
opt_contracts = contracts_for_eth(opt_qty, ct_mult)
|
||||||
|
|
||||||
|
pf = perp_fill(
|
||||||
|
side=perp_side,
|
||||||
|
action="open",
|
||||||
|
bid=float(snap.perp.bid),
|
||||||
|
ask=float(snap.perp.ask),
|
||||||
|
qty_eth=perp_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
)
|
||||||
|
of = option_fill(
|
||||||
|
action="open",
|
||||||
|
bid=float(oq.bid or 0),
|
||||||
|
ask=float(oq.ask),
|
||||||
|
qty_eth=opt_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
)
|
||||||
|
initial_premium = of.fill_px * opt_qty # 锁定口径:成交价×名义,不含费
|
||||||
|
premium_cost = of.notional + of.fee
|
||||||
|
total_debit = premium_cost + pf.fee # 永续开仓只扣费;期权支付权利金+费
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.ledger.apply_cash(
|
||||||
|
-total_debit,
|
||||||
|
kind="open_debit",
|
||||||
|
group_id=group_id,
|
||||||
|
note=f"open {group_id}",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return OpenResult(ok=False, detail=str(e))
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
with self.db._lock:
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""INSERT INTO groups(
|
||||||
|
group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
|
||||||
|
strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"open",
|
||||||
|
bias,
|
||||||
|
option_side,
|
||||||
|
perp_side,
|
||||||
|
option_inst_id,
|
||||||
|
s.perp_inst_id,
|
||||||
|
strike,
|
||||||
|
expiry_ymd,
|
||||||
|
entry_index_px,
|
||||||
|
initial_premium,
|
||||||
|
now,
|
||||||
|
pf.fee + of.fee,
|
||||||
|
pf.slip + of.slip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||||
|
base_px, fill_px, fee, slip, notional, ts_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"perp",
|
||||||
|
"open",
|
||||||
|
perp_side,
|
||||||
|
s.perp_inst_id,
|
||||||
|
perp_qty,
|
||||||
|
None,
|
||||||
|
pf.base_px,
|
||||||
|
pf.fill_px,
|
||||||
|
pf.fee,
|
||||||
|
pf.slip,
|
||||||
|
pf.notional,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||||
|
base_px, fill_px, fee, slip, notional, ts_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"option",
|
||||||
|
"open",
|
||||||
|
"long",
|
||||||
|
option_inst_id,
|
||||||
|
opt_qty,
|
||||||
|
opt_contracts,
|
||||||
|
of.base_px,
|
||||||
|
of.fill_px,
|
||||||
|
of.fee,
|
||||||
|
of.slip,
|
||||||
|
of.notional,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE positions SET
|
||||||
|
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
|
||||||
|
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
|
||||||
|
option_entry_px=?, entry_index_px=?, initial_premium=?, status=?
|
||||||
|
WHERE id=1""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
perp_side,
|
||||||
|
perp_qty,
|
||||||
|
pf.fill_px,
|
||||||
|
option_inst_id,
|
||||||
|
option_side,
|
||||||
|
opt_qty,
|
||||||
|
opt_contracts,
|
||||||
|
of.fill_px,
|
||||||
|
entry_index_px,
|
||||||
|
initial_premium,
|
||||||
|
"open",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
|
||||||
|
return OpenResult(
|
||||||
|
ok=True,
|
||||||
|
group_id=group_id,
|
||||||
|
detail="opened",
|
||||||
|
data={
|
||||||
|
"group_id": group_id,
|
||||||
|
"perp": pf.__dict__,
|
||||||
|
"option": of.__dict__,
|
||||||
|
"initial_premium": initial_premium,
|
||||||
|
"fees": pf.fee + of.fee,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def close_group(self, *, reason: str) -> CloseResult:
|
||||||
|
s = get_settings()
|
||||||
|
pos = self.current_position()
|
||||||
|
if pos.get("status") != "open" or not pos.get("group_id"):
|
||||||
|
return CloseResult(ok=False, detail="无持仓可平")
|
||||||
|
|
||||||
|
group_id = str(pos["group_id"])
|
||||||
|
gw = get_gateway()
|
||||||
|
snap = gw.snapshot()
|
||||||
|
if not snap.perp or snap.perp.bid is None or snap.perp.ask is None:
|
||||||
|
return CloseResult(ok=False, detail="永续盘口不可用")
|
||||||
|
|
||||||
|
option_inst_id = str(pos["option_inst_id"])
|
||||||
|
option_side = str(pos["option_side"])
|
||||||
|
oq = snap.call if option_side == "call" else snap.put
|
||||||
|
if not oq or oq.bid is None:
|
||||||
|
return CloseResult(ok=False, detail="期权买一不可用", liquidity_wait=True)
|
||||||
|
|
||||||
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
|
need_eth = float(pos["option_qty_eth"] or s.option_qty_eth)
|
||||||
|
if not bid_covers_eth(
|
||||||
|
bid_sz_contracts=oq.bid_sz,
|
||||||
|
ct_mult=ct_mult,
|
||||||
|
need_eth=need_eth,
|
||||||
|
):
|
||||||
|
# 记流动性不足到组 note,不改变仓位
|
||||||
|
note = f"liquidity_wait:{int(time.time())}"
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE groups SET note=? WHERE group_id=? AND status='open'",
|
||||||
|
(note, group_id),
|
||||||
|
)
|
||||||
|
return CloseResult(
|
||||||
|
ok=False,
|
||||||
|
detail="期权买一流动性不足",
|
||||||
|
liquidity_wait=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_rate = self._fee_rate()
|
||||||
|
perp_side = str(pos["perp_side"])
|
||||||
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
|
opt_qty = float(pos["option_qty_eth"])
|
||||||
|
perp_entry = float(pos["perp_entry_px"])
|
||||||
|
opt_entry = float(pos["option_entry_px"])
|
||||||
|
|
||||||
|
pf = perp_fill(
|
||||||
|
side=perp_side,
|
||||||
|
action="close",
|
||||||
|
bid=float(snap.perp.bid),
|
||||||
|
ask=float(snap.perp.ask),
|
||||||
|
qty_eth=perp_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
)
|
||||||
|
of = option_fill(
|
||||||
|
action="close",
|
||||||
|
bid=float(oq.bid),
|
||||||
|
ask=float(oq.ask or oq.bid),
|
||||||
|
qty_eth=opt_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 永续盈亏
|
||||||
|
if perp_side == "long":
|
||||||
|
perp_pnl = (pf.fill_px - perp_entry) * perp_qty
|
||||||
|
else:
|
||||||
|
perp_pnl = (perp_entry - pf.fill_px) * perp_qty
|
||||||
|
# 期权多头盈亏
|
||||||
|
opt_pnl = (of.fill_px - opt_entry) * opt_qty
|
||||||
|
cash_in = of.notional - of.fee + pf.fee * 0 # 收回权利金(扣卖出费);永续平仓费另扣
|
||||||
|
# 永续平仓:实现盈亏入账并扣平仓手续费
|
||||||
|
net = perp_pnl + opt_pnl - pf.fee - of.fee
|
||||||
|
# 更清晰:现金变动 = 期权卖出净额 + 永续盈亏 - 永续平仓费
|
||||||
|
# 开仓已付期权权利金+开仓费;平仓收回 of.notional 并付 of.fee;永续只记 pnl 与 fee
|
||||||
|
cash_delta = (of.notional - of.fee) + perp_pnl - pf.fee
|
||||||
|
|
||||||
|
self.ledger.apply_cash(
|
||||||
|
cash_delta,
|
||||||
|
kind="close_settle",
|
||||||
|
group_id=group_id,
|
||||||
|
note=f"close {reason}",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
with self.db._lock:
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||||
|
base_px, fill_px, fee, slip, notional, ts_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"perp",
|
||||||
|
"close",
|
||||||
|
"flat",
|
||||||
|
s.perp_inst_id,
|
||||||
|
perp_qty,
|
||||||
|
None,
|
||||||
|
pf.base_px,
|
||||||
|
pf.fill_px,
|
||||||
|
pf.fee,
|
||||||
|
pf.slip,
|
||||||
|
pf.notional,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
|
||||||
|
base_px, fill_px, fee, slip, notional, ts_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"option",
|
||||||
|
"close",
|
||||||
|
"flat",
|
||||||
|
option_inst_id,
|
||||||
|
opt_qty,
|
||||||
|
float(pos["option_qty_contracts"] or 0),
|
||||||
|
of.base_px,
|
||||||
|
of.fill_px,
|
||||||
|
of.fee,
|
||||||
|
of.slip,
|
||||||
|
of.notional,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
g = self.db._conn.execute(
|
||||||
|
"SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
|
||||||
|
).fetchone()
|
||||||
|
fees = float(g["fees"] or 0) + pf.fee + of.fee
|
||||||
|
slip = float(g["slip_cost"] or 0) + pf.slip + of.slip
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
|
||||||
|
fees=?, slip_cost=?, note=NULL WHERE group_id=?""",
|
||||||
|
("closed", now, reason, net, fees, slip, group_id),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE positions SET
|
||||||
|
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||||
|
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||||
|
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||||
|
WHERE id=1"""
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
|
||||||
|
return CloseResult(
|
||||||
|
ok=True,
|
||||||
|
detail="closed",
|
||||||
|
data={
|
||||||
|
"group_id": group_id,
|
||||||
|
"reason": reason,
|
||||||
|
"perp_pnl": perp_pnl,
|
||||||
|
"option_pnl": opt_pnl,
|
||||||
|
"net": net,
|
||||||
|
"cash_delta": cash_delta,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def unrealized(self) -> dict[str, Any]:
|
||||||
|
pos = self.current_position()
|
||||||
|
if pos.get("status") != "open":
|
||||||
|
return {
|
||||||
|
"has_position": False,
|
||||||
|
"perp_upl": 0.0,
|
||||||
|
"option_upl": 0.0,
|
||||||
|
"index_px": None,
|
||||||
|
"move_points": 0.0,
|
||||||
|
"premium_gap": None,
|
||||||
|
}
|
||||||
|
gw = get_gateway()
|
||||||
|
snap = gw.snapshot()
|
||||||
|
index_px = snap.index_px
|
||||||
|
if index_px is None and snap.perp:
|
||||||
|
index_px = snap.perp.mark_px
|
||||||
|
perp_side = str(pos["perp_side"])
|
||||||
|
perp_entry = float(pos["perp_entry_px"])
|
||||||
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
|
mark = None
|
||||||
|
if snap.perp:
|
||||||
|
# 浮盈用对手方可平价粗估
|
||||||
|
if perp_side == "long":
|
||||||
|
mark = snap.perp.bid
|
||||||
|
else:
|
||||||
|
mark = snap.perp.ask
|
||||||
|
mark = mark or snap.perp.mark_px
|
||||||
|
perp_upl = 0.0
|
||||||
|
if mark is not None:
|
||||||
|
if perp_side == "long":
|
||||||
|
perp_upl = (float(mark) - perp_entry) * perp_qty
|
||||||
|
else:
|
||||||
|
perp_upl = (perp_entry - float(mark)) * perp_qty
|
||||||
|
|
||||||
|
option_side = str(pos["option_side"])
|
||||||
|
oq = snap.call if option_side == "call" else snap.put
|
||||||
|
opt_mark = None
|
||||||
|
if oq:
|
||||||
|
opt_mark = oq.bid or oq.mark_px
|
||||||
|
option_upl = 0.0
|
||||||
|
if opt_mark is not None:
|
||||||
|
option_upl = (float(opt_mark) - float(pos["option_entry_px"])) * float(
|
||||||
|
pos["option_qty_eth"]
|
||||||
|
)
|
||||||
|
|
||||||
|
entry_idx = float(pos["entry_index_px"] or 0)
|
||||||
|
move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
|
||||||
|
initial_premium = float(pos["initial_premium"] or 0)
|
||||||
|
premium_gap = initial_premium - perp_upl
|
||||||
|
return {
|
||||||
|
"has_position": True,
|
||||||
|
"group_id": pos["group_id"],
|
||||||
|
"perp_side": perp_side,
|
||||||
|
"option_side": option_side,
|
||||||
|
"perp_upl": perp_upl,
|
||||||
|
"option_upl": option_upl,
|
||||||
|
"index_px": index_px,
|
||||||
|
"entry_index_px": entry_idx,
|
||||||
|
"move_points": move,
|
||||||
|
"initial_premium": initial_premium,
|
||||||
|
"premium_gap": premium_gap,
|
||||||
|
"status": pos.get("status"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""成交价与手续费:滑点 = 1×f。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PriceResult:
|
||||||
|
base_px: float
|
||||||
|
fill_px: float
|
||||||
|
fee: float
|
||||||
|
slip: float
|
||||||
|
notional: float
|
||||||
|
|
||||||
|
|
||||||
|
def perp_fill(
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
action: str,
|
||||||
|
bid: float,
|
||||||
|
ask: float,
|
||||||
|
qty_eth: float,
|
||||||
|
fee_rate: float,
|
||||||
|
) -> PriceResult:
|
||||||
|
"""
|
||||||
|
side: long|short(持仓方向意图:开仓要建立的方向 / 平仓时原持仓方向)
|
||||||
|
action: open|close
|
||||||
|
开多/平空: 吃卖一 ×(1+f)
|
||||||
|
开空/平多: 吃买一 ×(1-f)
|
||||||
|
"""
|
||||||
|
f = float(fee_rate)
|
||||||
|
buying = (action == "open" and side == "long") or (action == "close" and side == "short")
|
||||||
|
if buying:
|
||||||
|
base = float(ask)
|
||||||
|
fill = base * (1.0 + f)
|
||||||
|
else:
|
||||||
|
base = float(bid)
|
||||||
|
fill = base * (1.0 - f)
|
||||||
|
notional = abs(fill * qty_eth)
|
||||||
|
fee = notional * f
|
||||||
|
slip = abs(fill - base) * qty_eth
|
||||||
|
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional)
|
||||||
|
|
||||||
|
|
||||||
|
def option_fill(
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
bid: float,
|
||||||
|
ask: float,
|
||||||
|
qty_eth: float,
|
||||||
|
fee_rate: float,
|
||||||
|
) -> PriceResult:
|
||||||
|
"""开仓买入吃卖一;平仓卖出吃买一。"""
|
||||||
|
f = float(fee_rate)
|
||||||
|
if action == "open":
|
||||||
|
base = float(ask)
|
||||||
|
fill = base * (1.0 + f)
|
||||||
|
else:
|
||||||
|
base = float(bid)
|
||||||
|
fill = base * (1.0 - f)
|
||||||
|
notional = abs(fill * qty_eth)
|
||||||
|
fee = notional * f
|
||||||
|
slip = abs(fill - base) * qty_eth
|
||||||
|
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional)
|
||||||
@@ -1 +1,17 @@
|
|||||||
# Placeholder: strategy state machine (P2).
|
from .clock import can_open_new, window_key
|
||||||
|
from .engine import StrategyEngine, get_engine, set_engine
|
||||||
|
from .exits import check_exits
|
||||||
|
from .group import next_group_id
|
||||||
|
from .signal import Signal, decide
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Signal",
|
||||||
|
"StrategyEngine",
|
||||||
|
"can_open_new",
|
||||||
|
"check_exits",
|
||||||
|
"decide",
|
||||||
|
"get_engine",
|
||||||
|
"next_group_id",
|
||||||
|
"set_engine",
|
||||||
|
"window_key",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""业务窗时钟:16:00 开 → 08:00 停开;轮次与休息。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def now_sh(now: datetime | None = None) -> datetime:
|
||||||
|
return (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hhmm(s: str) -> tuple[int, int]:
|
||||||
|
parts = (s or "16:00").strip().split(":")
|
||||||
|
return int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def window_key(now: datetime | None = None) -> str:
|
||||||
|
"""
|
||||||
|
业务窗键:若当前 >= 当日 16:00,窗从今日 16:00 起,键=今日日期;
|
||||||
|
若 < 16:00,仍可能属于「昨日起的窗」(到今日 08:00),键=昨日。
|
||||||
|
"""
|
||||||
|
n = now_sh(now)
|
||||||
|
open_h, open_m = 16, 0
|
||||||
|
stop_h, stop_m = 8, 0
|
||||||
|
today_open = n.replace(hour=open_h, minute=open_m, second=0, microsecond=0)
|
||||||
|
today_stop = n.replace(hour=stop_h, minute=stop_m, second=0, microsecond=0)
|
||||||
|
if n >= today_open:
|
||||||
|
return n.strftime("%Y%m%d")
|
||||||
|
if n < today_stop:
|
||||||
|
# 仍在昨 16:00 开启的窗内
|
||||||
|
return (n.date() - timedelta(days=1)).strftime("%Y%m%d")
|
||||||
|
# 08:00~16:00:不在开仓窗,键用「即将开始」的今日窗
|
||||||
|
return n.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def can_open_new(
|
||||||
|
now: datetime | None = None,
|
||||||
|
*,
|
||||||
|
open_hhmm: str = "16:00",
|
||||||
|
stop_hhmm: str = "08:00",
|
||||||
|
) -> bool:
|
||||||
|
n = now_sh(now)
|
||||||
|
oh, om = parse_hhmm(open_hhmm)
|
||||||
|
sh, sm = parse_hhmm(stop_hhmm)
|
||||||
|
today_open = n.replace(hour=oh, minute=om, second=0, microsecond=0)
|
||||||
|
today_stop = n.replace(hour=sh, minute=sm, second=0, microsecond=0)
|
||||||
|
if n >= today_open:
|
||||||
|
return True
|
||||||
|
if n < today_stop:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def group_date_ymd(now: datetime | None = None) -> str:
|
||||||
|
return window_key(now)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""策略状态机:选向开仓 / 盯盘平仓 / 休息 / 限轮。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..market import get_gateway
|
||||||
|
from ..models.db import get_db
|
||||||
|
from ..sim.ledger import Ledger
|
||||||
|
from ..sim.matcher import Matcher
|
||||||
|
from .clock import can_open_new, window_key
|
||||||
|
from .exits import check_exits
|
||||||
|
from .group import next_group_id
|
||||||
|
from .signal import decide
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyEngine:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.db = get_db()
|
||||||
|
self.matcher = Matcher(self.db)
|
||||||
|
self.ledger = Ledger(self.db)
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
assert row is not None
|
||||||
|
upl = self.matcher.unrealized()
|
||||||
|
s = get_settings()
|
||||||
|
exit_pts = self.ledger.get_setting_float("exit_move_points", s.exit_move_points)
|
||||||
|
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
|
||||||
|
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
|
||||||
|
rest_until = row["rest_until_ms"]
|
||||||
|
rest_left = 0
|
||||||
|
if rest_until:
|
||||||
|
rest_left = max(0, int((int(rest_until) - time.time() * 1000) / 1000))
|
||||||
|
return {
|
||||||
|
"running": bool(row["running"]),
|
||||||
|
"phase": row["phase"],
|
||||||
|
"rounds_done": int(row["rounds_done"] or 0),
|
||||||
|
"max_rounds": max_rounds,
|
||||||
|
"window_key": row["window_key"],
|
||||||
|
"rest_until_ms": rest_until,
|
||||||
|
"rest_left_sec": rest_left,
|
||||||
|
"rest_seconds": rest_sec,
|
||||||
|
"exit_move_points": exit_pts,
|
||||||
|
"can_open": can_open_new(open_hhmm=s.open_hhmm, stop_hhmm=s.stop_open_hhmm),
|
||||||
|
"last_error": row["last_error"],
|
||||||
|
"position": upl,
|
||||||
|
"ledger": self.ledger.snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _set_state(self, **kwargs: Any) -> None:
|
||||||
|
cols = []
|
||||||
|
vals: list[Any] = []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
cols.append(f"{k}=?")
|
||||||
|
vals.append(v)
|
||||||
|
cols.append("updated_at_ms=?")
|
||||||
|
vals.append(int(time.time() * 1000))
|
||||||
|
sql = f"UPDATE strategy_state SET {', '.join(cols)} WHERE id=1"
|
||||||
|
self.db.execute(sql, tuple(vals))
|
||||||
|
|
||||||
|
async def start(self) -> dict[str, Any]:
|
||||||
|
self._set_state(running=1, last_error=None, phase="idle")
|
||||||
|
if self._task is None or self._task.done():
|
||||||
|
self._task = asyncio.create_task(self._loop(), name="strategy-engine")
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
async def pause(self) -> dict[str, Any]:
|
||||||
|
self._set_state(running=0, phase="paused")
|
||||||
|
return self.state()
|
||||||
|
|
||||||
|
async def emergency_close(self) -> dict[str, Any]:
|
||||||
|
async with self._lock:
|
||||||
|
r = self.matcher.close_group(reason="emergency")
|
||||||
|
if r.ok:
|
||||||
|
self._after_close()
|
||||||
|
return {"close": r.__dict__, "state": self.state()}
|
||||||
|
|
||||||
|
def _after_close(self) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
assert row is not None
|
||||||
|
rounds = int(row["rounds_done"] or 0) + 1
|
||||||
|
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
|
||||||
|
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
|
||||||
|
rest_until = int(time.time() * 1000) + rest_sec * 1000
|
||||||
|
if rounds >= max_rounds:
|
||||||
|
self._set_state(
|
||||||
|
rounds_done=rounds,
|
||||||
|
phase="stopped",
|
||||||
|
rest_until_ms=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._set_state(
|
||||||
|
rounds_done=rounds,
|
||||||
|
phase="resting",
|
||||||
|
rest_until_ms=rest_until,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _count_groups_for_window(self, wkey: str) -> int:
|
||||||
|
# group_id like G-20260724-01 ; window_key is YYYYMMDD
|
||||||
|
rows = self.db.fetchall(
|
||||||
|
"SELECT group_id FROM groups WHERE group_id LIKE ?",
|
||||||
|
(f"G-{wkey}-%",),
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
logger.info("strategy engine loop started")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
row = self.db.fetchone("SELECT running FROM strategy_state WHERE id=1")
|
||||||
|
if not row or not int(row["running"]):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
async with self._lock:
|
||||||
|
await asyncio.to_thread(self._tick)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("strategy tick failed")
|
||||||
|
self._set_state(last_error=str(e))
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
assert st is not None
|
||||||
|
wkey = window_key()
|
||||||
|
if st["window_key"] != wkey:
|
||||||
|
# 新业务窗重置轮次
|
||||||
|
self._set_state(window_key=wkey, rounds_done=0, phase="idle", rest_until_ms=None)
|
||||||
|
|
||||||
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
assert st is not None
|
||||||
|
max_rounds = self.ledger.get_setting_int("max_rounds", s.max_rounds)
|
||||||
|
exit_pts = self.ledger.get_setting_float("exit_move_points", s.exit_move_points)
|
||||||
|
pos = self.matcher.current_position()
|
||||||
|
|
||||||
|
# 有仓:盯平仓
|
||||||
|
if pos.get("status") == "open":
|
||||||
|
self._set_state(phase="open")
|
||||||
|
upl = self.matcher.unrealized()
|
||||||
|
decision = check_exits(
|
||||||
|
perp_upl=float(upl["perp_upl"]),
|
||||||
|
initial_premium=float(upl["initial_premium"] or 0),
|
||||||
|
move_points=float(upl["move_points"] or 0),
|
||||||
|
exit_move_points=exit_pts,
|
||||||
|
)
|
||||||
|
if decision.should_close:
|
||||||
|
self._set_state(phase="closing")
|
||||||
|
r = self.matcher.close_group(reason=decision.reason)
|
||||||
|
if r.ok:
|
||||||
|
self._after_close()
|
||||||
|
elif r.liquidity_wait:
|
||||||
|
self._set_state(phase="liquidity_wait", last_error=r.detail)
|
||||||
|
else:
|
||||||
|
self._set_state(last_error=r.detail)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 休息中
|
||||||
|
if st["phase"] == "resting" and st["rest_until_ms"]:
|
||||||
|
if int(time.time() * 1000) < int(st["rest_until_ms"]):
|
||||||
|
return
|
||||||
|
self._set_state(phase="idle", rest_until_ms=None)
|
||||||
|
|
||||||
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
assert st is not None
|
||||||
|
if int(st["rounds_done"] or 0) >= max_rounds:
|
||||||
|
self._set_state(phase="stopped")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not can_open_new(open_hhmm=s.open_hhmm, stop_hhmm=s.stop_open_hhmm):
|
||||||
|
self._set_state(phase="outside_window")
|
||||||
|
return
|
||||||
|
|
||||||
|
if st["phase"] in ("stopped", "paused"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 尝试开仓
|
||||||
|
self._set_state(phase="wait_signal")
|
||||||
|
gw = get_gateway()
|
||||||
|
snap = gw.snapshot()
|
||||||
|
if not snap.pair or not snap.call or not snap.put:
|
||||||
|
return
|
||||||
|
sig = decide(snap.call.ask, snap.put.ask)
|
||||||
|
if sig is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._set_state(phase="opening")
|
||||||
|
count = self._count_groups_for_window(wkey)
|
||||||
|
gid = next_group_id(count)
|
||||||
|
option_inst = (
|
||||||
|
snap.pair.call_inst_id if sig.option_side == "call" else snap.pair.put_inst_id
|
||||||
|
)
|
||||||
|
entry_idx = snap.index_px or (snap.perp.mark_px if snap.perp else None)
|
||||||
|
if entry_idx is None:
|
||||||
|
self._set_state(last_error="no index/mark for entry")
|
||||||
|
return
|
||||||
|
r = self.matcher.open_group(
|
||||||
|
group_id=gid,
|
||||||
|
bias=sig.bias,
|
||||||
|
option_side=sig.option_side,
|
||||||
|
perp_side=sig.perp_side,
|
||||||
|
option_inst_id=option_inst,
|
||||||
|
entry_index_px=float(entry_idx),
|
||||||
|
strike=snap.pair.strike,
|
||||||
|
expiry_ymd=snap.pair.expiry_ymd,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
self._set_state(phase="open", last_error=None)
|
||||||
|
else:
|
||||||
|
self._set_state(phase="idle", last_error=r.detail)
|
||||||
|
|
||||||
|
|
||||||
|
_engine: StrategyEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> StrategyEngine:
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = StrategyEngine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def set_engine(e: StrategyEngine | None) -> None:
|
||||||
|
global _engine
|
||||||
|
_engine = e
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ExitDecision:
|
||||||
|
should_close: bool
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_exits(
|
||||||
|
*,
|
||||||
|
perp_upl: float,
|
||||||
|
initial_premium: float,
|
||||||
|
move_points: float,
|
||||||
|
exit_move_points: float,
|
||||||
|
) -> ExitDecision:
|
||||||
|
if initial_premium > 0 and perp_upl + 1e-9 >= initial_premium:
|
||||||
|
return ExitDecision(True, "premium_cover")
|
||||||
|
if exit_move_points > 0 and move_points + 1e-9 >= exit_move_points:
|
||||||
|
return ExitDecision(True, "move_points")
|
||||||
|
return ExitDecision(False, "")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .clock import group_date_ymd
|
||||||
|
|
||||||
|
|
||||||
|
def next_group_id(existing_count: int, now=None) -> str:
|
||||||
|
ymd = group_date_ymd(now)
|
||||||
|
n = int(existing_count) + 1
|
||||||
|
return f"G-{ymd}-{n:02d}"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Signal:
|
||||||
|
bias: str # call_ask_gt_put | put_ask_gt_call
|
||||||
|
option_side: str # call | put
|
||||||
|
perp_side: str # long | short
|
||||||
|
call_ask: float
|
||||||
|
put_ask: float
|
||||||
|
|
||||||
|
|
||||||
|
def decide(call_ask: float | None, put_ask: float | None) -> Signal | None:
|
||||||
|
if call_ask is None or put_ask is None:
|
||||||
|
return None
|
||||||
|
if call_ask > put_ask:
|
||||||
|
return Signal(
|
||||||
|
bias="call_ask_gt_put",
|
||||||
|
option_side="call",
|
||||||
|
perp_side="short",
|
||||||
|
call_ask=float(call_ask),
|
||||||
|
put_ask=float(put_ask),
|
||||||
|
)
|
||||||
|
if put_ask > call_ask:
|
||||||
|
return Signal(
|
||||||
|
bias="put_ask_gt_call",
|
||||||
|
option_side="put",
|
||||||
|
perp_side="long",
|
||||||
|
call_ask=float(call_ask),
|
||||||
|
put_ask=float(put_ask),
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PERP_QTY_ETH = 1.0
|
||||||
|
OPTION_QTY_ETH = 2.0
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from app.strategy.signal import decide
|
||||||
|
from app.strategy.exits import check_exits
|
||||||
|
from app.sim.pricing import option_fill, perp_fill
|
||||||
|
from app.strategy.clock import can_open_new, window_key
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_buy_call_short_perp() -> None:
|
||||||
|
s = decide(20.0, 15.0)
|
||||||
|
assert s is not None
|
||||||
|
assert s.option_side == "call"
|
||||||
|
assert s.perp_side == "short"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_buy_put_long_perp() -> None:
|
||||||
|
s = decide(10.0, 16.0)
|
||||||
|
assert s is not None
|
||||||
|
assert s.option_side == "put"
|
||||||
|
assert s.perp_side == "long"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_equal() -> None:
|
||||||
|
assert decide(10.0, 10.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exit_premium_and_move() -> None:
|
||||||
|
assert check_exits(
|
||||||
|
perp_upl=50, initial_premium=40, move_points=1, exit_move_points=30
|
||||||
|
).reason == "premium_cover"
|
||||||
|
assert check_exits(
|
||||||
|
perp_upl=1, initial_premium=40, move_points=30, exit_move_points=30
|
||||||
|
).reason == "move_points"
|
||||||
|
|
||||||
|
|
||||||
|
def test_perp_pricing() -> None:
|
||||||
|
r = perp_fill(side="long", action="open", bid=100, ask=101, qty_eth=1, fee_rate=0.001)
|
||||||
|
assert abs(r.fill_px - 101 * 1.001) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_option_open_close_pricing() -> None:
|
||||||
|
o = option_fill(action="open", bid=10, ask=12, qty_eth=2, fee_rate=0.001)
|
||||||
|
assert o.fill_px > 12
|
||||||
|
c = option_fill(action="close", bid=10, ask=12, qty_eth=2, fee_rate=0.001)
|
||||||
|
assert c.fill_px < 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_window() -> None:
|
||||||
|
# 17:00 can open, window key today
|
||||||
|
n = datetime(2026, 7, 24, 17, 0, tzinfo=_SH)
|
||||||
|
assert can_open_new(n) is True
|
||||||
|
assert window_key(n) == "20260724"
|
||||||
|
# 10:00 cannot open
|
||||||
|
n2 = datetime(2026, 7, 24, 10, 0, tzinfo=_SH)
|
||||||
|
assert can_open_new(n2) is False
|
||||||
|
# 07:00 still previous window, can open
|
||||||
|
n3 = datetime(2026, 7, 24, 7, 0, tzinfo=_SH)
|
||||||
|
assert can_open_new(n3) is True
|
||||||
|
assert window_key(n3) == "20260723"
|
||||||
+21
-21
@@ -32,30 +32,30 @@
|
|||||||
|
|
||||||
### 2.1 时间与次数
|
### 2.1 时间与次数
|
||||||
|
|
||||||
- 期权合约:选 **次日 16:00** 到期。
|
- 期权合约:选 **次日 16:00** 到期;行权价默认 **ATM**(同到期、最接近指数/标记的同一行权价 Call+Put)。
|
||||||
- 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。
|
- 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。
|
||||||
- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓——若改规则在设置中可配)。
|
- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓);下一窗等 **16:00**。
|
||||||
- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓;平完才能开下一组。
|
- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓。
|
||||||
|
- 一轮全平结束后 **休息 5 分钟**(可配 `REST_SECONDS`),再自动开下一轮(未满 3 且仍在开仓窗)。
|
||||||
|
|
||||||
### 2.2 方向(Call 卖一 vs Put 卖一)
|
### 2.2 方向(Call 卖一 vs Put 卖一;期权只买入、永不为卖方)
|
||||||
|
|
||||||
| 条件 | 永续 | 期权 |
|
| 条件 | 期权(名义 2 ETH) | 永续(1 ETH) |
|
||||||
|------|------|------|
|
|------|-------------------|---------------|
|
||||||
| Call 卖一 > Put 卖一 | 市价做多 1 ETH | 做空 2 ETH 名义 |
|
| Call 卖一 > Put 卖一 | **买入 Call**(吃卖一) | **市价做空** |
|
||||||
| Call 卖一 < Put 卖一 | 市价做空 1 ETH | 做多 2 ETH 名义 |
|
| Call 卖一 < Put 卖一 | **买入 Put**(吃卖一) | **市价做多** |
|
||||||
| 相等 | 不开仓,等待 | — |
|
| 相等 | 不开仓,等待 | — |
|
||||||
|
|
||||||
> **实现前待定稿**:期权「做多/做空」具体买卖 Call 还是 Put(或组合);行权价选择(建议默认 ATM / 最接近标记价的同一行权价)。
|
|
||||||
|
|
||||||
### 2.3 平仓(任一触发 → 该组全平)
|
### 2.3 平仓(任一触发 → 该组全平)
|
||||||
|
|
||||||
1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(建议触发口径 **不含手续费**;费用单独记账)。此时期权侧通常仍有盈余/剩余价值,属预期内。
|
1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(触发口径 **不含手续费**;费用单独记账)。
|
||||||
2. **方向 30 点**:期权方向运行满 30 点 → 全平(**待定**:标的 ETH 点数 vs 权利金点数)。
|
2. **标的波动 N 点**(设置可配,默认 `EXIT_MOVE_POINTS=30`):相对开仓锁定的标的价(指数优先)绝对值走动 ≥ N → 全平。
|
||||||
|
主要用于永续方向错、期权方向对时的退出;永续方向对时同一 N 点也全平。
|
||||||
|
|
||||||
平仓执行:
|
平仓执行:
|
||||||
|
|
||||||
- 永续:本地市价平仓。
|
- 永续:本地市价平仓。
|
||||||
- 期权:吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。
|
- 期权:多头平仓吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。
|
||||||
|
|
||||||
### 2.4 组(Group)标识
|
### 2.4 组(Group)标识
|
||||||
|
|
||||||
@@ -205,17 +205,17 @@ TZ=Asia/Shanghai
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 实现前待拍板
|
## 8. 已拍板摘要
|
||||||
|
|
||||||
1. 期权多空的具体合约腿(Call / Put)。
|
1. 期权只买入 Call 或 Put(永不卖出开仓)。
|
||||||
2. 「30 点」定义(标的 vs 权利金)。
|
2. N 点 = 标的 ETH 波动点数,设置可配(默认 30)。
|
||||||
3. 行权价选择规则。
|
3. 行权价 ATM。
|
||||||
4. 初始权利金触发是否不含手续费(建议不含)。
|
4. 权利金覆盖触发不含手续费。
|
||||||
5. Call 卖一 = Put 卖一时:跳过等待(建议)。
|
5. Call 卖一 = Put 卖一:跳过等待。
|
||||||
6. 云服务器路径、域名/端口、PM2 进程最终命名。
|
6. 测试访问:`https://dc.hyf2.cc` → 本机 `5155` / PM2 `eth-hedge-api`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 一句话
|
## 9. 一句话
|
||||||
|
|
||||||
**独立仓 `eth_hedge_sim`:OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。**
|
**独立仓 `eth_hedge_sim`:OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只买吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const TOKEN_KEY = "eth_hedge_token";
|
const TOKEN_KEY = "eth_hedge_token";
|
||||||
const USER_KEY = "eth_hedge_user";
|
const USER_KEY = "eth_hedge_user";
|
||||||
|
|
||||||
/** 始终同源(经 dc.hyf2.cc 反代),不再暴露可改 API 地址。 */
|
|
||||||
export function getApiBase(): string {
|
export function getApiBase(): string {
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
@@ -113,3 +112,39 @@ type Quote = {
|
|||||||
ask_sz: number | null;
|
ask_sz: number | null;
|
||||||
mark_px: number | null;
|
mark_px: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlanState = {
|
||||||
|
running: boolean;
|
||||||
|
phase: string;
|
||||||
|
rounds_done: number;
|
||||||
|
max_rounds: number;
|
||||||
|
window_key: string | null;
|
||||||
|
rest_left_sec: number;
|
||||||
|
rest_seconds: number;
|
||||||
|
exit_move_points: number;
|
||||||
|
can_open: boolean;
|
||||||
|
last_error: string | null;
|
||||||
|
position: {
|
||||||
|
has_position: boolean;
|
||||||
|
group_id?: string;
|
||||||
|
perp_side?: string;
|
||||||
|
option_side?: string;
|
||||||
|
perp_upl?: number;
|
||||||
|
option_upl?: number;
|
||||||
|
index_px?: number | null;
|
||||||
|
entry_index_px?: number;
|
||||||
|
move_points?: number;
|
||||||
|
initial_premium?: number;
|
||||||
|
premium_gap?: number;
|
||||||
|
};
|
||||||
|
ledger: { equity: number; available: number; reserved: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StrategySettings = {
|
||||||
|
fee_rate: number;
|
||||||
|
exit_move_points: number;
|
||||||
|
rest_seconds: number;
|
||||||
|
max_rounds: number;
|
||||||
|
initial_equity: number;
|
||||||
|
ledger: { equity: number; available: number };
|
||||||
|
};
|
||||||
|
|||||||
+156
-60
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { apiFetch, MarketSnapshot } from "../api/client";
|
import { apiFetch, MarketSnapshot, PlanState } from "../api/client";
|
||||||
|
|
||||||
function fmt(n: number | null | undefined, d = 2) {
|
function fmt(n: number | null | undefined, d = 2) {
|
||||||
if (n == null || Number.isNaN(n)) return "—";
|
if (n == null || Number.isNaN(n)) return "—";
|
||||||
@@ -8,112 +8,208 @@ function fmt(n: number | null | undefined, d = 2) {
|
|||||||
|
|
||||||
export default function PlanPage() {
|
export default function PlanPage() {
|
||||||
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
|
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
|
||||||
|
const [plan, setPlan] = useState<PlanState | null>(null);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const [m, p] = await Promise.all([
|
||||||
|
apiFetch<MarketSnapshot>("/api/market/snapshot"),
|
||||||
|
apiFetch<PlanState>("/api/plan/state"),
|
||||||
|
]);
|
||||||
|
setSnap(m);
|
||||||
|
setPlan(p);
|
||||||
|
setErr("");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
refresh();
|
||||||
const load = async () => {
|
const t = window.setInterval(refresh, 1500);
|
||||||
try {
|
return () => window.clearInterval(t);
|
||||||
const data = await apiFetch<MarketSnapshot>("/api/market/snapshot");
|
|
||||||
if (alive) {
|
|
||||||
setSnap(data);
|
|
||||||
setErr("");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
const t = window.setInterval(load, 2000);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
window.clearInterval(t);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function act(path: string, label: string) {
|
||||||
|
setBusy(label);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await apiFetch(path, { method: "POST", body: "{}" });
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bias = snap?.ask_compare?.bias;
|
const bias = snap?.ask_compare?.bias;
|
||||||
const biasTag =
|
const biasTag =
|
||||||
bias === "call_ask_gt_put" ? (
|
bias === "call_ask_gt_put" ? (
|
||||||
<span className="tag up">Call卖一 > Put卖一 → 永续多+期权空</span>
|
<span className="tag up">买 Call + 永续空</span>
|
||||||
) : bias === "put_ask_gt_call" ? (
|
) : bias === "put_ask_gt_call" ? (
|
||||||
<span className="tag down">Put卖一 > Call卖一 → 永续空+期权多</span>
|
<span className="tag down">买 Put + 永续多</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="tag">等待 / 相等</span>
|
<span className="tag">等待 / 相等</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pos = plan?.position;
|
||||||
|
const exitN = plan?.exit_move_points ?? 30;
|
||||||
|
const move = pos?.move_points ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
||||||
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
||||||
P0 行情只读 · 策略开平仓待拍板后接入
|
SIM 本地撮合 · 期权只买 · 标的波动 N 点可配
|
||||||
</p>
|
</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy || plan?.running}
|
||||||
|
onClick={() => act("/api/plan/start", "start")}
|
||||||
|
>
|
||||||
|
启动策略
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy || !plan?.running}
|
||||||
|
onClick={() => act("/api/plan/pause", "pause")}
|
||||||
|
>
|
||||||
|
暂停
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => act("/api/sim/open-group", "open")}
|
||||||
|
>
|
||||||
|
模拟开一组
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => act("/api/sim/close-group", "close")}
|
||||||
|
>
|
||||||
|
模拟全平
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
type="button"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => act("/api/plan/emergency-close", "emg")}
|
||||||
|
>
|
||||||
|
紧急全平
|
||||||
|
</button>
|
||||||
|
{busy ? <span className="meta">{busy}…</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>模式</span>
|
<span>策略</span>
|
||||||
<span className="mono">SIM · 测试环境</span>
|
|
||||||
</div>
|
|
||||||
<div className="kv">
|
|
||||||
<span>行情连接</span>
|
|
||||||
<span className="mono">{snap?.connected ? "WS 已连接" : "REST/未连"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="kv">
|
|
||||||
<span>指数</span>
|
|
||||||
<span className="mono">{fmt(snap?.index_px)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="kv">
|
|
||||||
<span>选约</span>
|
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{snap?.pair
|
{plan?.running ? "运行中" : "已停"} · {plan?.phase || "—"} · 轮次{" "}
|
||||||
? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}`
|
{plan?.rounds_done ?? 0}/{plan?.max_rounds ?? 3}
|
||||||
: "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>选向</span>
|
<span>开仓窗</span>
|
||||||
{biasTag}
|
<span className="mono">
|
||||||
|
{plan?.can_open ? "可开" : "禁止新开"} · 窗 {plan?.window_key || "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>休息</span>
|
||||||
|
<span className="mono">
|
||||||
|
{plan?.rest_left_sec ? `${plan.rest_left_sec}s / ${plan.rest_seconds}s` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>权益</span>
|
||||||
|
<span className="mono">{fmt(plan?.ledger?.equity)} / 可用 {fmt(plan?.ledger?.available)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>当前组</span>
|
||||||
|
<span className="mono">{pos?.group_id || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>方向</span>
|
||||||
|
<span className="mono">
|
||||||
|
{pos?.has_position
|
||||||
|
? `永续${pos.perp_side} + 买${pos.option_side?.toUpperCase()}`
|
||||||
|
: "—"}{" "}
|
||||||
|
{biasTag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>初始权利金</span>
|
||||||
|
<span className="mono">{fmt(pos?.initial_premium)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>永续浮盈 / 距覆盖</span>
|
||||||
|
<span className="mono">
|
||||||
|
{fmt(pos?.perp_upl)} / {fmt(pos?.premium_gap)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>N 点进度</span>
|
||||||
|
<span className="mono">
|
||||||
|
{fmt(move, 1)} / {fmt(exitN, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{plan?.last_error ? (
|
||||||
|
<div className="kv">
|
||||||
|
<span>最近错误</span>
|
||||||
|
<span className="err" style={{ margin: 0 }}>
|
||||||
|
{plan.last_error}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginTop: 0 }}>永续 ETH-USDT-SWAP</h3>
|
<h3 style={{ marginTop: 0 }}>永续</h3>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>买一</span>
|
<span>买一</span>
|
||||||
<span className="mono">{fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)}</span>
|
<span className="mono">{fmt(snap?.perp?.bid)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>卖一</span>
|
<span>卖一</span>
|
||||||
<span className="mono">{fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)}</span>
|
<span className="mono">{fmt(snap?.perp?.ask)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>标记</span>
|
<span>指数</span>
|
||||||
<span className="mono">{fmt(snap?.perp?.mark_px)}</span>
|
<span className="mono">{fmt(snap?.index_px)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 style={{ marginTop: 0 }}>期权 ATM</h3>
|
<h3 style={{ marginTop: 0 }}>
|
||||||
|
期权 ATM {snap?.pair ? `@ ${snap.pair.strike}` : ""}
|
||||||
|
</h3>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>Call 卖一</span>
|
<span>Call 卖一/买一</span>
|
||||||
<span className="mono">{fmt(snap?.call?.ask)} / 买一 {fmt(snap?.call?.bid)}</span>
|
<span className="mono">
|
||||||
</div>
|
{fmt(snap?.call?.ask)} / {fmt(snap?.call?.bid)}
|
||||||
<div className="kv">
|
|
||||||
<span>Put 卖一</span>
|
|
||||||
<span className="mono">{fmt(snap?.put?.ask)} / 买一 {fmt(snap?.put?.bid)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="kv">
|
|
||||||
<span>Call</span>
|
|
||||||
<span className="mono" style={{ fontSize: 12 }}>
|
|
||||||
{snap?.pair?.call_inst_id || "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>Put</span>
|
<span>Put 卖一/买一</span>
|
||||||
<span className="mono" style={{ fontSize: 12 }}>
|
<span className="mono">
|
||||||
{snap?.pair?.put_inst_id || "—"}
|
{fmt(snap?.put?.ask)} / {fmt(snap?.put?.bid)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>到期</span>
|
||||||
|
<span className="mono">{snap?.pair?.expiry_ymd || "—"}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+153
-56
@@ -1,5 +1,11 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { changeCredentials, getUsername, setSession } from "../api/client";
|
import {
|
||||||
|
changeCredentials,
|
||||||
|
getUsername,
|
||||||
|
setSession,
|
||||||
|
apiFetch,
|
||||||
|
StrategySettings,
|
||||||
|
} from "../api/client";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
|
||||||
@@ -10,7 +16,24 @@ export default function SettingsPage() {
|
|||||||
const [ok, setOk] = useState("");
|
const [ok, setOk] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function onSave(e: FormEvent) {
|
const [fee, setFee] = useState(0.0005);
|
||||||
|
const [exitPts, setExitPts] = useState(30);
|
||||||
|
const [rest, setRest] = useState(300);
|
||||||
|
const [maxRounds, setMaxRounds] = useState(3);
|
||||||
|
const [stratOk, setStratOk] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<StrategySettings>("/api/settings/strategy")
|
||||||
|
.then((s) => {
|
||||||
|
setFee(s.fee_rate);
|
||||||
|
setExitPts(s.exit_move_points);
|
||||||
|
setRest(s.rest_seconds);
|
||||||
|
setMaxRounds(s.max_rounds);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSaveCreds(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr("");
|
setErr("");
|
||||||
setOk("");
|
setOk("");
|
||||||
@@ -41,60 +64,134 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSaveStrategy(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setStratOk("");
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/settings/strategy", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fee_rate: fee,
|
||||||
|
exit_move_points: exitPts,
|
||||||
|
rest_seconds: rest,
|
||||||
|
max_rounds: maxRounds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setStratOk("策略参数已保存");
|
||||||
|
} catch (ex) {
|
||||||
|
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ maxWidth: 560 }}>
|
<div style={{ display: "grid", gap: 16, maxWidth: 560 }}>
|
||||||
<h2 style={{ marginTop: 0 }}>系统设置</h2>
|
<div className="card">
|
||||||
<p style={{ color: "var(--muted)" }}>修改登录用户名与密码(写入服务器 .env,立即生效)。</p>
|
<h2 style={{ marginTop: 0 }}>策略设置</h2>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
<p style={{ color: "var(--muted)" }}>
|
||||||
{ok ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{ok}</div> : null}
|
标的波动 N 点全平、轮次休息、费率(滑点=1×费率)。
|
||||||
<form onSubmit={onSave}>
|
</p>
|
||||||
<div className="field">
|
{stratOk ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{stratOk}</div> : null}
|
||||||
<label htmlFor="user">新用户名</label>
|
<form onSubmit={onSaveStrategy}>
|
||||||
<input
|
<div className="field">
|
||||||
id="user"
|
<label htmlFor="exit">EXIT_MOVE_POINTS(标的波动点数)</label>
|
||||||
value={newUsername}
|
<input
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
id="exit"
|
||||||
autoComplete="username"
|
className="mono"
|
||||||
required
|
type="number"
|
||||||
/>
|
step="1"
|
||||||
</div>
|
value={exitPts}
|
||||||
<div className="field">
|
onChange={(e) => setExitPts(Number(e.target.value))}
|
||||||
<label htmlFor="cur">当前密码</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
id="cur"
|
<div className="field">
|
||||||
type="password"
|
<label htmlFor="rest">REST_SECONDS(轮间休息秒)</label>
|
||||||
value={currentPassword}
|
<input
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
id="rest"
|
||||||
autoComplete="current-password"
|
className="mono"
|
||||||
required
|
type="number"
|
||||||
/>
|
step="1"
|
||||||
</div>
|
value={rest}
|
||||||
<div className="field">
|
onChange={(e) => setRest(Number(e.target.value))}
|
||||||
<label htmlFor="np">新密码</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
id="np"
|
<div className="field">
|
||||||
type="password"
|
<label htmlFor="rounds">MAX_ROUNDS</label>
|
||||||
value={newPassword}
|
<input
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
id="rounds"
|
||||||
autoComplete="new-password"
|
className="mono"
|
||||||
required
|
type="number"
|
||||||
/>
|
step="1"
|
||||||
</div>
|
value={maxRounds}
|
||||||
<div className="field">
|
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||||
<label htmlFor="cp">确认新密码</label>
|
/>
|
||||||
<input
|
</div>
|
||||||
id="cp"
|
<div className="field">
|
||||||
type="password"
|
<label htmlFor="fee">FEE_RATE</label>
|
||||||
value={confirmPassword}
|
<input
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
id="fee"
|
||||||
autoComplete="new-password"
|
className="mono"
|
||||||
required
|
type="number"
|
||||||
/>
|
step="0.0001"
|
||||||
</div>
|
value={fee}
|
||||||
<button className="btn" type="submit" disabled={loading}>
|
onChange={(e) => setFee(Number(e.target.value))}
|
||||||
{loading ? "保存中…" : "保存"}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</form>
|
<button className="btn" type="submit">
|
||||||
|
保存策略
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginTop: 0 }}>登录账号</h2>
|
||||||
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
{ok ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{ok}</div> : null}
|
||||||
|
<form onSubmit={onSaveCreds}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="user">新用户名</label>
|
||||||
|
<input
|
||||||
|
id="user"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="cur">当前密码</label>
|
||||||
|
<input
|
||||||
|
id="cur"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="np">新密码</label>
|
||||||
|
<input
|
||||||
|
id="np"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="cp">确认新密码</label>
|
||||||
|
<input
|
||||||
|
id="cp"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn" type="submit" disabled={loading}>
|
||||||
|
{loading ? "保存中…" : "保存账号"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,64 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiFetch } from "../api/client";
|
||||||
|
|
||||||
|
type Summary = {
|
||||||
|
groups: number;
|
||||||
|
wins: number;
|
||||||
|
win_rate: number;
|
||||||
|
total_pnl: number;
|
||||||
|
total_fees: number;
|
||||||
|
total_slip: number;
|
||||||
|
close_reasons: Record<string, number>;
|
||||||
|
equity_curve: { group_id: string; realized_pnl: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
|
const [s, setS] = useState<Summary | null>(null);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Summary>("/api/stats/summary")
|
||||||
|
.then(setS)
|
||||||
|
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 style={{ marginTop: 0 }}>统计</h2>
|
<h2 style={{ marginTop: 0 }}>统计</h2>
|
||||||
<p style={{ color: "var(--muted)" }}>胜率 / 盈亏 / 手续费曲线将在有成交后展示。</p>
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
{!s ? (
|
||||||
|
<p style={{ color: "var(--muted)" }}>加载中…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="kv">
|
||||||
|
<span>组数 / 胜率</span>
|
||||||
|
<span className="mono">
|
||||||
|
{s.groups} / {(s.win_rate * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>总盈亏</span>
|
||||||
|
<span className="mono">{s.total_pnl.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>总手续费 / 滑点</span>
|
||||||
|
<span className="mono">
|
||||||
|
{s.total_fees.toFixed(2)} / {s.total_slip.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>平仓原因</span>
|
||||||
|
<span className="mono">{JSON.stringify(s.close_reasons)}</span>
|
||||||
|
</div>
|
||||||
|
<h3>按组盈亏</h3>
|
||||||
|
{s.equity_curve.map((x) => (
|
||||||
|
<div key={x.group_id} className="kv">
|
||||||
|
<span className="mono">{x.group_id}</span>
|
||||||
|
<span className="mono">{x.realized_pnl.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,91 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiFetch } from "../api/client";
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
group_id: string;
|
||||||
|
status: string;
|
||||||
|
bias: string | null;
|
||||||
|
option_side: string | null;
|
||||||
|
perp_side: string | null;
|
||||||
|
initial_premium: number;
|
||||||
|
realized_pnl: number;
|
||||||
|
close_reason: string | null;
|
||||||
|
open_at_ms: number | null;
|
||||||
|
close_at_ms: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fill = {
|
||||||
|
id: number;
|
||||||
|
leg: string;
|
||||||
|
action: string;
|
||||||
|
side: string;
|
||||||
|
fill_px: number;
|
||||||
|
fee: number;
|
||||||
|
qty_eth: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function TradesPage() {
|
export default function TradesPage() {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [fills, setFills] = useState<Fill[]>([]);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ groups: Group[] }>("/api/trades/groups")
|
||||||
|
.then((r) => setGroups(r.groups))
|
||||||
|
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function openGroup(id: string) {
|
||||||
|
setSelected(id);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch<{ fills: Fill[] }>(`/api/trades/groups/${id}`);
|
||||||
|
setFills(r.fills);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
||||||
<p style={{ color: "var(--muted)" }}>按组成交明细将在 P1/P2 接入。</p>
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--muted)" }}>暂无成交组</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.group_id}
|
||||||
|
className="kv"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => openGroup(g.group_id)}
|
||||||
|
>
|
||||||
|
<span className="mono">
|
||||||
|
{g.group_id} · {g.status} · {g.perp_side}/{g.option_side}
|
||||||
|
</span>
|
||||||
|
<span className="mono">
|
||||||
|
PnL {Number(g.realized_pnl || 0).toFixed(2)} · {g.close_reason || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selected ? (
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>{selected} 成交明细</h3>
|
||||||
|
{fills.map((f) => (
|
||||||
|
<div key={f.id} className="kv">
|
||||||
|
<span className="mono">
|
||||||
|
{f.leg} {f.action} {f.side}
|
||||||
|
</span>
|
||||||
|
<span className="mono">
|
||||||
|
px {f.fill_px.toFixed(4)} · qty {f.qty_eth} · fee {f.fee.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user