eca6d091e9
Start strategy arms a watching plan instead of opening immediately; list filters by leverage; type is a dropdown defaulting to OTM. Co-authored-by: Cursor <cursoragent@cursor.com>
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""对冲计划 SQLite 表."""
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from typing import Any, Optional
|
||
|
||
|
||
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS hedge_plans (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
plan_type TEXT NOT NULL,
|
||
status TEXT NOT NULL,
|
||
underlying TEXT NOT NULL,
|
||
direction TEXT,
|
||
entry_mark REAL,
|
||
tp REAL,
|
||
sl REAL,
|
||
target_price REAL,
|
||
sizing_mode_at_open TEXT,
|
||
perp_size REAL,
|
||
margin REAL,
|
||
leverage REAL,
|
||
premium_total REAL,
|
||
realized_pnl_perp REAL,
|
||
realized_pnl_options REAL,
|
||
realized_pnl_total REAL,
|
||
stats_bucket TEXT,
|
||
close_reason TEXT,
|
||
wechat_start_sent INTEGER DEFAULT 0,
|
||
wechat_end_sent INTEGER DEFAULT 0,
|
||
note TEXT,
|
||
preview_json TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
opened_at TIMESTAMP,
|
||
closed_at TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
plan_id INTEGER NOT NULL,
|
||
leg_role TEXT NOT NULL,
|
||
symbol TEXT,
|
||
inst_id TEXT,
|
||
opt_type TEXT,
|
||
strike REAL,
|
||
side TEXT,
|
||
size REAL,
|
||
avg_open REAL,
|
||
premium REAL,
|
||
status TEXT,
|
||
linked_monitor_id INTEGER,
|
||
options_trade_id INTEGER,
|
||
exchange_ord_id TEXT,
|
||
realized_pnl REAL,
|
||
close_reason TEXT,
|
||
opened_at TIMESTAMP,
|
||
closed_at TIMESTAMP,
|
||
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
|
||
)
|
||
"""
|
||
)
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
|
||
)
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
|
||
)
|
||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
||
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
|
||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
||
# 永期「以期权为主」
|
||
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
|
||
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
|
||
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
|
||
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
|
||
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
|
||
|
||
|
||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||
names: set[str] = set()
|
||
for r in rows:
|
||
try:
|
||
names.add(str(r["name"]))
|
||
except (TypeError, KeyError, IndexError):
|
||
names.add(str(r[1]))
|
||
if col not in names:
|
||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||
|
||
|
||
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
|
||
|
||
|
||
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
|
||
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
|
||
if plan_type:
|
||
row = conn.execute(
|
||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
|
||
(plan_type,),
|
||
).fetchone()
|
||
else:
|
||
row = conn.execute(
|
||
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
|
||
).fetchone()
|
||
return int((row["c"] if row else 0) or 0)
|
||
|
||
|
||
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
|
||
cols = list(row.keys())
|
||
placeholders = ",".join(["?"] * len(cols))
|
||
conn.execute(
|
||
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
|
||
[row[c] for c in cols],
|
||
)
|
||
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
|
||
|
||
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
|
||
cols = list(row.keys())
|
||
placeholders = ",".join(["?"] * len(cols))
|
||
conn.execute(
|
||
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
|
||
[row[c] for c in cols],
|
||
)
|
||
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
|
||
|
||
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
|
||
if not fields:
|
||
return
|
||
sets = ", ".join(f"{k}=?" for k in fields)
|
||
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
|
||
|
||
|
||
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
|
||
if not fields:
|
||
return
|
||
sets = ", ".join(f"{k}=?" for k in fields)
|
||
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
|
||
|
||
|
||
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
|
||
for leg in legs or []:
|
||
if str(leg.get("status") or "").strip().lower() == "pending":
|
||
role = str(leg.get("leg_role") or "").strip()
|
||
if role:
|
||
return role
|
||
return None
|
||
|
||
|
||
def list_plans(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
status: Optional[str] = None,
|
||
plan_type: Optional[str] = None,
|
||
underlying: Optional[str] = None,
|
||
limit: int = 50,
|
||
) -> list[dict[str, Any]]:
|
||
wheres: list[str] = []
|
||
args: list[Any] = []
|
||
if status:
|
||
wheres.append("status=?")
|
||
args.append(status)
|
||
if plan_type:
|
||
wheres.append("plan_type=?")
|
||
args.append(plan_type)
|
||
if underlying:
|
||
wheres.append("underlying=?")
|
||
args.append(underlying)
|
||
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
|
||
rows = conn.execute(
|
||
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
|
||
[*args, int(limit)],
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
|
||
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
|
||
rows = conn.execute(
|
||
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
|
||
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
|
||
plan = get_plan(conn, int(plan_id))
|
||
if not plan:
|
||
return {"ok": False, "msg": "计划不存在"}
|
||
st = str(plan.get("status") or "")
|
||
if st in ("opening", "active", "partial", "watching"):
|
||
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
|
||
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
|
||
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
|
||
return {"ok": True, "deleted_id": int(plan_id)}
|
||
|
||
|
||
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
|
||
parts: list[str] = []
|
||
for leg in legs:
|
||
role = str(leg.get("leg_role") or "")
|
||
st = str(leg.get("status") or "").strip().lower()
|
||
if st == "pending":
|
||
suffix = "(待补)"
|
||
elif st in ("cancelled", "canceled"):
|
||
suffix = "(未成交)"
|
||
else:
|
||
suffix = ""
|
||
if role == "perp":
|
||
name = str(leg.get("symbol") or "永续")
|
||
parts.append(f"永续 {name}{suffix}")
|
||
else:
|
||
inst = str(leg.get("inst_id") or "")
|
||
ot = str(leg.get("opt_type") or "").upper()
|
||
strike = leg.get("strike")
|
||
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
|
||
parts.append(f"{label}{suffix}")
|
||
return " · ".join(parts) if parts else "—"
|
||
|
||
|
||
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for p in plans:
|
||
legs = get_plan_legs(conn, int(p["id"]))
|
||
row = dict(p)
|
||
row["legs"] = legs
|
||
summary = legs_contract_summary(legs)
|
||
if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
|
||
money = str(p.get("option_moneyness") or "otm")
|
||
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
|
||
parts = [f"盯盘·{money_lab}"]
|
||
try:
|
||
if p.get("strike_interval") not in (None, ""):
|
||
parts.append(f"间隔{float(p.get('strike_interval')):g}")
|
||
except (TypeError, ValueError):
|
||
pass
|
||
try:
|
||
if p.get("option_leverage") not in (None, ""):
|
||
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
|
||
except (TypeError, ValueError):
|
||
pass
|
||
summary = "·".join(parts)
|
||
row["contracts_summary"] = summary
|
||
row["missing_leg"] = missing_leg_role(legs)
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
|
||
"""返回由进行中「期期对冲」托管的期权目标位,仅供期权页只读展示。
|
||
|
||
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
|
||
否则两套监控会同时尝试平掉同一条期权腿。
|
||
"""
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
||
l.inst_id, l.opt_type
|
||
FROM hedge_plans p
|
||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||
WHERE p.plan_type = 'options_options'
|
||
AND p.status IN ('opening', 'active', 'partial')
|
||
AND l.status = 'open'
|
||
AND l.inst_id IS NOT NULL
|
||
AND l.inst_id != ''
|
||
ORDER BY p.id DESC, l.id DESC
|
||
"""
|
||
).fetchall()
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for raw in rows:
|
||
row = dict(raw)
|
||
inst_id = str(row.get("inst_id") or "")
|
||
opt_type = str(row.get("opt_type") or "").upper()
|
||
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
||
target_f = _sf(target)
|
||
if not inst_id or target_f is None or target_f <= 0 or inst_id in out:
|
||
continue
|
||
out[inst_id] = {
|
||
"plan_id": int(row["plan_id"]),
|
||
"inst_id": inst_id,
|
||
"underlying": row.get("underlying"),
|
||
"opt_type": opt_type,
|
||
"target_index": target_f,
|
||
"plan_type": "options_options",
|
||
"managed_by": "hedge_plan",
|
||
}
|
||
return out
|
||
|
||
|
||
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
|
||
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT DISTINCT l.inst_id
|
||
FROM hedge_plan_legs l
|
||
JOIN hedge_plans p ON p.id = l.plan_id
|
||
WHERE p.status IN ('opening', 'active', 'partial')
|
||
AND l.status IN ('open', 'hold_to_expiry')
|
||
AND l.inst_id IS NOT NULL
|
||
AND TRIM(l.inst_id) != ''
|
||
AND (
|
||
l.leg_role LIKE 'option%'
|
||
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
|
||
)
|
||
"""
|
||
).fetchall()
|
||
return {str(r[0]).strip() for r in rows if r and r[0]}
|
||
|
||
|
||
def _sf(v: Any) -> Optional[float]:
|
||
try:
|
||
if v is None or v == "":
|
||
return None
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
|
||
pnls: list[float] = []
|
||
timed: list[tuple[str, float]] = []
|
||
for r in rows:
|
||
pnl = _sf(r.get("realized_pnl_total"))
|
||
if pnl is None:
|
||
continue
|
||
pnls.append(pnl)
|
||
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
|
||
timed.append((t, pnl))
|
||
n = len(pnls)
|
||
if n == 0:
|
||
return {
|
||
"count": 0,
|
||
"wins": 0,
|
||
"losses": 0,
|
||
"win_rate": None,
|
||
"net_pnl": 0.0,
|
||
"avg_pnl": None,
|
||
"avg_premium": None,
|
||
"profit_factor": None,
|
||
"max_profit": None,
|
||
"max_loss": None,
|
||
"max_drawdown": None,
|
||
}
|
||
wins = [x for x in pnls if x > 0]
|
||
losses = [x for x in pnls if x < 0]
|
||
gross_win = sum(wins)
|
||
gross_loss = abs(sum(losses))
|
||
if gross_loss > 0:
|
||
profit_factor = round(gross_win / gross_loss, 4)
|
||
elif gross_win > 0:
|
||
profit_factor = None # 全胜,标无限
|
||
else:
|
||
profit_factor = 0.0
|
||
|
||
timed.sort(key=lambda x: x[0] or "")
|
||
cum = 0.0
|
||
peak = 0.0
|
||
mdd = 0.0
|
||
for _, p in timed:
|
||
cum += p
|
||
if cum > peak:
|
||
peak = cum
|
||
dd = peak - cum
|
||
if dd > mdd:
|
||
mdd = dd
|
||
|
||
premiums = [_sf(r.get("premium_total")) for r in rows]
|
||
premiums_f = [x for x in premiums if x is not None]
|
||
return {
|
||
"count": n,
|
||
"wins": len(wins),
|
||
"losses": len(losses),
|
||
"win_rate": round(len(wins) / n, 4),
|
||
"net_pnl": round(sum(pnls), 4),
|
||
"avg_pnl": round(sum(pnls) / n, 4),
|
||
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
|
||
"profit_factor": profit_factor,
|
||
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
|
||
"max_profit": round(max(pnls), 4),
|
||
"max_loss": round(min(pnls), 4),
|
||
"max_drawdown": round(mdd, 4),
|
||
}
|
||
|
||
|
||
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
|
||
reason_rows = conn.execute(
|
||
"""
|
||
SELECT plan_type, close_reason, COUNT(1) AS n,
|
||
COALESCE(SUM(realized_pnl_total), 0) AS pnl
|
||
FROM hedge_plans
|
||
WHERE status='closed'
|
||
GROUP BY plan_type, close_reason
|
||
"""
|
||
).fetchall()
|
||
closed_rows = [
|
||
dict(r)
|
||
for r in conn.execute(
|
||
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
|
||
).fetchall()
|
||
]
|
||
active = count_active_plans(conn)
|
||
overall = _metrics_from_pnls(closed_rows)
|
||
by_type = {
|
||
"perp_options": _metrics_from_pnls(
|
||
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||
),
|
||
"options_options": _metrics_from_pnls(
|
||
[r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||
),
|
||
}
|
||
# 永期止盈/止损分桶
|
||
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
|
||
by_type["perp_options"]["buckets"] = {
|
||
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
|
||
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
|
||
}
|
||
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
|
||
by_type["options_options"]["buckets"] = {
|
||
"expiry_loss": _metrics_from_pnls(
|
||
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
|
||
),
|
||
"expiry_win": _metrics_from_pnls(
|
||
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
|
||
),
|
||
}
|
||
return {
|
||
"active": active,
|
||
"closed_count": overall["count"],
|
||
"closed_pnl_total": overall["net_pnl"],
|
||
"overall": overall,
|
||
"by_type": by_type,
|
||
"by_reason": [dict(r) for r in reason_rows],
|
||
}
|