diff --git a/.env.example b/.env.example index 7858cd3..2c82968 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,7 @@ INITIAL_EQUITY=100000 MAX_ROUNDS=3 OPEN_HHMM=16:00 STOP_OPEN_HHMM=08:00 +EXIT_MOVE_POINTS=30 +REST_SECONDS=300 +PERP_QTY_ETH=1 +OPTION_QTY_ETH=2 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 39d4c1a..f9b4f03 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -2,7 +2,17 @@ from fastapi import APIRouter from .auth_routes import router as auth_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.include_router(auth_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) diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py new file mode 100644 index 0000000..5b47afd --- /dev/null +++ b/backend/app/api/plan.py @@ -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() diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..4872ac9 --- /dev/null +++ b/backend/app/api/settings.py @@ -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(), + } diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py new file mode 100644 index 0000000..e07860e --- /dev/null +++ b/backend/app/api/sim.py @@ -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, + } diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py new file mode 100644 index 0000000..90f0fc0 --- /dev/null +++ b/backend/app/api/stats.py @@ -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, + } diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py new file mode 100644 index 0000000..58e65f4 --- /dev/null +++ b/backend/app/api/trades.py @@ -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]} diff --git a/backend/app/config.py b/backend/app/config.py index 82be518..32a885e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,12 @@ class Settings(BaseSettings): max_rounds: int = 3 open_hhmm: str = "16: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 def is_sim(self) -> bool: diff --git a/backend/app/main.py b/backend/app/main.py index c03379c..64c06b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,8 @@ from fastapi.staticfiles import StaticFiles from .api import router as api_router from .config import get_settings from .market import MarketGateway, set_gateway +from .models.db import Database, set_db +from .strategy import StrategyEngine, set_engine logging.basicConfig( level=logging.INFO, @@ -22,7 +24,6 @@ logger = logging.getLogger("eth_hedge_sim") def resolve_frontend_dist() -> Path: here = Path(__file__).resolve() - # backend/app/main.py -> repo root is parents[2] repo_root = here.parents[2] return repo_root / "frontend" / "dist" @@ -30,25 +31,39 @@ def resolve_frontend_dist() -> Path: @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() - if not settings.is_sim: - logger.warning("MODE=%s — still read-only market in current phase", settings.mode) + db = Database() + set_db(db) + engine = StrategyEngine() + set_engine(engine) gw = MarketGateway(settings) set_gateway(gw) try: await gw.start() - logger.info("market gateway started (SIM read-only)") + logger.info("market gateway started (SIM)") except Exception: logger.exception("market gateway 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 gw.stop() set_gateway(None) + set_engine(None) + db.close() + set_db(None) app = FastAPI( title="eth_hedge_sim", - version="0.2.0", - description="ETH 自动对冲模拟盘", + version="0.3.0", + description="ETH 自动对冲模拟盘 P1/P2", lifespan=lifespan, ) app.add_middleware( @@ -64,10 +79,15 @@ app.include_router(api_router) @app.get("/health") async def health() -> dict: from .market import get_gateway + from .strategy import get_engine settings = get_settings() gw = get_gateway() snap = gw.snapshot() + try: + st = get_engine().state() + except Exception: + st = None return { "ok": True, "mode": settings.mode, @@ -76,6 +96,11 @@ async def health() -> dict: "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, + }, } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f7d8c18..c27d295 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,3 @@ -# Placeholder: DB models (P1). +from .db import Database, get_db, set_db + +__all__ = ["Database", "get_db", "set_db"] diff --git a/backend/app/models/db.py b/backend/app/models/db.py new file mode 100644 index 0000000..bac8617 --- /dev/null +++ b/backend/app/models/db.py @@ -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 diff --git a/backend/app/sim/__init__.py b/backend/app/sim/__init__.py index 18b3d54..5778e7a 100644 --- a/backend/app/sim/__init__.py +++ b/backend/app/sim/__init__.py @@ -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", +] diff --git a/backend/app/sim/ledger.py b/backend/app/sim/ledger.py new file mode 100644 index 0000000..5d9c259 --- /dev/null +++ b/backend/app/sim/ledger.py @@ -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))) diff --git a/backend/app/sim/liquidity.py b/backend/app/sim/liquidity.py new file mode 100644 index 0000000..71df146 --- /dev/null +++ b/backend/app/sim/liquidity.py @@ -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) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py new file mode 100644 index 0000000..143f0ed --- /dev/null +++ b/backend/app/sim/matcher.py @@ -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"), + } diff --git a/backend/app/sim/pricing.py b/backend/app/sim/pricing.py new file mode 100644 index 0000000..cb607db --- /dev/null +++ b/backend/app/sim/pricing.py @@ -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) diff --git a/backend/app/strategy/__init__.py b/backend/app/strategy/__init__.py index 90d4c45..cd50b46 100644 --- a/backend/app/strategy/__init__.py +++ b/backend/app/strategy/__init__.py @@ -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", +] diff --git a/backend/app/strategy/clock.py b/backend/app/strategy/clock.py new file mode 100644 index 0000000..3efc6e0 --- /dev/null +++ b/backend/app/strategy/clock.py @@ -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) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py new file mode 100644 index 0000000..2b166cc --- /dev/null +++ b/backend/app/strategy/engine.py @@ -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 diff --git a/backend/app/strategy/exits.py b/backend/app/strategy/exits.py new file mode 100644 index 0000000..68b216c --- /dev/null +++ b/backend/app/strategy/exits.py @@ -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, "") diff --git a/backend/app/strategy/group.py b/backend/app/strategy/group.py new file mode 100644 index 0000000..1db6e0a --- /dev/null +++ b/backend/app/strategy/group.py @@ -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}" diff --git a/backend/app/strategy/signal.py b/backend/app/strategy/signal.py new file mode 100644 index 0000000..10e319f --- /dev/null +++ b/backend/app/strategy/signal.py @@ -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 diff --git a/backend/app/strategy/sizing.py b/backend/app/strategy/sizing.py new file mode 100644 index 0000000..374cefb --- /dev/null +++ b/backend/app/strategy/sizing.py @@ -0,0 +1,2 @@ +PERP_QTY_ETH = 1.0 +OPTION_QTY_ETH = 2.0 diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py new file mode 100644 index 0000000..4abb3de --- /dev/null +++ b/backend/tests/test_p1_p2_rules.py @@ -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" diff --git a/docs/开发方案.md b/docs/开发方案.md index 31eea61..f6fd720 100644 --- a/docs/开发方案.md +++ b/docs/开发方案.md @@ -32,30 +32,30 @@ ### 2.1 时间与次数 -- 期权合约:选 **次日 16:00** 到期。 +- 期权合约:选 **次日 16:00** 到期;行权价默认 **ATM**(同到期、最接近指数/标记的同一行权价 Call+Put)。 - 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。 -- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓——若改规则在设置中可配)。 -- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓;平完才能开下一组。 +- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓);下一窗等 **16:00**。 +- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓。 +- 一轮全平结束后 **休息 5 分钟**(可配 `REST_SECONDS`),再自动开下一轮(未满 3 且仍在开仓窗)。 -### 2.2 方向(Call 卖一 vs Put 卖一) +### 2.2 方向(Call 卖一 vs Put 卖一;期权只买入、永不为卖方) -| 条件 | 永续 | 期权 | -|------|------|------| -| Call 卖一 > Put 卖一 | 市价做多 1 ETH | 做空 2 ETH 名义 | -| Call 卖一 < Put 卖一 | 市价做空 1 ETH | 做多 2 ETH 名义 | +| 条件 | 期权(名义 2 ETH) | 永续(1 ETH) | +|------|-------------------|---------------| +| Call 卖一 > Put 卖一 | **买入 Call**(吃卖一) | **市价做空** | +| Call 卖一 < Put 卖一 | **买入 Put**(吃卖一) | **市价做多** | | 相等 | 不开仓,等待 | — | -> **实现前待定稿**:期权「做多/做空」具体买卖 Call 还是 Put(或组合);行权价选择(建议默认 ATM / 最接近标记价的同一行权价)。 - ### 2.3 平仓(任一触发 → 该组全平) -1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(建议触发口径 **不含手续费**;费用单独记账)。此时期权侧通常仍有盈余/剩余价值,属预期内。 -2. **方向 30 点**:期权方向运行满 30 点 → 全平(**待定**:标的 ETH 点数 vs 权利金点数)。 +1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(触发口径 **不含手续费**;费用单独记账)。 +2. **标的波动 N 点**(设置可配,默认 `EXIT_MOVE_POINTS=30`):相对开仓锁定的标的价(指数优先)绝对值走动 ≥ N → 全平。 + 主要用于永续方向错、期权方向对时的退出;永续方向对时同一 N 点也全平。 平仓执行: - 永续:本地市价平仓。 -- 期权:吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。 +- 期权:多头平仓吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。 ### 2.4 组(Group)标识 @@ -205,17 +205,17 @@ TZ=Asia/Shanghai --- -## 8. 实现前待拍板 +## 8. 已拍板摘要 -1. 期权多空的具体合约腿(Call / Put)。 -2. 「30 点」定义(标的 vs 权利金)。 -3. 行权价选择规则。 -4. 初始权利金触发是否不含手续费(建议不含)。 -5. Call 卖一 = Put 卖一时:跳过等待(建议)。 -6. 云服务器路径、域名/端口、PM2 进程最终命名。 +1. 期权只买入 Call 或 Put(永不卖出开仓)。 +2. N 点 = 标的 ETH 波动点数,设置可配(默认 30)。 +3. 行权价 ATM。 +4. 权利金覆盖触发不含手续费。 +5. Call 卖一 = Put 卖一:跳过等待。 +6. 测试访问:`https://dc.hyf2.cc` → 本机 `5155` / PM2 `eth-hedge-api`。 --- ## 9. 一句话 -**独立仓 `eth_hedge_sim`:OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。** +**独立仓 `eth_hedge_sim`:OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只买吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。** diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d9246aa..d4750f8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,6 @@ const TOKEN_KEY = "eth_hedge_token"; const USER_KEY = "eth_hedge_user"; -/** 始终同源(经 dc.hyf2.cc 反代),不再暴露可改 API 地址。 */ export function getApiBase(): string { return window.location.origin; } @@ -113,3 +112,39 @@ type Quote = { ask_sz: 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 }; +}; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index aa8c624..721cf26 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -1,5 +1,5 @@ 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) { if (n == null || Number.isNaN(n)) return "—"; @@ -8,112 +8,208 @@ function fmt(n: number | null | undefined, d = 2) { export default function PlanPage() { const [snap, setSnap] = useState(null); + const [plan, setPlan] = useState(null); const [err, setErr] = useState(""); + const [busy, setBusy] = useState(""); + + async function refresh() { + try { + const [m, p] = await Promise.all([ + apiFetch("/api/market/snapshot"), + apiFetch("/api/plan/state"), + ]); + setSnap(m); + setPlan(p); + setErr(""); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + } useEffect(() => { - let alive = true; - const load = async () => { - try { - const data = await apiFetch("/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); - }; + refresh(); + const t = window.setInterval(refresh, 1500); + return () => 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 biasTag = bias === "call_ask_gt_put" ? ( - Call卖一 > Put卖一 → 永续多+期权空 + 买 Call + 永续空 ) : bias === "put_ask_gt_call" ? ( - Put卖一 > Call卖一 → 永续空+期权多 + 买 Put + 永续多 ) : ( 等待 / 相等 ); + const pos = plan?.position; + const exitN = plan?.exit_move_points ?? 30; + const move = pos?.move_points ?? 0; + return (

自动对冲计划

- P0 行情只读 · 策略开平仓待拍板后接入 + SIM 本地撮合 · 期权只买 · 标的波动 N 点可配

{err ?
{err}
: null} +
+ + + + + + {busy ? {busy}… : null} +
+
- 模式 - SIM · 测试环境 -
-
- 行情连接 - {snap?.connected ? "WS 已连接" : "REST/未连"} -
-
- 指数 - {fmt(snap?.index_px)} -
-
- 选约 + 策略 - {snap?.pair - ? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}` - : "—"} + {plan?.running ? "运行中" : "已停"} · {plan?.phase || "—"} · 轮次{" "} + {plan?.rounds_done ?? 0}/{plan?.max_rounds ?? 3}
- 选向 - {biasTag} + 开仓窗 + + {plan?.can_open ? "可开" : "禁止新开"} · 窗 {plan?.window_key || "—"} +
+
+ 休息 + + {plan?.rest_left_sec ? `${plan.rest_left_sec}s / ${plan.rest_seconds}s` : "—"} + +
+
+ 权益 + {fmt(plan?.ledger?.equity)} / 可用 {fmt(plan?.ledger?.available)} +
+
+ 当前组 + {pos?.group_id || "—"} +
+
+ 方向 + + {pos?.has_position + ? `永续${pos.perp_side} + 买${pos.option_side?.toUpperCase()}` + : "—"}{" "} + {biasTag} + +
+
+ 初始权利金 + {fmt(pos?.initial_premium)} +
+
+ 永续浮盈 / 距覆盖 + + {fmt(pos?.perp_upl)} / {fmt(pos?.premium_gap)} + +
+
+ N 点进度 + + {fmt(move, 1)} / {fmt(exitN, 0)} + +
+ {plan?.last_error ? ( +
+ 最近错误 + + {plan.last_error} + +
+ ) : null}
-

永续 ETH-USDT-SWAP

+

永续

买一 - {fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)} + {fmt(snap?.perp?.bid)}
卖一 - {fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)} + {fmt(snap?.perp?.ask)}
- 标记 - {fmt(snap?.perp?.mark_px)} + 指数 + {fmt(snap?.index_px)}
-

期权 ATM

+

+ 期权 ATM {snap?.pair ? `@ ${snap.pair.strike}` : ""} +

- Call 卖一 - {fmt(snap?.call?.ask)} / 买一 {fmt(snap?.call?.bid)} -
-
- Put 卖一 - {fmt(snap?.put?.ask)} / 买一 {fmt(snap?.put?.bid)} -
-
- Call - - {snap?.pair?.call_inst_id || "—"} + Call 卖一/买一 + + {fmt(snap?.call?.ask)} / {fmt(snap?.call?.bid)}
- Put - - {snap?.pair?.put_inst_id || "—"} + Put 卖一/买一 + + {fmt(snap?.put?.ask)} / {fmt(snap?.put?.bid)}
+
+ 到期 + {snap?.pair?.expiry_ymd || "—"} +
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 6c107df..c9d1acb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,5 +1,11 @@ -import { FormEvent, useState } from "react"; -import { changeCredentials, getUsername, setSession } from "../api/client"; +import { FormEvent, useEffect, useState } from "react"; +import { + changeCredentials, + getUsername, + setSession, + apiFetch, + StrategySettings, +} from "../api/client"; export default function SettingsPage() { const [newUsername, setNewUsername] = useState(getUsername() || "admin"); @@ -10,7 +16,24 @@ export default function SettingsPage() { const [ok, setOk] = useState(""); 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("/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(); setErr(""); 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 ( -
-

系统设置

-

修改登录用户名与密码(写入服务器 .env,立即生效)。

- {err ?
{err}
: null} - {ok ?
{ok}
: null} -
-
- - setNewUsername(e.target.value)} - autoComplete="username" - required - /> -
-
- - setCurrentPassword(e.target.value)} - autoComplete="current-password" - required - /> -
-
- - setNewPassword(e.target.value)} - autoComplete="new-password" - required - /> -
-
- - setConfirmPassword(e.target.value)} - autoComplete="new-password" - required - /> -
- -
+
+
+

策略设置

+

+ 标的波动 N 点全平、轮次休息、费率(滑点=1×费率)。 +

+ {stratOk ?
{stratOk}
: null} +
+
+ + setExitPts(Number(e.target.value))} + /> +
+
+ + setRest(Number(e.target.value))} + /> +
+
+ + setMaxRounds(Number(e.target.value))} + /> +
+
+ + setFee(Number(e.target.value))} + /> +
+ +
+
+ +
+

登录账号

+ {err ?
{err}
: null} + {ok ?
{ok}
: null} +
+
+ + setNewUsername(e.target.value)} + required + /> +
+
+ + setCurrentPassword(e.target.value)} + required + /> +
+
+ + setNewPassword(e.target.value)} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + required + /> +
+ +
+
); } diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index 2715cc6..0a6823d 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -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; + equity_curve: { group_id: string; realized_pnl: number }[]; +}; + export default function StatsPage() { + const [s, setS] = useState(null); + const [err, setErr] = useState(""); + + useEffect(() => { + apiFetch("/api/stats/summary") + .then(setS) + .catch((e) => setErr(e instanceof Error ? e.message : String(e))); + }, []); + return (

统计

-

胜率 / 盈亏 / 手续费曲线将在有成交后展示。

+ {err ?
{err}
: null} + {!s ? ( +

加载中…

+ ) : ( + <> +
+ 组数 / 胜率 + + {s.groups} / {(s.win_rate * 100).toFixed(1)}% + +
+
+ 总盈亏 + {s.total_pnl.toFixed(2)} +
+
+ 总手续费 / 滑点 + + {s.total_fees.toFixed(2)} / {s.total_slip.toFixed(2)} + +
+
+ 平仓原因 + {JSON.stringify(s.close_reasons)} +
+

按组盈亏

+ {s.equity_curve.map((x) => ( +
+ {x.group_id} + {x.realized_pnl.toFixed(2)} +
+ ))} + + )}
); } diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx index 58173b6..0da55ac 100644 --- a/frontend/src/pages/Trades.tsx +++ b/frontend/src/pages/Trades.tsx @@ -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() { + const [groups, setGroups] = useState([]); + const [selected, setSelected] = useState(null); + const [fills, setFills] = useState([]); + 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 ( -
+

交易记录

-

按组成交明细将在 P1/P2 接入。

+ {err ?
{err}
: null} +
+ {groups.length === 0 ? ( +

暂无成交组

+ ) : ( + groups.map((g) => ( +
openGroup(g.group_id)} + > + + {g.group_id} · {g.status} · {g.perp_side}/{g.option_side} + + + PnL {Number(g.realized_pnl || 0).toFixed(2)} · {g.close_reason || "—"} + +
+ )) + )} +
+ {selected ? ( +
+

{selected} 成交明细

+ {fills.map((f) => ( +
+ + {f.leg} {f.action} {f.side} + + + px {f.fill_px.toFixed(4)} · qty {f.qty_eth} · fee {f.fee.toFixed(4)} + +
+ ))} +
+ ) : null}
); }