8ecc70a61c
Replace single S* with up/down targets across UI, preview, persist, monitor, and alerts so ranging breakouts can close the winner either way. Co-authored-by: Cursor <cursoragent@cursor.com>
333 lines
11 KiB
Python
333 lines
11 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")
|
|
|
|
|
|
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}")
|
|
|
|
|
|
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
|
|
if plan_type:
|
|
row = conn.execute(
|
|
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?",
|
|
(plan_type,),
|
|
).fetchone()
|
|
else:
|
|
row = conn.execute(
|
|
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')"
|
|
).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 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"):
|
|
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 "")
|
|
if role == "perp":
|
|
name = str(leg.get("symbol") or "永续")
|
|
parts.append(f"永续 {name}")
|
|
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(label)
|
|
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
|
|
row["contracts_summary"] = legs_contract_summary(legs)
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
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],
|
|
}
|