11c67b3c44
Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
7.6 KiB
Python
261 lines
7.6 KiB
Python
"""实例资金曲线采样:权益(现金+现货币+权利金+浮盈),供数据统计页使用."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import os
|
|
import threading
|
|
import time
|
|
from typing import Any, Callable, Optional
|
|
|
|
_lock = threading.Lock()
|
|
_last_sample_at = 0.0
|
|
_thread: threading.Thread | None = None
|
|
_stop = threading.Event()
|
|
|
|
|
|
def cash_sample_interval_sec() -> float:
|
|
try:
|
|
return max(30.0, float(os.getenv("INSTANCE_CASH_SAMPLE_SEC") or 60))
|
|
except (TypeError, ValueError):
|
|
return 60.0
|
|
|
|
|
|
def cash_sample_keep_days() -> int:
|
|
try:
|
|
return max(7, int(float(os.getenv("INSTANCE_CASH_SAMPLE_KEEP_DAYS") or 90)))
|
|
except (TypeError, ValueError):
|
|
return 90
|
|
|
|
|
|
def init_cash_sample_table(conn) -> None:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS instance_cash_samples (
|
|
ts_ms INTEGER PRIMARY KEY,
|
|
cash_usdt REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
try:
|
|
conn.execute("ALTER TABLE instance_cash_samples ADD COLUMN equity_usdt REAL")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _safe_float(v: Any) -> Optional[float]:
|
|
if v is None or v == "":
|
|
return None
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]:
|
|
"""稳定币现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC)."""
|
|
if not isinstance(bal, dict):
|
|
return None
|
|
parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))]
|
|
if all(x is None for x in parts):
|
|
parts = [_safe_float(bal.get("trading_usdc")), _safe_float(bal.get("funding_usdc"))]
|
|
known = [x for x in parts if x is not None]
|
|
if not known:
|
|
return None
|
|
return round(sum(known), 4)
|
|
|
|
|
|
def spot_coin_usdt_from_balances(
|
|
bal: dict[str, Any] | None,
|
|
*,
|
|
btc_px: float | None = None,
|
|
eth_px: float | None = None,
|
|
) -> float:
|
|
"""交易+资金账户 BTC/ETH 现货按指数折 U(缓冲买币占用)."""
|
|
if not isinstance(bal, dict):
|
|
return 0.0
|
|
total = 0.0
|
|
btc = (_safe_float(bal.get("trading_btc")) or 0.0) + (
|
|
_safe_float(bal.get("funding_btc")) or 0.0
|
|
)
|
|
eth = (_safe_float(bal.get("trading_eth")) or 0.0) + (
|
|
_safe_float(bal.get("funding_eth")) or 0.0
|
|
)
|
|
if btc > 0 and btc_px is not None and btc_px > 0:
|
|
total += btc * float(btc_px)
|
|
if eth > 0 and eth_px is not None and eth_px > 0:
|
|
total += eth * float(eth_px)
|
|
return round(total, 4)
|
|
|
|
|
|
def option_premium_and_upl_usdt(positions: list[dict[str, Any]] | None) -> tuple[float, float]:
|
|
"""持仓权利金U + 浮盈U(权利金+浮盈≈期权市值)."""
|
|
prem = 0.0
|
|
upl = 0.0
|
|
if not positions:
|
|
return 0.0, 0.0
|
|
from lib.options.options_positions_lib import (
|
|
display_pnl_usdt_from_option_row,
|
|
premium_paid_usdt_from_option_row,
|
|
)
|
|
|
|
for p in positions:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
try:
|
|
pu = premium_paid_usdt_from_option_row(p)
|
|
except Exception:
|
|
pu = None
|
|
if pu is not None:
|
|
prem += float(pu)
|
|
try:
|
|
du = display_pnl_usdt_from_option_row(p)
|
|
except Exception:
|
|
du = None
|
|
if du is not None:
|
|
upl += float(du)
|
|
return round(prem, 4), round(upl, 4)
|
|
|
|
|
|
def equity_usdt_from_snapshot(
|
|
bal: dict[str, Any] | None,
|
|
positions: list[dict[str, Any]] | None = None,
|
|
*,
|
|
btc_px: float | None = None,
|
|
eth_px: float | None = None,
|
|
) -> Optional[float]:
|
|
"""权益 = 稳定币现金 + 现货币折U + 权利金U + 浮盈U."""
|
|
cash = cash_usdt_from_balances(bal)
|
|
spot = spot_coin_usdt_from_balances(bal, btc_px=btc_px, eth_px=eth_px)
|
|
prem, upl = option_premium_and_upl_usdt(positions)
|
|
if cash is None and spot <= 0 and prem == 0 and upl == 0:
|
|
return None
|
|
return round(float(cash or 0.0) + float(spot) + float(prem) + float(upl), 4)
|
|
|
|
|
|
def record_cash_sample(
|
|
conn,
|
|
cash_usdt: float,
|
|
*,
|
|
equity_usdt: float | None = None,
|
|
ts_ms: int | None = None,
|
|
) -> None:
|
|
init_cash_sample_table(conn)
|
|
t = int(ts_ms if ts_ms is not None else time.time() * 1000)
|
|
eq = float(equity_usdt) if equity_usdt is not None else float(cash_usdt)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO instance_cash_samples(ts_ms, cash_usdt, equity_usdt)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(ts_ms) DO UPDATE SET
|
|
cash_usdt=excluded.cash_usdt,
|
|
equity_usdt=excluded.equity_usdt
|
|
""",
|
|
(t, float(cash_usdt), eq),
|
|
)
|
|
keep_ms = cash_sample_keep_days() * 86400 * 1000
|
|
cutoff = t - keep_ms
|
|
conn.execute("DELETE FROM instance_cash_samples WHERE ts_ms < ?", (cutoff,))
|
|
|
|
|
|
def _range_start_ms(range_key: str) -> tuple[str, int]:
|
|
rk = (range_key or "7d").strip().lower()
|
|
now_ms = int(time.time() * 1000)
|
|
if rk in ("today", "1d"):
|
|
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
return "today", int(start.timestamp() * 1000)
|
|
if rk in ("30d", "30"):
|
|
return "30d", now_ms - 30 * 86400 * 1000
|
|
if rk in ("all", "0"):
|
|
return "all", 0
|
|
return "7d", now_ms - 7 * 86400 * 1000
|
|
|
|
|
|
def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
|
|
rk, start_ms = _range_start_ms(range_key)
|
|
now_ms = int(time.time() * 1000)
|
|
init_cash_sample_table(conn)
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT ts_ms, cash_usdt, equity_usdt
|
|
FROM instance_cash_samples
|
|
WHERE ts_ms >= ?
|
|
ORDER BY ts_ms ASC
|
|
""",
|
|
(start_ms,),
|
|
).fetchall()
|
|
points = []
|
|
for r in rows:
|
|
cash = float(r["cash_usdt"])
|
|
eq = r["equity_usdt"]
|
|
v = float(eq) if eq is not None else cash
|
|
points.append({"t": int(r["ts_ms"]), "v": v, "cash": cash})
|
|
return {
|
|
"ok": True,
|
|
"range": rk,
|
|
"metric": "equity",
|
|
"points": points,
|
|
"start_ms": start_ms,
|
|
"end_ms": now_ms,
|
|
"latest": points[-1]["v"] if points else None,
|
|
}
|
|
|
|
|
|
def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool:
|
|
"""拉取快照并写入采样.
|
|
|
|
fetch_snapshot 可返回:
|
|
- float: 视为权益(兼容旧现金回调)
|
|
- dict: {cash_usdt, equity_usdt}
|
|
"""
|
|
global _last_sample_at
|
|
try:
|
|
snap = fetch_snapshot()
|
|
except Exception:
|
|
return False
|
|
cash: float | None
|
|
equity: float | None
|
|
if isinstance(snap, dict):
|
|
cash = _safe_float(snap.get("cash_usdt"))
|
|
equity = _safe_float(snap.get("equity_usdt"))
|
|
else:
|
|
cash = _safe_float(snap)
|
|
equity = cash
|
|
if equity is None and cash is None:
|
|
return False
|
|
if cash is None:
|
|
cash = float(equity or 0.0)
|
|
if equity is None:
|
|
equity = float(cash)
|
|
with _lock:
|
|
conn = get_db()
|
|
try:
|
|
record_cash_sample(conn, float(cash), equity_usdt=float(equity))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
_last_sample_at = time.time()
|
|
return True
|
|
|
|
|
|
def start_cash_sample_loop(get_db: Callable, fetch_snapshot: Callable) -> None:
|
|
global _thread
|
|
if _thread and _thread.is_alive():
|
|
return
|
|
_stop.clear()
|
|
|
|
def _loop() -> None:
|
|
try:
|
|
sample_once(get_db, fetch_snapshot)
|
|
except Exception:
|
|
pass
|
|
while not _stop.is_set():
|
|
if _stop.wait(cash_sample_interval_sec()):
|
|
break
|
|
try:
|
|
sample_once(get_db, fetch_snapshot)
|
|
except Exception:
|
|
pass
|
|
|
|
_thread = threading.Thread(target=_loop, daemon=True, name="instance-cash-sample")
|
|
_thread.start()
|