Initial standalone crypto_okx with one-click deploy.

Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-13 20:00:59 +08:00
commit a1abe159fa
238 changed files with 69214 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""crypto_monitor shared libraries."""
+1
View File
@@ -0,0 +1 @@
"""实例账户流水(交易所资金/交易账户账单)."""
+186
View File
@@ -0,0 +1,186 @@
"""账户流水 SQLite 缓存."""
from __future__ import annotations
import time
from typing import Any, Optional
from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS
def ensure_account_ledger_tables(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS account_ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account TEXT NOT NULL,
ccy TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL,
kind TEXT,
raw_type TEXT,
symbol TEXT,
ref_id TEXT NOT NULL,
ts_ms INTEGER NOT NULL,
note TEXT,
synced_at REAL,
UNIQUE(account, ref_id, ccy, ts_ms)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts "
"ON account_ledger_entries(account, ts_ms DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS account_ledger_meta (
key TEXT PRIMARY KEY,
value TEXT
)
"""
)
conn.commit()
def meta_get(conn, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM account_ledger_meta WHERE key=?", (key,)
).fetchone()
if not row:
return default
try:
return str(row[0] if not hasattr(row, "keys") else row["value"])
except Exception:
return default
def meta_set(conn, key: str, value: str) -> None:
conn.execute(
"INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, str(value)),
)
def upsert_entries(conn, rows: list[dict[str, Any]]) -> int:
if not rows:
return 0
now = time.time()
n = 0
for r in rows:
try:
conn.execute(
"""
INSERT INTO account_ledger_entries(
account, ccy, amount, balance_after, kind, raw_type,
symbol, ref_id, ts_ms, note, synced_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET
amount=excluded.amount,
balance_after=excluded.balance_after,
kind=excluded.kind,
raw_type=excluded.raw_type,
symbol=excluded.symbol,
note=excluded.note,
synced_at=excluded.synced_at
""",
(
r["account"],
r["ccy"],
float(r["amount"]),
r.get("balance_after"),
r.get("kind") or "other",
r.get("raw_type") or "",
r.get("symbol") or "",
r["ref_id"],
int(r["ts_ms"]),
r.get("note") or "",
now,
),
)
n += 1
except Exception:
continue
conn.commit()
return n
def query_entries(
conn,
*,
account: str,
start_ms: int,
end_ms: int,
page: int = 1,
page_size: int = PAGE_SIZE,
currencies: Optional[list[str]] = None,
) -> dict[str, Any]:
acc = (account or "").strip().lower()
if acc not in VALID_ACCOUNTS:
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0}
page = max(1, int(page or 1))
page_size = max(1, min(50, int(page_size or PAGE_SIZE)))
start_ms = int(start_ms)
end_ms = int(end_ms)
params: list[Any] = [acc, start_ms, end_ms]
ccy_sql = ""
if currencies:
ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()]
if ccy_list:
placeholders = ",".join("?" for _ in ccy_list)
ccy_sql = f" AND ccy IN ({placeholders})"
params.extend(ccy_list)
total = conn.execute(
f"SELECT COUNT(*) FROM account_ledger_entries "
f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}",
params,
).fetchone()[0]
total = int(total or 0)
pages = (total + page_size - 1) // page_size if total else 0
if pages and page > pages:
page = pages
offset = (page - 1) * page_size
rows = conn.execute(
f"""
SELECT account, ccy, amount, balance_after, kind, raw_type, symbol,
ref_id, ts_ms, note
FROM account_ledger_entries
WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}
ORDER BY ts_ms DESC, id DESC
LIMIT ? OFFSET ?
""",
params + [page_size, offset],
).fetchall()
items = []
for r in rows:
if hasattr(r, "keys"):
d = {k: r[k] for k in r.keys()}
else:
d = {
"account": r[0],
"ccy": r[1],
"amount": r[2],
"balance_after": r[3],
"kind": r[4],
"raw_type": r[5],
"symbol": r[6],
"ref_id": r[7],
"ts_ms": r[8],
"note": r[9],
}
from lib.account_ledger.account_ledger_normalize import kind_label_zh
d["kind_label"] = kind_label_zh(d.get("kind") or "")
items.append(d)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
def prune_older_than(conn, min_ts_ms: int) -> None:
conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),))
conn.commit()
@@ -0,0 +1,192 @@
"""账户流水:交易所原始记录 → 统一行模型."""
from __future__ import annotations
from typing import Any, Optional
ACCOUNT_FUNDING = "funding"
ACCOUNT_TRADING = "trading"
VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING})
PAGE_SIZE = 10
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 _safe_int(v: Any) -> Optional[int]:
if v is None or v == "":
return None
try:
n = float(v)
if n > 1e12:
return int(n)
if n > 1e9:
return int(n)
return int(n)
except (TypeError, ValueError):
return None
def _ts_ms(v: Any) -> Optional[int]:
if v is None or v == "":
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if n > 1e12:
return int(n)
if n > 1e10:
return int(n)
return int(n * 1000.0)
def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str:
t = (raw_type or "").strip().lower()
if not t:
return "other"
if "deposit" in t or t in ("1", "funding_deposit"):
return "deposit"
if "withdraw" in t or "withdrawal" in t:
return "withdraw"
if "transfer" in t or "dnw" in t or t in ("2", "18", "19"):
if amount is not None and amount < 0:
return "transfer_out"
if amount is not None and amount > 0:
return "transfer_in"
return "transfer"
if "funding" in t and "fee" in t:
return "funding_fee"
if t in ("funding_fee", "fundingfee", "8"):
return "funding_fee"
if "commission" in t or "fee" in t or t in ("commission", "5", "fee"):
return "commission"
if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"):
return "realized_pnl"
if "liqui" in t:
return "liquidate"
return "other"
def kind_label_zh(kind: str) -> str:
return {
"deposit": "充值",
"withdraw": "提现",
"transfer": "划转",
"transfer_in": "划入",
"transfer_out": "划出",
"realized_pnl": "已实现盈亏",
"funding_fee": "资金费",
"commission": "手续费",
"liquidate": "强平",
"other": "其他",
}.get((kind or "").strip().lower(), "其他")
def make_ref_id(*parts: Any) -> str:
bits = []
for p in parts:
if p is None:
continue
s = str(p).strip()
if s:
bits.append(s)
return "|".join(bits) if bits else ""
def normalize_row(
*,
account: str,
ccy: str,
amount: Any,
ts_ms: Any,
ref_id: str,
raw_type: str = "",
balance_after: Any = None,
symbol: str = "",
note: str = "",
kind: str = "",
) -> Optional[dict[str, Any]]:
acc = (account or "").strip().lower()
if acc not in VALID_ACCOUNTS:
return None
ccy_u = (ccy or "").strip().upper()
if not ccy_u:
return None
amt = _safe_float(amount)
if amt is None:
return None
ts = _ts_ms(ts_ms)
if ts is None or ts <= 0:
return None
rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type)
k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt)
bal = _safe_float(balance_after)
return {
"account": acc,
"ccy": ccy_u,
"amount": amt,
"balance_after": bal,
"kind": k,
"kind_label": kind_label_zh(k),
"raw_type": (raw_type or "").strip()[:120],
"symbol": (symbol or "").strip()[:80],
"ref_id": rid[:200],
"ts_ms": int(ts),
"note": (note or "").strip()[:240],
}
def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: str) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
if amount is None:
amount = entry.get("change")
if amount is None:
amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount")
ts = entry.get("timestamp") or entry.get("datetime")
if ts is None:
ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate")
ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT"
raw_type = (
entry.get("type")
or entry.get("status")
or info.get("type")
or info.get("incomeType")
or info.get("change_type")
or info.get("subType")
or ""
)
if isinstance(raw_type, (int, float)):
raw_type = str(raw_type)
balance_after = entry.get("balance") or info.get("bal") or info.get("balance")
symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or ""
ref = (
entry.get("id")
or info.get("billId")
or info.get("tranId")
or info.get("id")
or info.get("trade_id")
or ""
)
note = entry.get("description") or info.get("info") or info.get("text") or ""
return normalize_row(
account=account,
ccy=str(ccy),
amount=amount,
ts_ms=ts,
ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type),
raw_type=str(raw_type),
balance_after=balance_after,
symbol=str(symbol or ""),
note=str(note or ""),
)
@@ -0,0 +1,208 @@
"""三所统一:账户流水路由 + 后台同步安装."""
from __future__ import annotations
import os
from typing import Any, Callable
from flask import Flask, Response, jsonify, request, session, stream_with_context
from jinja2 import ChoiceLoader, FileSystemLoader
from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
PAGE_SIZE,
VALID_ACCOUNTS,
)
from lib.account_ledger.account_ledger_sync import account_ledger_store
from lib.common.history_window_lib import resolve_list_window
def attach_account_ledger_templates(app: Flask, repo_root: str) -> None:
tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "templates")
if not os.path.isdir(tpl_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(tpl_dir)]
if existing is not None:
if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders
else:
loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders)
def _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable:
ex_key = (exchange_key or "").strip().lower()
exchange = getattr(app_module, "exchange", None)
ensure_markets = getattr(app_module, "ensure_markets_loaded", None)
def _fetch(*, start_ms: int, end_ms: int):
if exchange is None:
return [], ["exchange missing"]
if ex_key == "okx":
from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger
return fetch_okx_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
if ex_key == "binance":
from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger
return fetch_binance_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger
return fetch_gate_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
return _fetch
def _currencies_for_exchange(exchange_key: str) -> list[str]:
if (exchange_key or "").strip().lower() == "okx":
return ["USDT", "USDC"]
return ["USDT"]
def install_account_ledger(
app: Flask,
repo_root: str,
app_module: Any,
*,
exchange_key: str = "",
) -> None:
ex = (exchange_key or "").strip().lower()
if not ex:
mod_name = getattr(app_module, "__name__", "") or ""
if "okx" in mod_name.lower():
ex = "okx"
elif "binance" in mod_name.lower():
ex = "binance"
else:
ex = "gate"
exchange_key = ex
attach_account_ledger_templates(app, repo_root)
get_db = app_module.get_db
login_required = app_module.login_required
# 初始化表
try:
conn = get_db()
try:
ensure_account_ledger_tables(conn)
finally:
conn.close()
except Exception:
pass
account_ledger_store.configure(
get_db=get_db,
fetch_fn=_build_fetch_fn(exchange_key, app_module),
exchange_key=str(exchange_key),
)
account_ledger_store.start()
app.extensions["account_ledger_exchange"] = str(exchange_key).lower()
def _list_window():
resolve = getattr(app_module, "_list_window_from_request", None)
if callable(resolve):
return resolve()
return resolve_list_window(request.args, session)
@app.route("/api/account_ledger")
@login_required
def api_account_ledger():
account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower()
if account not in VALID_ACCOUNTS:
account = ACCOUNT_FUNDING
try:
page = int(request.args.get("page") or 1)
except Exception:
page = 1
win = _list_window()
start_ms = int(win.get("start_ms") or 0)
end_ms = int(win.get("end_ms") or 0)
ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "")
conn = get_db()
try:
ensure_account_ledger_tables(conn)
data = query_entries(
conn,
account=account,
start_ms=start_ms,
end_ms=end_ms,
page=page,
page_size=PAGE_SIZE,
currencies=ccys,
)
finally:
conn.close()
st = account_ledger_store.status_dict()
return jsonify(
{
"ok": True,
"account": account,
"window": {
"preset": win.get("preset"),
"label": win.get("label"),
"start_ms": start_ms,
"end_ms": end_ms,
},
"currencies": ccys,
**data,
**st,
}
)
@app.route("/api/account_ledger/stream")
@login_required
def api_account_ledger_stream():
return Response(
stream_with_context(account_ledger_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/account_ledger/refresh", methods=["POST"])
@login_required
def api_account_ledger_refresh():
win = _list_window()
body = request.get_json(silent=True) or {}
start_ms = body.get("start_ms", win.get("start_ms"))
end_ms = body.get("end_ms", win.get("end_ms"))
try:
start_i = int(start_ms) if start_ms is not None else None
end_i = int(end_ms) if end_ms is not None else None
except Exception:
start_i, end_i = None, None
result = account_ledger_store.sync_once(
reason="manual", start_ms=start_i, end_ms=end_i
)
return jsonify(result)
@app.route("/account_ledger")
@login_required
def account_ledger_page():
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
redir = redirect_to_embed_shell_if_enabled("account_ledger")
if redir is not None:
return redir
return app_module.render_main_page("account_ledger")
+252
View File
@@ -0,0 +1,252 @@
"""账户流水:后台定时拉取交易所 + SSE 版本推送."""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Callable, Iterator
from datetime import datetime, timezone
from typing import Any, Optional
from lib.account_ledger.account_ledger_db import (
ensure_account_ledger_tables,
meta_get,
meta_set,
prune_older_than,
upsert_entries,
)
ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120"))
ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90"))
ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25"))
class AccountLedgerStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self.version = 0
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._syncing = False
self._get_db: Optional[Callable] = None
self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None
self._exchange_key = ""
self.last_sync_at: Optional[float] = None
self.last_error: str = ""
self.last_upserted: int = 0
self._last_manual_at: float = 0.0
self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30"))
def configure(
self,
*,
get_db: Callable,
fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]],
exchange_key: str,
) -> None:
self._get_db = get_db
self._fetch_fn = fetch_fn
self._exchange_key = (exchange_key or "").strip().lower()
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
if not self._get_db or not self._fetch_fn:
return
self._stop.clear()
self._thread = threading.Thread(
target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}"
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._broadcast(close=True)
def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]:
now = datetime.now(timezone.utc)
end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000)
floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000)
if start_ms is not None:
start = max(int(start_ms), floor)
else:
start = floor
if start > end:
start, end = end, start
return start, end
def sync_once(
self,
*,
reason: str = "poll",
start_ms: Optional[int] = None,
end_ms: Optional[int] = None,
) -> dict[str, Any]:
if not self._get_db or not self._fetch_fn:
return {"ok": False, "msg": "未配置"}
with self._lock:
if self._syncing:
return {"ok": True, "busy": True, "ledger_version": self.version}
if reason == "manual":
gap = time.time() - self._last_manual_at
if gap < self._manual_cooldown_sec:
wait = int(self._manual_cooldown_sec - gap) + 1
return {
"ok": False,
"msg": f"同步过于频繁,请 {wait}s 后再试",
"ledger_version": self.version,
}
self._syncing = True
try:
start, end = self.lookback_bounds_ms(start_ms, end_ms)
rows, errors = self._fetch_fn(start_ms=start, end_ms=end)
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
n = upsert_entries(conn, rows or [])
# 保留略宽于 lookback 的缓存
prune_ms = int(
(datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400)
* 1000
)
prune_older_than(conn, prune_ms)
self.last_sync_at = time.time()
self.last_upserted = n
self.last_error = "; ".join(errors[:3]) if errors else ""
meta_set(conn, "last_sync_at", str(self.last_sync_at))
meta_set(conn, "last_error", self.last_error)
meta_set(conn, "last_upserted", str(n))
conn.commit()
finally:
try:
conn.close()
except Exception:
pass
if reason == "manual":
self._last_manual_at = time.time()
ver = self.bump(reason)
return {
"ok": True,
"ledger_version": ver,
"upserted": n,
"errors": errors,
"start_ms": start,
"end_ms": end,
}
except Exception as e:
self.last_error = str(e)
try:
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
meta_set(conn, "last_error", self.last_error)
conn.commit()
finally:
conn.close()
except Exception:
pass
return {"ok": False, "msg": str(e), "ledger_version": self.version}
finally:
with self._lock:
self._syncing = False
def bump(self, reason: str = "poll") -> int:
with self._lock:
self.version += 1
ver = self.version
payload = json.dumps(
{"ledger_version": ver, "reason": reason, "exchange": self._exchange_key},
ensure_ascii=False,
)
self._broadcast(payload)
return ver
def status_dict(self) -> dict[str, Any]:
last_at = self.last_sync_at
if last_at is None and self._get_db:
try:
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
raw = meta_get(conn, "last_sync_at", "")
if raw:
last_at = float(raw)
self.last_error = meta_get(conn, "last_error", self.last_error)
finally:
conn.close()
except Exception:
pass
return {
"ledger_version": self.version,
"poll_sec": ACCOUNT_LEDGER_POLL_SEC,
"lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS,
"last_sync_at": last_at,
"last_error": self.last_error,
"last_upserted": self.last_upserted,
"exchange": self._exchange_key,
}
def _loop(self) -> None:
# 启动后稍等再拉,避免和启动高峰撞车
if self._stop.wait(3):
return
while not self._stop.is_set():
try:
self.sync_once(reason="poll")
except Exception:
pass
if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC):
break
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def _subscribe(self) -> queue.Queue[str | None]:
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
with self._lock:
self._subscribers.append(q)
return q
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q = self._subscribe()
try:
yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n"
last_hb = time.time()
while not self._stop.is_set():
try:
item = q.get(timeout=1.0)
except queue.Empty:
item = "timeout"
if item is None:
break
if item != "timeout":
yield f"event: ledger\ndata: {item}\n\n"
last_hb = time.time()
elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC:
yield ": heartbeat\n\n"
last_hb = time.time()
finally:
self._unsubscribe(q)
account_ledger_store = AccountLedgerStore()
@@ -0,0 +1,72 @@
{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #}
<div class="card full account-ledger-card" id="account-ledger-root" data-account-ledger="1">
<div class="account-ledger-head">
<div>
<h2 style="margin-bottom:4px">账户流水</h2>
<p class="muted account-ledger-desc">拉取交易所资金账户与交易账户账单 · 时间跟随顶栏 UTC 预设 · 约 2 分钟自动同步</p>
</div>
<div class="account-ledger-head-actions">
<span class="muted" id="account-ledger-sync"></span>
<button type="button" class="btn-sm" id="account-ledger-refresh">立即同步</button>
</div>
</div>
<div class="account-ledger-tabs" role="tablist">
<button type="button" class="account-ledger-tab active" data-ledger-account="funding" role="tab" aria-selected="true">资金账户</button>
<button type="button" class="account-ledger-tab" data-ledger-account="trading" role="tab" aria-selected="false">交易账户</button>
</div>
<p class="muted account-ledger-status" id="account-ledger-status"></p>
<div class="account-ledger-table-wrap panel-scroll">
<table class="account-ledger-table" id="account-ledger-table">
<thead>
<tr>
<th>时间(北京)</th>
<th>币种</th>
<th>类型</th>
<th>变动</th>
<th>余额</th>
<th>合约/备注</th>
</tr>
</thead>
<tbody id="account-ledger-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="account-ledger-pager" id="account-ledger-pager">
<button type="button" class="btn-sm" id="account-ledger-prev" disabled>上一页</button>
<span class="muted" id="account-ledger-page-info"></span>
<button type="button" class="btn-sm" id="account-ledger-next" disabled>下一页</button>
</div>
</div>
<style>
.account-ledger-card { grid-column: 1 / -1; }
.account-ledger-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.account-ledger-head-actions { display: flex; align-items: center; gap: 10px; }
.account-ledger-tabs {
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
}
.account-ledger-tab {
border: 1px solid rgba(140,160,200,.35);
background: transparent; color: #c5cbe0;
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: .9rem;
}
.account-ledger-tab.active {
background: #1f3a5a; border-color: #3d6f9c; color: #e8f1ff;
}
.account-ledger-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.account-ledger-table th, .account-ledger-table td {
padding: 8px 10px; border-bottom: 1px solid rgba(120,130,160,.2); text-align: left;
}
.account-ledger-table th { color: #9aa3bd; font-weight: 600; }
.account-ledger-amt-pos { color: #3ecf8e; }
.account-ledger-amt-neg { color: #f07178; }
.account-ledger-pager {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
margin-top: 12px;
}
.account-ledger-status { min-height: 1.2em; margin: 0 0 8px; }
.account-ledger-table-wrap { max-height: min(60vh, 560px); overflow: auto; }
</style>
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+130
View File
@@ -0,0 +1,130 @@
"""
每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额.
- 交易账户 < 目标:从资金账户划入差额
- 交易账户 > 目标:将多余划回资金账户
- 有 active 持仓:不划转,写账簿并企业微信说明
"""
from __future__ import annotations
from typing import Any, Callable
def run_auto_transfer_once_per_day(
*,
enabled: bool,
bj_hour: int,
target_amount: float,
from_account: str,
to_account: str,
funds_decimals: int,
get_db: Callable[[], Any],
get_active_position_count: Callable[[Any], int],
get_account_usdt_total: Callable[[str], float | None],
execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]],
send_wechat_msg: Callable[[str], None],
utc_now_dt: Callable[[], Any],
app_tz: Any,
utc_calendar_date_str: Callable[[], str],
app_now_str: Callable[[], str],
min_transfer: float = 0.01,
) -> None:
if not enabled:
return
utc_dt = utc_now_dt()
bj = utc_dt.astimezone(app_tz)
if bj.hour != bj_hour:
return
transfer_day = utc_calendar_date_str()
conn = get_db()
exists = conn.execute(
"SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?",
("auto_daily", transfer_day),
).fetchone()
if exists:
conn.close()
return
def _log(
amount: float,
fr: str,
to: str,
status: str,
message: str,
*,
commit_close: bool = True,
) -> None:
conn.execute(
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
("auto_daily", transfer_day, amount, fr, to, status, message[:500]),
)
conn.commit()
if commit_close:
conn.close()
active = get_active_position_count(conn)
if active > 0:
msg = f"持仓中({active}笔),本次资金无划转"
_log(0, from_account, to_account, "skipped", msg)
send_wechat_msg(
f"自动划转:{msg}\n"
f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
target = round(float(target_amount), funds_decimals)
trade_bal = get_account_usdt_total(to_account)
if trade_bal is None:
_log(
0,
from_account,
to_account,
"failed",
f"读取{to_account}账户USDT失败",
)
return
trade = round(float(trade_bal), funds_decimals)
diff = round(target - trade, funds_decimals)
if abs(diff) < min_transfer:
_log(
0,
from_account,
to_account,
"skipped",
f"{to_account}账户已为{trade}U(目标{target}U)",
)
return
if diff > 0:
fr, to, amount = from_account, to_account, diff
action = "划入"
else:
fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals)
action = "划出"
from_bal = get_account_usdt_total(fr)
if from_bal is not None and round(float(from_bal), funds_decimals) < amount:
cur = round(float(from_bal), funds_decimals)
_log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U")
send_wechat_msg(
f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}{to_account}目标{target}U)\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
ok, msg, _ = execute_transfer_usdt(amount, fr, to)
_log(amount, fr, to, "success" if ok else "failed", msg)
if ok:
send_wechat_msg(
f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
else:
send_wechat_msg(
f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
+16
View File
@@ -0,0 +1,16 @@
"""关闭 Flask/Werkzeug 开发服务器 access log 刷屏(避免灌满 PM2 error 日志)."""
from __future__ import annotations
import logging
def silence_werkzeug_access_log() -> None:
"""仅抑制 request access 行;WARNING/ERROR 仍可读."""
log = logging.getLogger("werkzeug")
log.setLevel(logging.WARNING)
# 部分环境会挂 StreamHandler 到 stderr;抬高阈值即可
for h in list(log.handlers):
try:
h.setLevel(logging.WARNING)
except Exception:
pass
+51
View File
@@ -0,0 +1,51 @@
"""防重复提交:Flask session 短窗口去重(下单 / 关键位等)."""
from __future__ import annotations
import time
from typing import Any, Optional
DEFAULT_SUBMIT_GUARD_TTL = 90.0
def _prune_locks(locks: dict, now: float) -> dict:
return {k: float(v) for k, v in (locks or {}).items() if float(v) > now}
def check_duplicate_submit(
session: Any,
scope: str,
*,
ttl: float = DEFAULT_SUBMIT_GUARD_TTL,
) -> Optional[str]:
"""
同一 scope 在 ttl 秒内仅允许通过一次.
返回提示文案表示应拒绝;返回 None 表示可继续处理.
"""
scope = (scope or "").strip()
if not scope:
return None
now = time.time()
locks = _prune_locks(session.get("_form_submit_guard") or {}, now)
if scope in locks:
return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)"
locks[scope] = now + float(ttl)
session["_form_submit_guard"] = locks
try:
session.modified = True
except Exception:
pass
return None
def submit_scope_add_order(symbol: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
d = (direction or "").strip().lower()
return f"add_order:{sym}:{d}"
def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
mt = (monitor_type or "").strip()
d = (direction or "").strip().lower() or "watch"
return f"add_key:{sym}:{mt}:{d}"
+187
View File
@@ -0,0 +1,187 @@
"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用)."""
from datetime import datetime, timedelta, timezone
PRESET_UTC_TODAY = "utc_today"
PRESET_UTC_LAST24H = "utc_last24h"
PRESET_UTC_LAST7D = "utc_last7d"
PRESET_UTC_THIS_MONTH = "utc_this_month"
PRESET_UTC_LAST3M = "utc_last3m"
PRESET_UTC_LAST6M = "utc_last6m"
PRESET_ALL = "all"
PRESET_CUSTOM = "custom"
PRESET_DEFAULT = PRESET_UTC_THIS_MONTH
def utc_now():
return datetime.now(timezone.utc)
def utc_today_bounds(now=None):
now = now or utc_now()
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
return start, now
def resolve_window(query_mapping, default_preset=PRESET_DEFAULT):
"""
从 ?win_preset= & from_utc= & to_utc= 解析窗口.
返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms
"""
preset = (query_mapping.get("win_preset") or default_preset or PRESET_DEFAULT).strip().lower()
now = utc_now()
if preset == PRESET_UTC_LAST24H:
start = now - timedelta(hours=24)
end = now
label = "近24小时(UTC)"
elif preset == PRESET_UTC_LAST7D:
start = now - timedelta(days=7)
end = now
label = "近7天(UTC)"
elif preset == PRESET_UTC_THIS_MONTH:
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = now
label = f"本月 {start.strftime('%Y-%m')}"
elif preset == PRESET_UTC_LAST3M:
start = now - timedelta(days=90)
end = now
label = "近3月"
elif preset == PRESET_UTC_LAST6M:
start = now - timedelta(days=180)
end = now
label = "近6月"
elif preset == PRESET_ALL:
start = datetime(2000, 1, 1, tzinfo=timezone.utc)
end = now
label = "全部"
elif preset == PRESET_CUSTOM:
start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0]
end = _parse_utc_input(query_mapping.get("to_utc")) or now
if end < start:
start, end = end, start
label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC"
elif preset == PRESET_UTC_TODAY:
start, end = utc_today_bounds(now)
label = f"UTC当日 {start.strftime('%Y-%m-%d')}"
else:
return resolve_window(
{**(query_mapping or {}), "win_preset": default_preset},
default_preset=default_preset,
)
return {
"preset": preset,
"start_utc": start,
"end_utc": end,
"label": label,
"start_ms": int(start.timestamp() * 1000),
"end_ms": int(end.timestamp() * 1000),
}
def _parse_utc_input(raw):
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return None
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(s[:n], fmt)
return dt.replace(tzinfo=timezone.utc)
except Exception:
continue
return None
def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz):
"""DB 存北京时间字符串时,用于 SQLite 字符串范围比较."""
start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S")
return start_bj, end_bj
def utc_window_to_utc_sql_strings(start_utc, end_utc):
"""SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较."""
return (
start_utc.strftime("%Y-%m-%d %H:%M:%S"),
end_utc.strftime("%Y-%m-%d %H:%M:%S"),
)
def normalize_bj_datetime_storage(raw):
"""表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间)."""
s = (raw or "").strip().replace("T", " ").replace("Z", "").strip()
if not s:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return s
def sql_list_time_field(*columns):
"""
SQLite 列表时间窗比较表达式.
journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界.
单列时不用 COALESCE(SQLite 要求 COALESCE 至少 2 个参数).
"""
cols = [c for c in columns if c]
if not cols:
raise ValueError("sql_list_time_field requires at least one column")
if len(cols) == 1:
return f"REPLACE({cols[0]}, 'T', ' ')"
return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')"
SESSION_KEY_LIST_WIN = "list_win_filter"
def query_mapping_from_session(session_store):
"""从 Flask session 恢复 win_preset / from_utc / to_utc."""
if not session_store:
return {}
block = session_store.get(SESSION_KEY_LIST_WIN)
if not isinstance(block, dict):
return {}
preset = (block.get("preset") or "").strip()
if not preset:
return {}
return {
"win_preset": preset,
"from_utc": (block.get("from_utc") or "").strip(),
"to_utc": (block.get("to_utc") or "").strip(),
}
def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_DEFAULT):
"""
URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设.
"""
qm = query_mapping or {}
preset_in_q = (qm.get("win_preset") or "").strip()
if preset_in_q:
win = resolve_window(qm, default_preset=default_preset)
if session_store is not None:
session_store[SESSION_KEY_LIST_WIN] = {
"preset": win["preset"],
"from_utc": (qm.get("from_utc") or "").strip(),
"to_utc": (qm.get("to_utc") or "").strip(),
}
return win
stored = query_mapping_from_session(session_store)
if stored.get("win_preset"):
return resolve_window(stored, default_preset=default_preset)
return resolve_window(qm, default_preset=default_preset)
def list_window_redirect_query(session_store):
"""复盘/表单 POST 后重定向时附带列表筛选 query."""
from urllib.parse import urlencode
stored = query_mapping_from_session(session_store)
if not stored.get("win_preset"):
return ""
params = {k: v for k, v in stored.items() if v}
return urlencode(params)
+128
View File
@@ -0,0 +1,128 @@
"""Markdown → HTML for system guide / options docs."""
from __future__ import annotations
import re
def render_markdown_html(md_text: str) -> str:
try:
import markdown # type: ignore
return markdown.markdown(
md_text,
extensions=["tables", "fenced_code", "nl2br", "sane_lists"],
)
except Exception:
return _simple_md_html(md_text)
def _simple_md_html(md_text: str) -> str:
from html import escape
lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
out: list[str] = []
i = 0
in_code = False
code_buf: list[str] = []
list_buf: list[str] = []
list_ordered = False
def flush_list() -> None:
nonlocal list_buf, list_ordered
if not list_buf:
return
tag = "ol" if list_ordered else "ul"
out.append(f"<{tag}>")
for item in list_buf:
out.append(f"<li>{_inline_md(item)}</li>")
out.append(f"</{tag}>")
list_buf = []
def flush_code() -> None:
nonlocal code_buf, in_code
if not code_buf:
return
out.append(f"<pre><code>{escape(chr(10).join(code_buf))}</code></pre>")
code_buf = []
in_code = False
while i < len(lines):
line = lines[i]
if line.strip().startswith("```"):
flush_list()
if in_code:
flush_code()
else:
in_code = True
i += 1
continue
if in_code:
code_buf.append(line)
i += 1
continue
if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]):
flush_list()
header = [c.strip() for c in line.strip().strip("|").split("|")]
i += 2
rows: list[list[str]] = []
while i < len(lines) and re.match(r"^\s*\|", lines[i]):
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" + "".join(f"<th>{_inline_md(h)}</th>" for h in header) + "</tr></thead><tbody>")
for row in rows:
out.append("<tr>" + "".join(f"<td>{_inline_md(c)}</td>" for c in row) + "</tr>")
out.append("</tbody></table>")
continue
if re.match(r"^#{1,3}\s+", line):
flush_list()
m = re.match(r"^(#{1,3})\s+(.*)$", line)
if m:
level = len(m.group(1))
out.append(f"<h{level}>{_inline_md(m.group(2))}</h{level}>")
i += 1
continue
if line.strip() == "---":
flush_list()
out.append("<hr>")
i += 1
continue
if line.startswith(">"):
flush_list()
out.append(f"<blockquote>{_inline_md(line.lstrip('>').strip())}</blockquote>")
i += 1
continue
m = re.match(r"^(\d+)\.\s+(.*)$", line.strip())
if m:
if list_buf and not list_ordered:
flush_list()
list_ordered = True
list_buf.append(m.group(2))
i += 1
continue
if re.match(r"^[-*]\s+", line.strip()):
if list_buf and list_ordered:
flush_list()
list_ordered = False
list_buf.append(re.sub(r"^[-*]\s+", "", line.strip()))
i += 1
continue
if not line.strip():
flush_list()
i += 1
continue
flush_list()
out.append(f"<p>{_inline_md(line.strip())}</p>")
i += 1
flush_list()
flush_code()
return "\n".join(out)
def _inline_md(text: str) -> str:
from html import escape
s = escape(text)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
return s
+337
View File
@@ -0,0 +1,337 @@
/**
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
*/
(function (global) {
const PAGE_SIZE = 10;
let account = "funding";
let page = 1;
let pages = 0;
let localVersion = 0;
let es = null;
let reconnectTimer = null;
let loading = false;
let booted = false;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
if (active) return active;
return document.getElementById("account-ledger-root");
}
function $(id) {
const r = root();
return (r && r.querySelector("#" + id)) || document.getElementById(id);
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listWindowQs() {
if (typeof global.listWindowQueryString === "function") {
const q = global.listWindowQueryString();
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
}
try {
return new URLSearchParams(location.search).toString();
} catch (_) {
return "";
}
}
function fmtBj(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
try {
const d = new Date(n);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
return (
get("year") +
"-" +
get("month") +
"-" +
get("day") +
" " +
get("hour") +
":" +
get("minute") +
":" +
get("second")
);
} catch (_) {
return "—";
}
}
function fmtAmt(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
}
function fmtBal(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(6).replace(/\.?0+$/, "");
}
function setStatus(msg, isErr) {
const el = $("account-ledger-status");
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "#f07178" : "";
}
function setSyncLabel(data) {
const el = $("account-ledger-sync");
if (!el) return;
const ts = data && data.last_sync_at;
if (!ts) {
el.textContent = "尚未同步";
return;
}
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
}
function renderRows(items) {
const tbody = $("account-ledger-tbody");
if (!tbody) return;
if (!items || !items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
return;
}
tbody.innerHTML = items
.map(function (it) {
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
return (
"<tr>" +
"<td>" +
escapeHtml(fmtBj(it.ts_ms)) +
"</td>" +
"<td>" +
escapeHtml(it.ccy || "") +
"</td>" +
"<td>" +
escapeHtml(it.kind_label || it.kind || "") +
"</td>" +
"<td>" +
fmtAmt(it.amount) +
"</td>" +
"<td>" +
escapeHtml(fmtBal(it.balance_after)) +
"</td>" +
"<td>" +
escapeHtml(note || "—") +
"</td>" +
"</tr>"
);
})
.join("");
}
function renderPager(data) {
pages = Number(data.pages || 0);
page = Number(data.page || 1);
const info = $("account-ledger-page-info");
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
if (info) {
info.textContent =
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
}
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = !pages || page >= pages;
}
async function loadList(opts) {
const r = root();
if (!r) return;
if (loading) return;
loading = true;
const force = opts && opts.force;
try {
if (!force) setStatus("加载中…");
const qs = new URLSearchParams(listWindowQs());
qs.set("account", account);
qs.set("page", String(page));
const res = await fetch("/api/account_ledger?" + qs.toString(), {
credentials: "same-origin",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || res.statusText || "加载失败");
}
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
renderRows(data.items || []);
renderPager(data);
setSyncLabel(data);
const winLabel = (data.window && data.window.label) || "";
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
setStatus(
(winLabel ? "时间窗 " + winLabel + " · " : "") +
(account === "trading" ? "交易账户" : "资金账户") +
err,
!!data.last_error
);
} catch (e) {
setStatus(e.message || String(e), true);
} finally {
loading = false;
}
}
async function refreshNow() {
setStatus("正在从交易所同步…");
try {
const res = await fetch("/api/account_ledger/refresh", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || "同步失败");
}
await loadList({ force: true });
} catch (e) {
setStatus(e.message || String(e), true);
}
}
function bindUi() {
const r = root();
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
r.setAttribute("data-ledger-bound", "1");
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
const acc = btn.getAttribute("data-ledger-account") || "funding";
if (acc === account) return;
account = acc;
page = 1;
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
const on = b.getAttribute("data-ledger-account") === account;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
loadList();
});
});
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
const ref = $("account-ledger-refresh");
if (prev)
prev.addEventListener("click", function () {
if (page > 1) {
page -= 1;
loadList();
}
});
if (next)
next.addEventListener("click", function () {
if (!pages || page < pages) {
page += 1;
loadList();
}
});
if (ref) ref.addEventListener("click", refreshNow);
}
function connectSse() {
if (es) {
try {
es.close();
} catch (_) {}
es = null;
}
if (typeof EventSource === "undefined") return;
try {
es = new EventSource("/api/account_ledger/stream");
es.addEventListener("ledger", function (ev) {
let data = {};
try {
data = JSON.parse(ev.data || "{}");
} catch (_) {}
const ver = Number(data.ledger_version || 0);
if (ver && ver !== localVersion) {
localVersion = ver;
loadList({ force: true });
}
});
es.onerror = function () {
try {
es.close();
} catch (_) {}
es = null;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectSse, 5000);
};
} catch (_) {}
}
function boot() {
const r = root();
if (!r) return;
bindUi();
if (!booted) {
booted = true;
connectSse();
}
loadList();
}
function onTabActivated(tab) {
if (tab !== "account_ledger") return;
boot();
}
global.AccountLedgerPage = {
boot: boot,
onTabActivated: onTabActivated,
reload: function () {
page = 1;
loadList();
},
};
document.addEventListener("DOMContentLoaded", function () {
const page =
(document.body && document.body.getAttribute("data-page")) ||
(document.body && document.body.getAttribute("data-initial-tab")) ||
"";
if (page === "account_ledger" || root()) {
// embed 延后到 tab 激活;独立页直接 boot
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
boot();
} else if (page === "account_ledger") {
boot();
}
}
});
document.addEventListener("instance-embed-tab-activated", function (ev) {
const tab = ev && ev.detail && ev.detail.tab;
onTabActivated(tab);
});
})(window);
+150
View File
@@ -0,0 +1,150 @@
/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */
:root,
html[data-theme="dark"] {
--risk-normal-fg: #9cf0c4;
--risk-normal-bg: rgba(36, 140, 96, 0.16);
--risk-normal-border: rgba(72, 190, 130, 0.42);
--risk-normal-glow: rgba(72, 190, 130, 0.35);
--risk-1h-fg: #ffd27a;
--risk-1h-bg: rgba(210, 150, 40, 0.16);
--risk-1h-border: rgba(230, 170, 60, 0.45);
--risk-1h-glow: rgba(230, 170, 60, 0.32);
--risk-4h-fg: #ffab8a;
--risk-4h-bg: rgba(210, 90, 55, 0.16);
--risk-4h-border: rgba(230, 110, 70, 0.48);
--risk-4h-glow: rgba(230, 110, 70, 0.34);
--risk-daily-fg: #ff9ec4;
--risk-daily-bg: rgba(190, 55, 100, 0.18);
--risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36);
--risk-position-fg: #8ec8ff;
--risk-position-bg: rgba(55, 120, 210, 0.18);
--risk-position-border: rgba(75, 145, 230, 0.48);
--risk-position-glow: rgba(75, 145, 230, 0.34);
--risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] {
--risk-normal-fg: #056b44;
--risk-normal-bg: rgba(10, 143, 92, 0.14);
--risk-normal-border: rgba(8, 122, 80, 0.38);
--risk-normal-glow: rgba(10, 143, 92, 0.22);
--risk-1h-fg: #8a5a00;
--risk-1h-bg: rgba(200, 140, 20, 0.14);
--risk-1h-border: rgba(170, 115, 10, 0.38);
--risk-1h-glow: rgba(200, 140, 20, 0.2);
--risk-4h-fg: #a83812;
--risk-4h-bg: rgba(210, 85, 35, 0.12);
--risk-4h-border: rgba(180, 65, 25, 0.36);
--risk-4h-glow: rgba(210, 85, 35, 0.2);
--risk-daily-fg: #9a1248;
--risk-daily-bg: rgba(180, 35, 80, 0.1);
--risk-daily-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18);
--risk-position-fg: #0b5cab;
--risk-position-bg: rgba(20, 100, 190, 0.12);
--risk-position-border: rgba(15, 85, 165, 0.36);
--risk-position-glow: rgba(20, 100, 190, 0.2);
--risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1);
}
.risk-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1.15;
padding: 5px 12px 5px 10px;
border-radius: 999px;
border: 1px solid var(--risk-border, transparent);
background: var(--risk-bg, transparent);
color: var(--risk-fg, inherit);
box-shadow: var(--risk-badge-shadow);
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */
html[data-hub-linked="1"] .header-row .risk-status-badge {
transition: none;
}
.risk-status-badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent),
0 0 8px var(--risk-glow, currentColor);
opacity: 0.92;
}
.risk-status-normal {
--risk-fg: var(--risk-normal-fg);
--risk-bg: var(--risk-normal-bg);
--risk-border: var(--risk-normal-border);
--risk-glow: var(--risk-normal-glow);
}
.risk-status-freeze_1h {
--risk-fg: var(--risk-1h-fg);
--risk-bg: var(--risk-1h-bg);
--risk-border: var(--risk-1h-border);
--risk-glow: var(--risk-1h-glow);
}
.risk-status-freeze_4h {
--risk-fg: var(--risk-4h-fg);
--risk-bg: var(--risk-4h-bg);
--risk-border: var(--risk-4h-border);
--risk-glow: var(--risk-4h-glow);
}
.risk-status-freeze_daily {
--risk-fg: var(--risk-daily-fg);
--risk-bg: var(--risk-daily-bg);
--risk-border: var(--risk-daily-border);
--risk-glow: var(--risk-daily-glow);
}
.risk-status-freeze_position {
--risk-fg: var(--risk-position-fg);
--risk-bg: var(--risk-position-bg);
--risk-border: var(--risk-position-border);
--risk-glow: var(--risk-position-glow);
}
/* 实例页:与交易所标签并排 */
.header-row .risk-status-badge {
min-height: 28px;
}
/* 中控卡片标题内 */
.card-title .risk-status-badge,
.hub-tile-name .risk-status-badge {
font-size: 0.7rem;
padding: 3px 10px 3px 8px;
vertical-align: middle;
}
.card-title .risk-status-badge::before,
.hub-tile-name .risk-status-badge::before {
width: 6px;
height: 6px;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 账户风控徽章倒计时 — 三所实例 + 中控共用.
*/
(function (global) {
"use strict";
function formatRemaining(totalSec) {
const sec = Math.max(0, Math.floor(Number(totalSec) || 0));
if (sec <= 0) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
function baseLabel(riskStatus, el) {
if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label);
if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel);
return "正常";
}
function resolveFreezeUntilMs(riskStatus) {
if (!riskStatus) return null;
const sec = Number(riskStatus.freeze_remaining_sec);
if (Number.isFinite(sec) && sec > 0) {
return Date.now() + sec * 1000;
}
const until = Number(riskStatus.freeze_until_ms);
return Number.isFinite(until) && until > 0 ? until : null;
}
function badgeText(riskStatus) {
const label = baseLabel(riskStatus, null);
const until = resolveFreezeUntilMs(riskStatus);
if (!until || until <= Date.now()) return label;
const cd = formatRemaining((until - Date.now()) / 1000);
return cd ? `${label} · ${cd}` : label;
}
function setNormalBadge(el) {
el.className = "risk-status-badge risk-status-normal";
el.dataset.statusLabel = "正常";
el.textContent = "正常";
el.title = "";
if (el.dataset) delete el.dataset.freezeUntilMs;
}
function refreshElement(el) {
if (!el) return;
const label = baseLabel(null, el);
const until = Number(el.dataset && el.dataset.freezeUntilMs);
if (!Number.isFinite(until) || until <= Date.now()) {
if (el.dataset && el.dataset.freezeUntilMs) {
setNormalBadge(el);
} else {
el.textContent = label;
}
return;
}
const cd = formatRemaining((until - Date.now()) / 1000);
el.textContent = cd ? `${label} · ${cd}` : label;
}
function applyToElement(el, riskStatus) {
if (!el || !riskStatus) return;
const st = riskStatus.status || "normal";
el.className = "risk-status-badge risk-status-" + st;
el.dataset.statusLabel = baseLabel(riskStatus, el);
const until = resolveFreezeUntilMs(riskStatus);
if (until) {
el.dataset.freezeUntilMs = String(until);
} else if (el.dataset) {
delete el.dataset.freezeUntilMs;
}
el.textContent = badgeText(riskStatus);
el.title = riskStatus.reason || "";
}
function formatBadgeHtml(riskStatus, esc) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const safe = typeof esc === "function" ? esc : (s) => String(s);
const st = riskStatus.status || "normal";
const label = safe(riskStatus.status_label || "正常");
const title = safe(riskStatus.reason || "");
const text = safe(badgeText(riskStatus));
const until = resolveFreezeUntilMs(riskStatus);
const untilAttr =
until != null
? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"`
: "";
return (
`<span class="risk-status-badge risk-status-${safe(st)}" role="status"` +
` title="${title}" data-status-label="${label}"${untilAttr}>${text}</span>`
);
}
function tickAll(root) {
const scope = root || document;
scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement);
}
let timer = null;
function startTicker() {
if (timer) return;
tickAll();
timer = setInterval(() => tickAll(), 1000);
}
global.AccountRiskBadge = {
formatRemaining,
badgeText,
refreshElement,
applyToElement,
formatBadgeHtml,
tickAll,
startTicker,
};
})(typeof window !== "undefined" ? window : globalThis);
+148
View File
@@ -0,0 +1,148 @@
/**
* 全局防浏览器自动填充登录账号/密码进业务输入框.
* 跳过真正的登录/改密字段;对划转数量等易中招框用 readonly 到聚焦.
*/
(function () {
"use strict";
var GUARD_ATTRS = {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
spellcheck: "false",
"data-lpignore": "true",
"data-1p-ignore": "true",
"data-bwignore": "true",
"data-form-type": "other",
};
function looksLikeUsername(v) {
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
}
function isAuthField(el) {
if (!el || !el.getAttribute) return true;
var t = String(el.type || "").toLowerCase();
if (t === "hidden" || t === "checkbox" || t === "radio" || t === "file" || t === "submit" || t === "button") {
return true;
}
if (el.getAttribute("aria-hidden") === "true") return true;
if (el.tabIndex === -1 && String(el.getAttribute("autocomplete") || "").toLowerCase() === "username") {
return true; // 诱饵账号框
}
var idName = String(el.id || "") + " " + String(el.name || "");
if (/^(pwd-|hub-pwd-|login-)/i.test(String(el.id || ""))) return true;
if (el.closest) {
if (el.closest(".login-form, #login-form, form.login-form, .password-settings, [data-password-settings]")) {
return true;
}
}
// env API Key 等 type=password 仍要防登录密码灌入,不在此跳过
if (t === "password" && /^(username|password)$/i.test(String(el.name || ""))) {
if (el.closest && el.closest("form[method='post'], form[method='POST']")) return true;
}
return false;
}
function isAmountLike(el) {
var key = String(el.id || "") + " " + String(el.name || "") + " " + String(el.placeholder || "");
return /amount|xfer|transfer|划转|数量|金额/i.test(key);
}
function wipeBad(el) {
if (!el || isAuthField(el)) return;
var v = String(el.value || "").trim();
if (!looksLikeUsername(v)) return;
var t = String(el.type || "text").toLowerCase();
if (t === "number" || isAmountLike(el) || /price|sheets|qty|sl|tp|target|entry|strike/i.test(String(el.id || "") + String(el.name || ""))) {
el.value = "";
}
}
function harden(el) {
if (!el || el.nodeType !== 1) return;
if (isAuthField(el)) return;
if (el.getAttribute("aria-hidden") === "true") return;
if (el.dataset && el.dataset.autofillGuarded === "1") {
wipeBad(el);
return;
}
if (el.dataset) el.dataset.autofillGuarded = "1";
Object.keys(GUARD_ATTRS).forEach(function (k) {
var cur = el.getAttribute(k);
if (k === "autocomplete" && cur && /^(username|current-password)/i.test(cur)) {
return;
}
// env 密钥框用 new-password 更抗登录密码灌入
if (k === "autocomplete" && String(el.type || "").toLowerCase() === "password") {
el.setAttribute(k, "new-password");
return;
}
if (!cur || cur === "on") el.setAttribute(k, GUARD_ATTRS[k]);
});
if (String(el.type || "").toLowerCase() === "password" || isAmountLike(el)) {
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
}
wipeBad(el);
setTimeout(function () {
wipeBad(el);
}, 250);
setTimeout(function () {
wipeBad(el);
}, 900);
setTimeout(function () {
wipeBad(el);
}, 2000);
}
function scan(root) {
var scope = root && root.querySelectorAll ? root : document;
var list = scope.querySelectorAll(
'input[type="text"], input[type="number"], input[type="search"], input[type="url"], input[type="email"], input[type="tel"], input[type="password"], input:not([type]), textarea'
);
for (var i = 0; i < list.length; i++) harden(list[i]);
}
function boot() {
scan(document);
if (typeof MutationObserver === "undefined") return;
var obs = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.type === "childList") {
for (var j = 0; j < m.addedNodes.length; j++) {
var n = m.addedNodes[j];
if (!n || n.nodeType !== 1) continue;
if (n.matches && n.matches("input, textarea")) harden(n);
else if (n.querySelectorAll) scan(n);
}
} else if (m.type === "attributes" && m.target) {
harden(m.target);
}
}
});
obs.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["value"],
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
window.cmAutofillGuardScan = scan;
})();
+221
View File
@@ -0,0 +1,221 @@
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
body.focus-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 14px;
margin: 0;
background: var(--focus-bg, #0b0d14);
color: var(--focus-fg, #eaeaea);
}
html[data-theme="light"] body.focus-page {
--focus-bg: #eef3f8;
--focus-fg: #142232;
--focus-card-bg: #fff;
--focus-card-border: #b8c8d8;
--focus-meta-bg: #fff;
--focus-meta-border: #9eb4c8;
--focus-meta-label: #2a4a66;
--focus-meta-value: #0a1628;
--focus-status: #4a6078;
--focus-chart-bg: #f0f4f9;
--focus-chart-border: #b8c8d8;
--focus-btn-bg: #fff;
--focus-btn-fg: #006e9a;
--focus-btn-border: rgba(0, 95, 140, 0.22);
--focus-input-bg: #fff;
--focus-input-fg: #142232;
--focus-input-border: #b8c8d8;
--focus-title: #0a1628;
--focus-pnl-up: #0a7a3d;
--focus-pnl-down: #c62828;
--focus-dir-short: #b71c1c;
--focus-dir-long: #0a7a3d;
}
html[data-theme="dark"] body.focus-page {
--focus-bg: #0b0d14;
--focus-fg: #eaeaea;
--focus-card-bg: #121726;
--focus-card-border: #2a3150;
--focus-meta-bg: #141b2f;
--focus-meta-border: #3d4f72;
--focus-meta-label: #c8d8f0;
--focus-meta-value: #f0f4ff;
--focus-status: #95a2c2;
--focus-chart-bg: #0f1320;
--focus-chart-border: #2a3150;
--focus-btn-bg: #151a2a;
--focus-btn-fg: #8fc8ff;
--focus-btn-border: #304164;
--focus-input-bg: #1a1a29;
--focus-input-fg: #fff;
--focus-input-border: #2e2e45;
--focus-title: #dbe4ff;
--focus-pnl-up: #3ddc84;
--focus-pnl-down: #ff7070;
--focus-dir-short: #ff8a80;
--focus-dir-long: #69f0ae;
}
body.focus-page * {
box-sizing: border-box;
}
.focus-page .container {
width: min(98vw, 1900px);
margin: 0 auto;
}
.focus-page .card {
background: var(--focus-card-bg);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--focus-card-border);
margin-bottom: 12px;
}
.focus-page .row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.focus-page .btn {
padding: 7px 10px;
border-radius: 8px;
text-decoration: none;
border: 1px solid var(--focus-btn-border);
background: var(--focus-btn-bg);
color: var(--focus-btn-fg);
cursor: pointer;
}
.focus-page .btn:hover {
filter: brightness(1.06);
}
.focus-page select,
.focus-page input,
.focus-page button {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--focus-input-border);
background: var(--focus-input-bg);
color: var(--focus-input-fg);
}
.focus-page .focus-title {
color: var(--focus-title);
font-weight: 700;
}
.focus-page .meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 10px;
}
.focus-page .meta-item {
background: var(--focus-meta-bg);
border: 1px solid var(--focus-meta-border);
border-radius: 8px;
padding: 10px 10px 9px;
}
.focus-page .meta-item .k {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--focus-meta-label);
}
.focus-page .meta-item .v {
font-size: 1.02rem;
font-weight: 600;
margin-top: 5px;
word-break: break-all;
color: var(--focus-meta-value);
}
.focus-page .meta-item--emph {
border-width: 2px;
border-color: var(--focus-meta-label);
}
.focus-page .meta-item--emph .k {
font-size: 0.82rem;
font-weight: 700;
}
.focus-page .meta-item--emph .v {
font-size: 1.12rem;
font-weight: 800;
}
.focus-page .meta-item--pnl .v {
font-size: 1.14rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.focus-page .meta-pnl-up {
color: var(--focus-pnl-up) !important;
}
.focus-page .meta-pnl-down {
color: var(--focus-pnl-down) !important;
}
.focus-page .meta-dir-long {
color: var(--focus-dir-long) !important;
}
.focus-page .meta-dir-short {
color: var(--focus-dir-short) !important;
}
.focus-page .status {
font-size: 0.84rem;
color: var(--focus-status);
}
.focus-page .status.err {
color: var(--focus-pnl-down);
}
.focus-page #chart-wrap {
height: 560px;
background: var(--focus-chart-bg);
border: 1px solid var(--focus-chart-border);
border-radius: 10px;
padding: 8px;
}
.focus-page #chart {
width: 100%;
height: 100%;
}
.focus-page .empty {
padding: 18px;
color: var(--focus-status);
}
.focus-page .exchange-tag {
font-size: 0.72rem;
font-weight: 600;
color: #b8f5d0;
background: #14241e;
border: 1px solid #2d6a4f;
padding: 4px 10px;
border-radius: 999px;
margin-left: 8px;
}
html[data-theme="light"] .focus-page .exchange-tag {
color: #0a5c38;
background: #e8f5ee;
border-color: #7bc9a0;
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 实盘/关键位放大 K 线:交易所 tick 精度,主题感知图表,高对比 meta.
*/
(function (global) {
"use strict";
let activePriceTick = null;
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "light"
? "light"
: "dark";
}
function chartTheme(theme) {
if (theme === "light") {
return {
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
rightPriceScale: { borderColor: "#b8c8d8" },
timeScale: { borderColor: "#b8c8d8" },
candle: {
upColor: "#0a7a3d",
downColor: "#c62828",
wickUpColor: "#0a7a3d",
wickDownColor: "#c62828",
},
};
}
return {
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
rightPriceScale: { borderColor: "#2a3150" },
timeScale: { borderColor: "#2a3150" },
candle: {
upColor: "#4cd97f",
downColor: "#ff6666",
wickUpColor: "#4cd97f",
wickDownColor: "#ff6666",
},
};
}
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
try {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
return { type: "price", precision: 2, minMove: 0.01 };
}
const minMove = Number(tick);
let prec = decimalsFromTick(minMove);
if (prec == null || prec < 0) prec = 4;
prec = Math.min(12, Math.max(0, Math.floor(prec)));
return { type: "price", precision: prec, minMove: minMove };
} catch (_) {
return SAFE_PRICE_FORMAT;
}
}
function roundToTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return v;
const n = Number(v);
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
const t = Number(tick);
const rounded = Math.round(n / t) * t;
const dec = decimalsFromTick(t);
if (dec == null) return rounded;
return parseFloat(rounded.toFixed(dec));
}
function fmtPriceByTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(roundToTick(v, tick));
if (n === 0) return "0";
const dec = decimalsFromTick(tick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
const text = n.toFixed(d);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function setActivePriceTick(tick) {
activePriceTick =
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
? null
: Number(tick);
}
function formatSigned(v, digits) {
digits = digits === undefined ? 2 : digits;
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(digits);
}
function formatSignedPrice(v) {
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
if (body === "-") return "-";
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
}
function formatRrRatio(rr) {
if (rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if (Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function displayPrice(orderOrData, field, rawField) {
const dispKey = field + "_display";
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
return String(orderOrData[dispKey]);
}
const raw = orderOrData ? orderOrData[rawField || field] : null;
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
return fmtPriceByTick(raw, activePriceTick);
}
function lineTitle(label, display) {
const d = display && display !== "-" ? display : "";
return d ? label + " " + d : label;
}
function paintOrderMeta(order) {
const symEl = document.getElementById("m-symbol");
const dirEl = document.getElementById("m-direction");
const pnlEl = document.getElementById("m-pnl");
if (symEl) symEl.textContent = order.symbol || "-";
if (dirEl) {
const isShort = order.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set("m-entry", displayPrice(order, "trigger_price"));
set("m-sl", displayPrice(order, "stop_loss"));
set("m-tp", displayPrice(order, "take_profit"));
set("m-rr", formatRrRatio(order.rr_ratio));
set(
"m-breakeven",
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
);
set(
"m-price",
order.current_price_display ||
order.price_display ||
displayPrice(order, "current_price")
);
if (pnlEl) {
pnlEl.textContent =
formatSigned(order.float_pnl, 2) +
"U (" +
formatSigned(order.float_pct, 2) +
"%)";
pnlEl.className = "v";
const pnl = Number(order.float_pnl || 0);
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
}
}
function paintKeyMeta(data) {
const key = data.key_monitor || null;
const symEl = document.getElementById("m-symbol");
if (symEl) symEl.textContent = data.symbol || "-";
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set(
"m-price",
data.current_price_display || displayPrice(data, "current_price")
);
const dirEl = document.getElementById("m-direction");
if (!key) {
set("m-type", "未匹配到关键位");
set("m-direction", "-");
if (dirEl) dirEl.className = "v";
set("m-upper", "-");
set("m-lower", "-");
set("m-updiff", "-");
set("m-lowdiff", "-");
return;
}
set("m-type", key.monitor_type || "-");
if (dirEl) {
const isShort = key.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
set("m-upper", key.upper_display || displayPrice(key, "upper"));
set("m-lower", key.lower_display || displayPrice(key, "lower"));
if (activePriceTick != null) {
set(
"m-updiff",
formatSignedPrice(key.upper_diff) +
" (" +
formatSigned(key.upper_pct, 2) +
"%)"
);
set(
"m-lowdiff",
formatSignedPrice(key.lower_diff) +
" (" +
formatSigned(key.lower_pct, 2) +
"%)"
);
} else {
set(
"m-updiff",
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
);
set(
"m-lowdiff",
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
);
}
}
function applyPriceFormatToSeries(series, pf) {
if (!series || !series.applyOptions) return;
try {
series.applyOptions({ priceFormat: pf });
} catch (_) {
try {
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
} catch (_2) {}
}
}
function createFocusChart(host) {
if (!global.LightweightCharts) return null;
const th = chartTheme(currentTheme());
const chart = global.LightweightCharts.createChart(host, {
layout: th.layout,
grid: th.grid,
rightPriceScale: th.rightPriceScale,
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
crosshair: { mode: 0 },
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
let candleSeries = null;
function applyChartPriceFormat() {
let pf = SAFE_PRICE_FORMAT;
try {
pf = tickToPriceFormat(activePriceTick);
} catch (_) {
pf = SAFE_PRICE_FORMAT;
}
applyPriceFormatToSeries(candleSeries, pf);
try {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
} catch (_) {}
}
function setPriceTick(tick) {
setActivePriceTick(tick);
applyChartPriceFormat();
}
const opts = Object.assign({ borderVisible: false }, th.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
}
applyChartPriceFormat();
const priceLines = [];
function resetPriceLines() {
if (!candleSeries) return;
priceLines.forEach(function (line) {
try {
candleSeries.removePriceLine(line);
} catch (_) {}
});
priceLines.length = 0;
}
function addLine(price, title, color) {
if (!candleSeries || price === null || typeof price === "undefined") return;
const p = Number(roundToTick(price, activePriceTick));
if (Number.isNaN(p) || p <= 0) return;
priceLines.push(
candleSeries.createPriceLine({
price: p,
color: color,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: true,
title: title,
})
);
}
function applyTheme() {
const t = chartTheme(currentTheme());
chart.applyOptions({
layout: t.layout,
grid: t.grid,
rightPriceScale: t.rightPriceScale,
timeScale: t.timeScale,
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
if (candleSeries && typeof candleSeries.applyOptions === "function") {
candleSeries.applyOptions(t.candle);
}
applyChartPriceFormat();
}
function resize() {
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
global.addEventListener("resize", resize);
resize();
const obs = new MutationObserver(applyTheme);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return {
chart: chart,
candleSeries: candleSeries,
resetPriceLines: resetPriceLines,
addLine: addLine,
applyTheme: applyTheme,
setPriceTick: setPriceTick,
ensureSeries: function () {
if (candleSeries) return true;
const t = chartTheme(currentTheme());
const o = Object.assign({ borderVisible: false }, t.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(o);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
}
applyChartPriceFormat();
return !!candleSeries;
},
};
}
global.FocusChartPage = {
currentTheme: currentTheme,
chartTheme: chartTheme,
formatSigned: formatSigned,
formatRrRatio: formatRrRatio,
displayPrice: displayPrice,
lineTitle: lineTitle,
paintOrderMeta: paintOrderMeta,
paintKeyMeta: paintKeyMeta,
createFocusChart: createFocusChart,
setActivePriceTick: setActivePriceTick,
fmtPriceByTick: fmtPriceByTick,
};
})(typeof window !== "undefined" ? window : globalThis);
+81
View File
@@ -0,0 +1,81 @@
/**
* 表单提交防重复:网络慢时禁用按钮并显示「提交中」.
*/
(function (global) {
"use strict";
function submitButtons(form) {
if (!form) return [];
return Array.prototype.slice.call(
form.querySelectorAll('button[type="submit"], input[type="submit"]')
);
}
function lockForm(form, label) {
if (!form) return false;
if (form.dataset.submitGuard === "locked") return false;
form.dataset.submitGuard = "locked";
form.classList.add("is-form-submitting");
submitButtons(form).forEach(function (btn) {
if (btn.dataset.submitGuardOrig === undefined) {
btn.dataset.submitGuardOrig =
btn.tagName === "BUTTON" ? btn.textContent : btn.value;
}
btn.disabled = true;
if (label) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
}
});
return true;
}
function unlockForm(form) {
if (!form) return;
delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) {
// 风控灰显(开仓门禁)保持禁用
btn.disabled = btn.classList.contains("is-blocked");
var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig;
else btn.value = orig;
delete btn.dataset.submitGuardOrig;
}
});
}
function isLocked(form) {
return !!(form && form.dataset.submitGuard === "locked");
}
/** 已锁定时仅更新按钮文案(校验通过 → 真正提交前) */
function setSubmitLabel(form, label) {
if (!form || !label) return;
submitButtons(form).forEach(function (btn) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
});
}
/** 已通过前端校验,发起最终 POST(页面将跳转) */
function nativeSubmitOnce(form, label) {
if (!form) return;
var text = label || "提交中…";
if (form.dataset.submitGuard === "locked") {
setSubmitLabel(form, text);
} else {
lockForm(form, text);
}
form.submit();
}
global.FormSubmitGuard = {
lock: lockForm,
unlock: unlockForm,
isLocked: isLocked,
setSubmitLabel: setSubmitLabel,
nativeSubmitOnce: nativeSubmitOnce,
};
})(typeof window !== "undefined" ? window : this);
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
/**
* 实例数据看板:拉 /api/instance/dashboard 渲染只读表格.
* 各区块无数据时不展示;有数据按表格展示.
*/
(function (global) {
const SECTION_ORDER = ["orders", "keys", "strategy", "options", "hedge_plan"];
let loading = false;
let localDashVersion = 0;
let dashEventSource = null;
let dashReconnectTimer = null;
let booted = false;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-inst-dashboard="1"]');
if (active) return active;
return document.getElementById("instance-dashboard");
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtNum(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return escapeHtml(v);
return String(n);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(2) + "U</span>";
}
function fmtPnlPlain(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : "";
return '<span class="' + cls + '">' + n.toFixed(2) + "</span>";
}
function dirCell(it) {
const d = String(it.direction || "").toLowerCase();
const label = it.direction_label || (d === "short" ? "做空" : d === "long" ? "做多" : "-");
const cls = d === "short" ? "inst-dash-dir-short" : d === "long" ? "inst-dash-dir-long" : "";
return '<td class="' + cls + '">' + escapeHtml(label) + "</td>";
}
function fmtExpiry(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
let fallback = "—";
try {
const d = new Date(n);
if (!Number.isNaN(d.getTime())) {
const pad = function (x) {
return String(x).padStart(2, "0");
};
fallback =
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes());
}
} catch (_) {}
return (
'<span class="opt-expiry-cd" data-opt-exp-ms="' +
escapeHtml(String(Math.floor(n))) +
'">' +
escapeHtml(fallback) +
"</span>"
);
}
function goTab(tab) {
if (!tab) return;
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
global.InstanceEmbed.loadTab(tab);
return;
}
const pathMap = {
trade: "/trade",
key_monitor: "/key_monitor",
strategy: "/strategy",
options: "/options",
hedge_plan: "/hedge-plan",
};
const path = pathMap[tab] || "/" + tab;
location.href = path;
}
function tableWrap(headers, rowsHtml) {
return (
'<div class="inst-dash-table-wrap">' +
'<table class="inst-dash-table">' +
"<thead><tr>" +
headers
.map(function (h) {
return "<th>" + escapeHtml(h) + "</th>";
})
.join("") +
"</tr></thead>" +
"<tbody>" +
rowsHtml +
"</tbody></table></div>"
);
}
function rowClickAttrs(tab) {
return ' class="inst-dash-row" data-dash-tab="' + escapeHtml(tab || "") + '" role="link" tabindex="0"';
}
function renderOrdersTable(items) {
const rows = items
.map(function (it) {
const sym = it.symbol || "-";
const mark =
it.mark_display != null && it.mark_display !== ""
? escapeHtml(it.mark_display)
: fmtNum(it.mark_price);
const tpProfit =
it.tp_profit != null && Number.isFinite(Number(it.tp_profit))
? '<span class="pos-tp-profit">' + Number(it.tp_profit).toFixed(2) + "U</span>"
: "—";
return (
"<tr" +
rowClickAttrs(it.tab || "trade") +
">" +
'<td class="td-symbol"><span class="inst-dash-sym-link">' +
escapeHtml(sym) +
"</span></td>" +
dirCell(it) +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"<td>" +
mark +
"</td>" +
"<td>" +
fmtNum(it.contracts) +
"</td>" +
"<td>" +
tpProfit +
"</td>" +
"<td>" +
fmtPnlPlain(it.float_pnl) +
"</td>" +
"<td>—</td>" +
"</tr>"
);
})
.join("");
return tableWrap(
["合约", "方向", "开仓价", "标记价", "张数", "盈利金额", "浮盈", "操作"],
rows
);
}
function renderKeysTable(items) {
const rows = items
.map(function (it) {
return (
"<tr" +
rowClickAttrs(it.tab || "key_monitor") +
">" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.subtitle || "—") +
"</td>" +
"<td>" +
fmtNum(it.upper) +
"</td>" +
"<td>" +
fmtNum(it.lower) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "方向", "信号", "上沿", "下沿"], rows);
}
function renderStrategyTable(items) {
const rows = items
.map(function (it) {
const kindLabel = it.kind === "roll" ? "顺势加仓" : it.kind === "trend" ? "趋势回调" : "策略";
return (
"<tr" +
rowClickAttrs(it.tab || "strategy") +
">" +
"<td>" +
escapeHtml(kindLabel) +
"</td>" +
"<td>" +
escapeHtml(it.symbol || "-") +
"</td>" +
dirCell(it) +
"<td>" +
escapeHtml(it.status || "—") +
"</td>" +
"<td>" +
fmtNum(it.entry) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["类型", "合约", "方向", "状态", "入场"], rows);
}
function renderOptionsTable(items) {
const rows = items
.map(function (it) {
const opt = it.opt_type_label ||
(String(it.opt_type || "").toUpperCase() === "C"
? "Call"
: String(it.opt_type || "").toUpperCase() === "P"
? "Put"
: it.opt_type || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "options") +
">" +
"<td>" +
escapeHtml(it.inst_id || it.title || "-") +
"</td>" +
"<td>" +
escapeHtml(it.source_label || "纯期权") +
"</td>" +
"<td>" +
escapeHtml(opt) +
"</td>" +
"<td>" +
fmtNum(it.pos) +
"</td>" +
"<td>" +
fmtExpiry(it.exp_time_ms) +
"</td>" +
"<td>" +
escapeHtml(it.target_monitor || "—") +
"</td>" +
"<td>" +
fmtPnl(it.pnl) +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["合约", "来源", "类型", "张数", "到期时间", "目标监控", "净盈亏"], rows);
}
function renderHedgeTable(items) {
const rows = items
.map(function (it) {
const stCls = it.status_active ? "inst-dash-status-active" : "";
const stText = it.status_label || (it.status_active ? "进行中" : it.status || "—");
return (
"<tr" +
rowClickAttrs(it.tab || "hedge_plan") +
">" +
"<td>#" +
escapeHtml(it.id != null ? it.id : "—") +
"</td>" +
"<td>" +
escapeHtml(it.underlying || "-") +
"</td>" +
"<td>" +
escapeHtml(it.plan_type_label || it.plan_type || "—") +
"</td>" +
'<td class="' +
stCls +
'">' +
escapeHtml(stText) +
"</td>" +
"<td>" +
escapeHtml(it.contracts_summary || it.subtitle || "—") +
"</td>" +
"</tr>"
);
})
.join("");
return tableWrap(["ID", "标的", "计划类型", "状态", "说明"], rows);
}
function renderTable(key, items) {
if (key === "orders") return renderOrdersTable(items);
if (key === "keys") return renderKeysTable(items);
if (key === "strategy") return renderStrategyTable(items);
if (key === "options") return renderOptionsTable(items);
if (key === "hedge_plan") return renderHedgeTable(items);
return "";
}
function sectionHasData(sec) {
if (!sec) return false;
const count = Number(sec.count);
if (Number.isFinite(count) && count > 0) return true;
return Array.isArray(sec.items) && sec.items.length > 0;
}
function renderSection(key, sec) {
if (!sectionHasData(sec)) return "";
const items = sec.items || [];
const count = Number(sec.count) || items.length;
return (
'<section class="inst-dash-section" data-dash-section="' +
escapeHtml(key) +
'">' +
'<div class="inst-dash-section-head">' +
"<h3>" +
escapeHtml(sec.title || key) +
' <span class="inst-dash-count">' +
count +
"</span></h3>" +
'<button type="button" class="btn-sm inst-dash-goto" data-dash-tab="' +
escapeHtml(sec.tab || "") +
'">打开</button>' +
"</div>" +
renderTable(key, items) +
"</section>"
);
}
function bindClicks(el) {
if (!el) return;
el.querySelectorAll("[data-dash-tab]").forEach(function (node) {
const handler = function () {
goTab(node.getAttribute("data-dash-tab"));
};
node.addEventListener("click", handler);
node.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
handler();
}
});
});
}
async function load(opts) {
const el = root();
if (!el) return;
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
const sections = el.querySelector("#inst-dash-sections") || document.getElementById("inst-dash-sections");
const updated = el.querySelector("#inst-dash-updated") || document.getElementById("inst-dash-updated");
const options = opts || {};
if (loading && !options.force) return;
loading = true;
if (status && !options.silent) status.textContent = "同步中…";
try {
const dashRes = await fetch("/api/instance/dashboard", { credentials: "same-origin" });
if (dashRes.status === 401) {
location.href = "/login?next=" + encodeURIComponent(location.pathname);
return;
}
const data = await dashRes.json().catch(function () {
return {};
});
if (!dashRes.ok || !data.ok) {
if (
data.aggregating ||
(data.msg && String(data.msg).indexOf("尚未就绪") >= 0)
) {
if (status) status.textContent = "后台聚合中…";
return;
}
throw new Error(data.msg || data.error || dashRes.statusText || "加载失败");
}
const ver = Number(data.dashboard_version) || 0;
if (ver) localDashVersion = ver;
if (data.orders && Array.isArray(data.orders.items)) {
data.orders.count = data.orders.items.length;
}
if (updated) updated.textContent = "更新 " + (data.updated_at || "—");
if (sections) {
const html = SECTION_ORDER.map(function (k) {
return renderSection(k, data[k]);
}).join("");
sections.innerHTML =
html || '<p class="inst-dash-empty muted">当前无活跃监控与持仓</p>';
bindClicks(sections);
if (global.OptionsExpiryCountdown) {
if (typeof global.OptionsExpiryCountdown.tick === "function") {
global.OptionsExpiryCountdown.tick(sections);
}
if (typeof global.OptionsExpiryCountdown.ensureTimer === "function") {
global.OptionsExpiryCountdown.ensureTimer();
}
}
}
const sec = Number(data.poll_interval_sec) || 5;
if (status) {
status.textContent = options.silent
? "SSE 已连接 · 后台每 " + sec + "s 聚合"
: "已更新 · 后台每 " + sec + "s 聚合";
}
} catch (e) {
if (status) status.textContent = e.message || "加载失败";
} finally {
loading = false;
}
}
function closeDashboardStream() {
if (dashEventSource) {
dashEventSource.close();
dashEventSource = null;
}
if (dashReconnectTimer) {
clearTimeout(dashReconnectTimer);
dashReconnectTimer = null;
}
}
function connectDashboardStream() {
const el = root();
if (!el) return;
closeDashboardStream();
dashEventSource = new EventSource("/api/instance/dashboard/stream");
dashEventSource.addEventListener("dashboard", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.dashboard_version) || 0;
if (ver && ver !== localDashVersion) {
load({ silent: true });
} else if (st.aggregating) {
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
if (status) status.textContent = "后台聚合中…";
}
} catch (_) {}
});
dashEventSource.onerror = function () {
closeDashboardStream();
const status = el.querySelector("#inst-dash-status") || document.getElementById("inst-dash-status");
if (status) status.textContent = "SSE 断开,8s 后重连…";
dashReconnectTimer = setTimeout(function () {
if (booted) {
connectDashboardStream();
load({ silent: true });
}
}, 8000);
};
}
async function requestDashboardRefresh() {
try {
await fetch("/api/instance/dashboard/refresh", {
method: "POST",
credentials: "same-origin",
});
} catch (_) {}
load({ force: true });
}
function stopAuto() {
closeDashboardStream();
}
function init(force) {
const el = root();
if (!el) return;
if (!force && el.getAttribute("data-dash-booted") === "1") {
booted = true;
load({ silent: true });
connectDashboardStream();
return;
}
el.setAttribute("data-dash-booted", "1");
booted = true;
const btn = el.querySelector("#inst-dash-refresh") || document.getElementById("inst-dash-refresh");
if (btn && !btn.getAttribute("data-bound")) {
btn.setAttribute("data-bound", "1");
btn.addEventListener("click", function () {
requestDashboardRefresh();
});
}
load({});
connectDashboardStream();
}
function refreshSoft(opts) {
load(Object.assign({ silent: true }, opts || {}));
}
global.InstanceDashboard = {
init: init,
refreshSoft: refreshSoft,
load: load,
stopAuto: stopAuto,
};
})(window);
+595
View File
@@ -0,0 +1,595 @@
/**
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>.
* 各 tab 面板常驻 DOM,切换时 show/hide;脚本延后到首次激活,回访零请求.
*/
(function (global) {
const TAB_PATH = {
dashboard: "/dashboard",
account_ledger: "/account_ledger",
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
options: "/options",
options_review: "/options/review",
hedge_plan: "/hedge-plan",
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
let navToken = 0;
let loadingTab = false;
let pendingTabLoad = null;
const tabPanes = new Map();
const tabBooted = new Set();
/** 自带 AJAX/校验提交的表单,勿在捕获阶段再 fetch+reloadCurrentTab(会卡在「加载中…」) */
const CUSTOM_SUBMIT_FORM_IDS = new Set([
"add-order-form",
"key-form",
"roll-form",
"journal-form",
]);
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function getTab() {
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return document.body.getAttribute("data-page") || "trade";
}
function listWindowQueryString() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString();
}
return "";
}
function pageRoot() {
return document.getElementById("embed-page-root");
}
function setNavActive(tab) {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab);
});
if (global.InstanceMobileNav && typeof global.InstanceMobileNav.onTabChange === "function") {
global.InstanceMobileNav.onTabChange(tab);
} else if (global.InstanceMobileNav && typeof global.InstanceMobileNav.syncTabActive === "function") {
global.InstanceMobileNav.syncTabActive(tab);
}
}
function pageNavAllowed(tab) {
if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") {
return global.InstanceSettingsPrefs.pageNavAllowed(tab);
}
return true;
}
function syncUrl(tab, replace) {
const q = new URLSearchParams(location.search);
q.set("tab", tab);
q.set("embed", "1");
const qs = q.toString();
const url = "/embed?" + qs;
if (replace) history.replaceState({ embedTab: tab }, "", url);
else history.pushState({ embedTab: tab }, "", url);
}
function notifyParentTabSwitch(tab) {
try {
window.parent.postMessage({ type: "instance-frame-navigating", embedShellTab: true, tab: tab }, "*");
} catch (_) {}
}
function runPageInit(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
document.body.setAttribute("data-page", tab);
if (!revisit && typeof global.attachListWindowToExports === "function") {
global.attachListWindowToExports();
}
if (tab === "trade") {
if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
if (!revisit && typeof global.initOrderEntryModelSelect === "function") {
const root = pageRoot() || document;
global.initOrderEntryModelSelect(root);
}
if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
global.ManualOrderRrPreview.wire();
}
}
if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
global.KeyMonitorForm.init();
}
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
global.InstanceDashboard.init(!!revisit);
}
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
global.AccountLedgerPage.boot();
}
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
global.initStrategyRollForm();
}
if (tab === "records") {
if (global.RecordsReviewPage && typeof global.RecordsReviewPage.init === "function") {
global.RecordsReviewPage.init({ refresh: !!revisit });
} else {
if (!revisit && typeof global.loadJournals === "function") global.loadJournals();
if (!revisit && typeof global.loadReviews === "function") global.loadReviews();
}
if (global.InstanceTheme && typeof global.InstanceTheme.initReviewEditModeSync === "function") {
global.InstanceTheme.initReviewEditModeSync();
} else if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
if (tab === "stats") {
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
}
if (tab === "settings" || tab === "env_config") {
if (global.InstanceSettingsPrefs) {
if (typeof global.InstanceSettingsPrefs.bindEvents === "function") {
global.InstanceSettingsPrefs.bindEvents();
}
if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") {
global.InstanceSettingsPrefs.loadDisplayPrefsForm();
}
if (tab === "env_config") {
if (typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") {
global.InstanceSettingsPrefs.loadEnvConfig();
}
if (typeof global.InstanceSettingsPrefs.bindEnvTabs === "function") {
global.InstanceSettingsPrefs.bindEnvTabs();
}
}
}
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot({ silent: true });
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
const root = pageRoot() || document;
global.SymbolLivePrice.init(root);
}
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
const root = pageRoot() || document;
global.JournalUploadSlots.init(root);
}
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
}
}
function runScripts(container) {
container.querySelectorAll("script").forEach((old) => {
const s = document.createElement("script");
if (old.src) s.src = old.src;
else s.textContent = old.textContent;
old.replaceWith(s);
});
}
function showPane(tab) {
tabPanes.forEach((pane, name) => {
const on = name === tab;
pane.hidden = !on;
pane.classList.toggle("is-active-pane", on);
});
}
function bootPaneScripts(tab) {
if (tabBooted.has(tab)) return;
const pane = tabPanes.get(tab);
if (!pane) return;
runScripts(pane);
tabBooted.add(tab);
}
function mountPane(tab, html) {
const root = pageRoot();
if (!root) return null;
const existing = tabPanes.get(tab);
if (existing) existing.remove();
const pane = document.createElement("div");
pane.className = "embed-tab-pane";
pane.setAttribute("data-embed-pane", tab);
pane.hidden = true;
const holder = document.createElement("div");
holder.innerHTML = html;
while (holder.firstChild) pane.appendChild(holder.firstChild);
root.appendChild(pane);
tabPanes.set(tab, pane);
return pane;
}
function initBootPane() {
const root = pageRoot();
if (!root || tabPanes.size > 0) return;
const tab = getTab();
if (root.querySelector("[data-embed-pane]")) return;
if (!root.childNodes.length) return;
const pane = document.createElement("div");
pane.className = "embed-tab-pane is-active-pane";
pane.setAttribute("data-embed-pane", tab);
Array.from(root.childNodes).forEach((node) => pane.appendChild(node));
root.appendChild(pane);
tabPanes.set(tab, pane);
tabBooted.add(tab);
showPane(tab);
}
function embedPageUrl(tab) {
const qs = listWindowQueryString();
let url = "/api/embed/page/" + encodeURIComponent(tab);
const parts = [];
if (qs) parts.push(qs);
parts.push("embed=1");
if (tab === "settings") {
try {
const st = new URLSearchParams(location.search).get("settings_tab");
if (st) parts.push("settings_tab=" + encodeURIComponent(st));
} catch (_) {}
}
return url + "?" + parts.join("&");
}
function setSettingsSubTabInUrl(key) {
if (!key) return;
try {
const q = new URLSearchParams(location.search);
q.set("tab", "settings");
q.set("settings_tab", key);
q.set("embed", "1");
history.replaceState(null, "", "/embed?" + q.toString());
} catch (_) {}
}
function activateSettingsSubTab(key) {
if (!key) return;
setSettingsSubTabInUrl(key);
const pane = tabPanes.get("settings") || document;
const radio = pane.querySelector(
'input.env-tab-radio[data-settings-tab="' + key + '"]'
);
if (radio) radio.checked = true;
}
function formActionPath(form) {
try {
return new URL(form.action || "", location.href).pathname.replace(/\/$/, "") || "/";
} catch (_) {
return "";
}
}
function maybeKeepSettingsSubTabAfterForm(form) {
const path = formActionPath(form);
if (path === "/manual_transfer") {
setSettingsSubTabInUrl("transfer");
return "transfer";
}
if (path.indexOf("/api/options/transfer") >= 0) {
setSettingsSubTabInUrl("options_transfer");
return "options_transfer";
}
return "";
}
async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin",
cache: "no-store",
headers: { "X-Instance-Soft-Nav": "1" },
});
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (!ct.includes("application/json")) {
throw new Error("加载失败(HTTP " + r.status + ")");
}
const j = await r.json();
if (!j.ok || !j.html) throw new Error(j.msg || "加载失败");
return j.html;
}
function warmTabCache(tab) {
if (!tab || tabPanes.has(tab) || loadingTab) return;
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {});
}
function preloadAllTabs() {
const tabs = Object.keys(TAB_PATH);
const current = getTab();
const heavyLast = new Set(["options", "records", "stats"]);
const ordered = tabs.filter((t) => t !== current && !heavyLast.has(t))
.concat(tabs.filter((t) => heavyLast.has(t) && t !== current));
let idx = 0;
function step() {
if (idx >= ordered.length) return;
const tab = ordered[idx++];
if (tabPanes.has(tab)) {
step();
return;
}
fetchTabHtml(tab)
.then((html) => {
if (!tabPanes.has(tab)) mountPane(tab, html);
})
.catch(() => {})
.finally(() => {
setTimeout(step, heavyLast.has(tab) ? 400 : 180);
});
}
const ric = global.requestIdleCallback || function (fn) {
setTimeout(fn, 2000);
};
ric(step);
}
function clearTabCache() {
tabPanes.forEach((pane) => pane.remove());
tabPanes.clear();
tabBooted.clear();
}
function syncShellChrome(tab) {
const hideTopBar =
tab === "settings" || tab === "risk_policy" || tab === "system_guide" || tab === "env_config";
document.querySelectorAll(".instance-top-bar").forEach((el) => {
el.hidden = hideTopBar;
});
}
function initPaneThemeToggle(tab) {
if (tab !== "settings") return;
const pane = tabPanes.get(tab);
if (!pane || !global.InstanceTheme) return;
if (typeof global.InstanceTheme.initToggleUI === "function") {
global.InstanceTheme.initToggleUI(pane);
}
if (typeof global.InstanceTheme.syncToggleUI === "function") {
global.InstanceTheme.syncToggleUI(pane);
}
}
function activateTab(tab, opts) {
const options = opts || {};
const revisit = !!options.revisit;
const firstBoot = !tabBooted.has(tab);
syncShellChrome(tab);
showPane(tab);
setNavActive(tab);
if (!options.skipUrl) syncUrl(tab, !!options.replace);
notifyParentTabSwitch(tab);
if (firstBoot) {
bootPaneScripts(tab);
initPaneThemeToggle(tab);
runPageInit(tab, { revisit: false });
return;
}
if (revisit) {
document.body.setAttribute("data-page", tab);
runPageInit(tab, { revisit: true });
return;
}
runPageInit(tab, { revisit: false });
}
async function loadTab(tab, opts) {
const options = opts || {};
if (!tab) return;
if (!pageNavAllowed(tab)) {
void loadTab("trade", { replace: true });
return;
}
if (tabPanes.has(tab) && !options.force) {
activateTab(tab, Object.assign({}, options, { revisit: true }));
return;
}
if (loadingTab) {
pendingTabLoad = { tab: tab, opts: options };
return;
}
const token = ++navToken;
loadingTab = true;
try {
const html = await fetchTabHtml(tab);
if (token !== navToken) return;
mountPane(tab, html);
activateTab(tab, options);
} catch (e) {
if (token === navToken) {
const flash = document.getElementById("embed-flash");
if (flash) {
flash.style.display = "";
flash.textContent = String(e && e.message ? e.message : e);
}
}
} finally {
if (token === navToken) loadingTab = false;
if (pendingTabLoad) {
const pending = pendingTabLoad;
pendingTabLoad = null;
if (pending.tab !== tab) void loadTab(pending.tab, pending.opts);
}
}
}
function reloadCurrentTab() {
const tab = getTab();
const pane = tabPanes.get(tab);
if (pane) pane.remove();
tabPanes.delete(tab);
tabBooted.delete(tab);
return loadTab(tab, { replace: true, skipUrl: true, force: true });
}
function postFormAndReload(form, label) {
if (!form) return Promise.resolve();
if (global.FormSubmitGuard) {
if (global.FormSubmitGuard.isLocked(form)) {
global.FormSubmitGuard.setSubmitLabel(form, label || "提交中…");
} else {
global.FormSubmitGuard.lock(form, label || "提交中…");
}
}
const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
return fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
}
function patchApplyListWindow() {
if (typeof global.applyListWindow !== "function") return;
global.applyListWindow = function embedApplyListWindow() {
clearTabCache();
const qs = listWindowQueryString();
const tab = getTab();
const q = new URLSearchParams(qs);
q.set("tab", tab);
q.set("embed", "1");
window.location.href = "/embed?" + q.toString();
};
}
function patchHardNavigations() {
const resubmitPaths =
/^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/;
document.addEventListener(
"click",
(ev) => {
if (!isEmbedShell()) return;
const a = ev.target.closest("a[href]");
if (!a || ev.defaultPrevented) return;
if (a.closest(".embed-top-nav")) return;
if (a.hasAttribute("download") || a.target === "_blank") return;
const raw = a.getAttribute("href");
if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return;
let url;
try {
url = new URL(raw, location.href);
} catch (_) {
return;
}
if (url.origin !== location.origin) return;
if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) {
return;
}
if (!resubmitPaths.test(url.pathname)) return;
ev.preventDefault();
fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" })
.then(() => reloadCurrentTab())
.catch(() => reloadCurrentTab());
},
false
);
document.addEventListener(
"submit",
(ev) => {
if (!isEmbedShell()) return;
const form = ev.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.method && form.method.toUpperCase() === "GET") return;
if (CUSTOM_SUBMIT_FORM_IDS.has(form.id)) return;
ev.preventDefault();
const fd = new FormData(form);
const keepSub = maybeKeepSettingsSubTabAfterForm(form);
fetch(form.action, {
method: form.method || "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
})
.then(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)))
.catch(() => reloadCurrentTab().then(() => activateSettingsSubTab(keepSub)));
},
true
);
}
function bindNav() {
document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("mouseenter", () => {
warmTabCache(a.getAttribute("data-embed-tab"));
});
a.addEventListener("click", (ev) => {
ev.preventDefault();
const tab = a.getAttribute("data-embed-tab");
if (!tab || tab === getTab()) return;
void loadTab(tab);
});
});
window.addEventListener("popstate", () => {
const tab = getTab();
void loadTab(tab, { replace: true, skipUrl: true });
});
}
function boot() {
if (!isEmbedShell()) return;
patchApplyListWindow();
patchHardNavigations();
initBootPane();
const bootTab = getTab();
if (!pageNavAllowed(bootTab)) {
void loadTab("trade", { replace: true });
return;
}
if (bootTab === "settings") {
initPaneThemeToggle("settings");
}
bindNav();
syncShellChrome(getTab());
runPageInit(getTab());
preloadAllTabs();
try {
window.parent.postMessage({ type: "instance-frame-ready" }, "*");
} catch (_) {}
}
global.InstanceEmbed = {
loadTab,
reloadCurrentTab,
getTab,
postFormAndReload,
clearTabCache,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+111
View File
@@ -0,0 +1,111 @@
/**
* embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML.
*/
(function (global) {
let liveEventSource = null;
let liveReconnectTimer = null;
let localLiveVersion = -1;
let sseConnected = false;
let refreshTimer = null;
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function currentTab() {
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
return global.InstanceEmbed.getTab();
}
return document.body.getAttribute("data-page") || "trade";
}
function refreshTabData(tab, opts) {
const options = opts || {};
if (typeof global.refreshAccountSnapshot === "function") {
global.refreshAccountSnapshot(options);
}
if (typeof global.refreshPriceSnapshotConditional === "function") {
global.refreshPriceSnapshotConditional();
}
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
global.OptionsPanelLive.refreshSoft(options);
}
// 数据看板自有 SSE + 快照,不跟 embed live tick 重拉.
}
function scheduleRefresh(opts) {
if (refreshTimer) return;
const options = opts || {};
refreshTimer = setTimeout(function () {
refreshTimer = null;
if (document.hidden) return;
refreshTabData(currentTab(), { silent: true, force: !!options.force });
}, 80);
}
function onLiveEvent(data) {
const reason = data && data.reason;
const ver = Number(data && data.live_version) || 0;
if (!ver) return;
if (reason === "connect") {
localLiveVersion = ver;
scheduleRefresh();
return;
}
if (ver === localLiveVersion) return;
localLiveVersion = ver;
scheduleRefresh({ force: reason === "balance" });
}
function closeLiveStream() {
if (liveEventSource) {
liveEventSource.close();
liveEventSource = null;
}
if (liveReconnectTimer) {
clearTimeout(liveReconnectTimer);
liveReconnectTimer = null;
}
sseConnected = false;
}
function connectLiveStream() {
if (!isEmbedShell()) return;
closeLiveStream();
liveEventSource = new EventSource("/api/instance/live/stream");
liveEventSource.addEventListener("live", function (ev) {
try {
onLiveEvent(JSON.parse(ev.data || "{}"));
} catch (_) {}
});
liveEventSource.onopen = function () {
sseConnected = true;
};
liveEventSource.onerror = function () {
sseConnected = false;
closeLiveStream();
liveReconnectTimer = setTimeout(function () {
connectLiveStream();
}, 8000);
};
}
function startLive() {
if (!isEmbedShell()) return;
connectLiveStream();
}
global.InstanceLive = {
start: startLive,
refreshTabData: refreshTabData,
isConnected: function () {
return sseConnected;
},
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startLive);
} else {
startLive();
}
})(typeof window !== "undefined" ? window : globalThis);
+177
View File
@@ -0,0 +1,177 @@
/**
* 实例手机壳: ≤720px 底栏 +「更多」,与 embed soft-nav 同步.
*/
(function (global) {
const PRIMARY = { trade: 1, key_monitor: 1, options: 1 };
const MQ = "(max-width: 720px)";
function isEmbedShell() {
return document.body && document.body.getAttribute("data-embed-shell") === "1";
}
function isMobileLayout() {
return window.matchMedia(MQ).matches;
}
function syncPhoneClass() {
if (!document.body) return;
document.body.classList.toggle("inst-phone", isMobileLayout());
}
function currentTab() {
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
return global.InstanceEmbed.getTab();
}
try {
const t = new URLSearchParams(location.search).get("tab");
if (t) return t;
} catch (_) {}
return (document.body && document.body.getAttribute("data-page")) || "trade";
}
function closeMore() {
document.body.classList.remove("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "true");
if (btn) btn.setAttribute("aria-expanded", "false");
syncTabActive(currentTab());
}
function openMore() {
if (!isMobileLayout()) return;
document.body.classList.add("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "false");
if (btn) btn.setAttribute("aria-expanded", "true");
syncTabActive(currentTab());
}
function toggleMore() {
if (document.body.classList.contains("inst-mobile-more-open")) closeMore();
else openMore();
}
function syncTabActive(tab) {
const page = tab || currentTab();
const primary = !!PRIMARY[page];
const moreOpen = document.body.classList.contains("inst-mobile-more-open");
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab").forEach((el) => {
const t = el.getAttribute("data-embed-tab") || "";
let on = false;
if (t === "more") on = moreOpen || !primary;
else on = !moreOpen && t === page;
el.classList.toggle("active", on);
});
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
a.classList.toggle("active", a.getAttribute("data-embed-tab") === page);
});
}
/** embed 切页时关闭「更多」并同步高亮 */
function onTabChange(tab) {
document.body.classList.remove("inst-mobile-more-open");
const more = document.getElementById("inst-mobile-more");
const btn = document.getElementById("inst-m-tab-more");
if (more) more.setAttribute("aria-hidden", "true");
if (btn) btn.setAttribute("aria-expanded", "false");
syncTabActive(tab);
}
function goTab(tab) {
if (!tab || tab === "more") return;
closeMore();
if (global.InstanceEmbed && typeof global.InstanceEmbed.loadTab === "function") {
if (tab === currentTab()) {
syncTabActive(tab);
return;
}
void global.InstanceEmbed.loadTab(tab);
return;
}
const pathMap = {
dashboard: "/dashboard",
key_monitor: "/key_monitor",
trade: "/trade",
strategy: "/strategy",
strategy_records: "/strategy/records",
options: "/options",
options_review: "/options/review",
hedge_plan: "/hedge-plan",
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
};
location.href = pathMap[tab] || "/trade";
}
function bindChrome() {
const moreBtn = document.getElementById("inst-m-tab-more");
const backdrop = document.getElementById("inst-mobile-more-backdrop");
const closeBtn = document.getElementById("inst-mobile-more-close");
if (moreBtn) {
moreBtn.addEventListener("click", (ev) => {
ev.preventDefault();
toggleMore();
});
}
if (backdrop) backdrop.addEventListener("click", closeMore);
if (closeBtn) closeBtn.addEventListener("click", closeMore);
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && document.body.classList.contains("inst-mobile-more-open")) {
closeMore();
}
});
document.querySelectorAll("#inst-mobile-tabbar .inst-m-tab[data-embed-tab]").forEach((el) => {
if (el.getAttribute("data-embed-tab") === "more") return;
el.addEventListener("click", (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
ev.preventDefault();
goTab(el.getAttribute("data-embed-tab"));
});
});
document.querySelectorAll("#inst-mobile-more .inst-mobile-more-nav [data-embed-tab]").forEach((a) => {
a.addEventListener("click", (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
ev.preventDefault();
goTab(a.getAttribute("data-embed-tab"));
});
});
}
function boot() {
if (!isEmbedShell()) return;
if (!document.getElementById("inst-mobile-tabbar")) return;
syncPhoneClass();
bindChrome();
syncTabActive(currentTab());
let resizeTimer = null;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const was = document.body.classList.contains("inst-phone");
syncPhoneClass();
if (!isMobileLayout()) closeMore();
else if (!was) syncTabActive(currentTab());
}, 120);
});
}
global.InstanceMobileNav = {
syncTabActive,
onTabChange,
closeMore,
isMobileLayout,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})(typeof window !== "undefined" ? window : globalThis);
+332
View File
@@ -0,0 +1,332 @@
.order-trade-style-hint{font-size:.78rem;color:#8fc8ff;margin-left:4px;white-space:nowrap}
.order-entry-model-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em}
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px}
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
.stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152}
.stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%}
.stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150}
.full{grid-column:1/-1}
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
/* 实盘下单监控:分层布局 */
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
.order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px}
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
.order-monitor-form .om-submit.is-blocked,
.order-monitor-form .om-submit:disabled{
opacity:.45;
cursor:not-allowed;
filter:grayscale(.35);
pointer-events:none;
}
.order-monitor-form .om-open-block-note{
color:var(--danger,#ff7b7b);
font-size:13px;
line-height:1.4;
max-width:min(28rem,100%);
}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f}
.order-preview-profit strong{color:#6ee7a0;font-weight:600}
.order-preview-rr{color:#cfd3ef}
.order-preview-rr strong{font-weight:600;color:#dbe4ff}
.order-preview-rr.order-preview-rr-low strong{color:#ff8f8f}
.order-preview-rr.order-preview-rr-ok strong{color:#8fc8ff}
.form-row > button,.form-row > label{flex:0 0 auto}
.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
/* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */
.journal-card .form-grid{gap:10px}
.journal-card .form-grid > input,
.journal-card .form-grid > select{
min-width:0;
width:100%;
max-width:100%;
box-sizing:border-box;
}
.journal-card #journal-form textarea[name="note"]{
display:block;width:100%;max-width:100%;box-sizing:border-box;margin-top:8px;
}
input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none}
button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer}
.list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto}
.list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px}
.btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem}
.rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem}
th{color:#a9a9ff}
.badge{padding:2px 6px;border-radius:6px;font-size:.72rem}
.profit{background:#1e332f;color:#4cd97f}
.loss{background:#331e24;color:#ff6666}
.miss{background:#29241e;color:#eac147}
.direction{background:#1e2533;color:#4cc2ff}
.direction-long{background:#1e332f;color:#4cd97f}
.direction-short{background:#331e24;color:#ff6666}
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
.ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0}
.ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5}
.ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600}
.ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45}
.ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px}
.ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em}
.ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600}
.price-up{color:#4cd97f}
.price-down{color:#ff6666}
.price-flat{color:#cfd3ef}
.panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto}
.entry{border-bottom:1px solid #2b2b43;padding:8px 0}
.entry:last-child{border-bottom:none}
.table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem}
.mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea}
.mood-grid label{display:flex;align-items:center;gap:3px}
.screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px}
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:2100}
.modal img{max-width:90%;max-height:90%;border-radius:8px}
.detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px}
.detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px}
.detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}
.detail-modal .panel-title{font-size:1rem;color:#dbe4ff}
.detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer}
.detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff}
.detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150}
.detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0}
.detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem}
.detail-modal.fullscreen{padding:10px}
.detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden}
.detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem}
.ai-result-wrap{margin-top:8px}
.ai-result-toolbar{display:flex;gap:8px;margin-top:6px}
.ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer}
.table-wrap{overflow-x:auto}
.dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch}
.dual-panel-grid .card{height:100%;display:flex;flex-direction:column}
.panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto}
.records-card{grid-column:1/-1}
.review-card{grid-column:1/-1}
.review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}
.review-card-head h2{margin:0}
.review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap}
.review-card-fs-btn:hover{filter:brightness(1.08)}
body.review-card-fullscreen-open{overflow:hidden}
.review-card.is-fullscreen{
position:fixed;inset:12px;z-index:1100;margin:0;
width:auto !important;max-width:none;height:auto;
overflow:auto;display:flex;flex-direction:column;
box-shadow:0 12px 48px rgba(0,0,0,.55);
}
.review-card.is-fullscreen .panel-list{flex:1;min-height:320px}
.review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px}
.review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)}
@media (max-width: 1200px){
.stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
}
@media (min-width: 1440px){
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
@media (min-width: 2200px){
.container{max-width:min(1720px,90vw)}
}
@media (min-width: 2560px){
.container{max-width:min(1860px,88vw)}
.dual-panel-grid{gap:18px}
}
@media (min-width: 3000px){
.container{max-width:min(1980px,86vw)}
.pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
}
@media (max-width: 1100px){
.grid{grid-template-columns:1fr}
.dual-panel-grid{grid-template-columns:1fr}
.records-card,.review-card{grid-column:auto}
.panel-list{grid-template-columns:1fr}
}
@media (max-width: 960px){
body{padding:10px}
.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.stat-box{grid-template-columns:repeat(2,minmax(0,1fr))}
}
.stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px}
.stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px}
.stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem}
.stats-detail .stat-item .label{font-size:.75rem}
.stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all}
.export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem}
.export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a}
.export-bar a:hover{background:#1f2740}
.list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem}
.list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px}
.stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468}
.stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px}
.stats-period-tabs{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;position:relative;z-index:2}
.stats-period-tab{background:#151a2a;color:#9aa3bf;border:1px solid #304164;border-radius:8px;padding:7px 14px;font-size:.84rem;cursor:pointer;transition:background .15s,border-color .15s,color .15s}
.stats-period-tab:hover{background:#1c2438;color:#cfd3ef}
.stats-period-tab.active{background:#1f3a5a;color:#8fc8ff;border-color:#3d5f8a;font-weight:600}
.stats-period-pane[hidden]{display:none!important}
.stats-period-range{font-size:.78rem;color:#8892b0;margin-bottom:12px;line-height:1.45}
.inst-stats-viz{display:flex;flex-direction:column;gap:14px;margin-bottom:14px}
.inst-stats-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.inst-stats-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:12px 10px;background:#151a2a;border:1px solid #2a3152;border-radius:10px;text-align:center;min-height:88px}
.inst-stats-kpi-val{font-size:1.15rem;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2}
.inst-stats-kpi-lbl{font-size:.72rem;color:#8892b0;line-height:1.3}
.inst-stats-ring{--win-pct:0;width:56px;height:56px;border-radius:50%;background:conic-gradient(#4cd97f 0 calc(var(--win-pct) * 1%),#ff6b6b calc(var(--win-pct) * 1%) 100%);display:flex;align-items:center;justify-content:center;position:relative}
.inst-stats-ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:#151a2a}
.inst-stats-ring-label{position:relative;z-index:1;font-size:.78rem;font-weight:700;font-variant-numeric:tabular-nums}
.inst-stats-block{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-stats-block-title{font-size:.72rem;color:#8892b0;margin-bottom:8px}
.inst-stats-stacked-bar{display:flex;height:10px;border-radius:6px;overflow:hidden;background:#1e2438}
.inst-stats-stacked-fill{height:100%;min-width:0;transition:width .2s ease}
.inst-stats-stacked-fill--profit{background:#4cd97f}
.inst-stats-stacked-fill--loss{background:#ff6b6b}
.inst-stats-bar-labels{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:.76rem;font-variant-numeric:tabular-nums}
.inst-stats-risk-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
.inst-stats-risk-item{display:flex;flex-direction:column;gap:3px;min-width:0}
.inst-stats-risk-item .k{font-size:.7rem;color:#8892b0}
.inst-stats-risk-item .v{font-size:.84rem;font-weight:600;font-variant-numeric:tabular-nums;color:#e8ecf4;word-break:break-word}
.inst-stats-empty{margin:0;padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
.inst-stats-details{margin-top:4px}
.inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside}
.inst-stats-details>summary::-webkit-details-marker{color:#6d7689}
.inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef}
.inst-stats-month-table-wrap{overflow:auto;-webkit-overflow-scrolling:touch}
.inst-stats-month-table{width:100%;border-collapse:collapse;font-size:.8rem;font-variant-numeric:tabular-nums}
.inst-stats-month-table th,.inst-stats-month-table td{padding:8px 10px;text-align:right;border-bottom:1px solid #2a3348;white-space:nowrap}
.inst-stats-month-table th:first-child,.inst-stats-month-table td:first-child{text-align:left}
.inst-stats-month-table th{color:#8892b0;font-weight:600;font-size:.72rem}
.inst-stats-month-table td{color:#e8ecf4}
.inst-stats-month-table tbody tr:last-child td{border-bottom:none}
@media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}}
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
.key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px}
.key-history .list{max-height:200px}
.pos-section{margin-top:12px}
.pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500}
.pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
.dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto}
.dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible}
.pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px}
.pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0}
.pos-meta-item{display:inline-flex;align-items:center}
.pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659}
.pos-meta-on{color:#6eb5ff}
.pos-meta-off{color:#7d8799}
.pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f}
.pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
.pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600}
.pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2}
.pos-side-long{background:#253a6e;color:#6eb5ff}
.pos-side-short{background:#4a2230;color:#ff8a8a}
.pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap}
.pos-entrust-btn:hover{background:#355d96}
.pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block}
.pos-close-btn:hover{background:#d66565;color:#fff}
.pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348}
.pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px}
.pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px}
.pos-ex-order-main{flex:1;min-width:0;line-height:1.35}
.pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0}
.pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed}
.tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px}
.tpsl-modal-backdrop.open{display:flex}
.tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto}
.tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff}
.tpsl-modal .form-row{margin-bottom:10px}
.tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.tpsl-modal-submit{background:#2d6a4f;color:#fff}
.tpsl-modal-cancel{background:#3a3f52;color:#ddd}
.review-entry-reason-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9100;align-items:center;justify-content:center;padding:16px}
.review-entry-reason-backdrop.open{display:flex}
.review-entry-reason-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(480px,100%);max-height:90vh;overflow:auto}
.review-entry-reason-modal h3{margin:0 0 8px;font-size:1rem;color:#fff}
.review-entry-reason-hint{margin:0 0 12px;font-size:.82rem;color:#9aa3c7;line-height:1.45}
.review-entry-reason-select{width:100%;padding:8px 10px;border-radius:8px;border:1px solid #3a4a66;background:#121726;color:#e8ecff;font-size:.9rem}
.review-entry-reason-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
.review-entry-reason-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem}
.review-entry-reason-ok{background:#2d6a4f;color:#fff}
.review-entry-reason-cancel{background:#3a3f52;color:#ddd}
.pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px}
.pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0}
.pos-label{font-size:.72rem;color:#7d8799}
.pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25}
.pos-val-dash{opacity:.75;color:#8b95a8}
.pos-value.price-up{color:#4cd97f}
.pos-value.price-down{color:#ff6666}
.pos-value.price-flat{color:#e8ecf4}
.pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689}
.pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px}
@media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}}
.stats-card{grid-column:1/-1;margin-top:14px}
.stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer}
.stats-card.collapsed .stats-content{display:none}
.stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150}
.stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px}
.stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
#embed-page-root{min-height:120px;position:relative}
.embed-tab-pane[hidden]{display:none!important}
.inst-dash-card{grid-column:1/-1}
.inst-dash-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:6px}
.inst-dash-card > .inst-dash-head h2{font-size:.88rem;margin:0 0 2px;font-weight:600}
.inst-dash-desc{margin:0;font-size:.72rem;line-height:1.35}
.inst-dash-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.inst-dash-updated{font-size:.72rem}
.inst-dash-status{min-height:1.1em;margin:0 0 8px;font-size:.72rem}
.inst-dash-sections{display:flex;flex-direction:column;gap:14px}
.inst-dash-section{padding:12px;background:#141923;border:1px solid #2a3150;border-radius:10px}
.inst-dash-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px}
.inst-dash-section-head h3{margin:0;font-size:.95rem;color:#dbe4ff}
.inst-dash-count{display:inline-block;min-width:1.4em;padding:1px 7px;margin-left:4px;border-radius:999px;background:#1f3a5a;color:#8fc8ff;font-size:.75rem;font-weight:600}
.inst-dash-empty{margin:0;padding:14px;text-align:center;border:1px dashed #2a3348;border-radius:8px;font-size:.84rem}
.inst-dash-table-wrap{overflow:auto;border:1px solid #2a3150;border-radius:8px}
.inst-dash-table{width:100%;border-collapse:collapse;font-size:.84rem}
.inst-dash-table th,.inst-dash-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #25253b;white-space:nowrap}
.inst-dash-table th{color:#a9a9ff;background:#151a2a;font-weight:600}
.inst-dash-table tbody tr:last-child td{border-bottom:none}
.inst-dash-table tbody tr.inst-dash-row{cursor:pointer}
.inst-dash-table tbody tr.inst-dash-row:hover{background:#1e2740}
.inst-dash-sym-link{color:#8fc8ff;text-decoration:underline}
.inst-dash-dir-long{color:#4cd97f;font-weight:600}
.inst-dash-dir-short{color:#ff6666;font-weight:600}
.inst-dash-status-active{color:#4cd97f;font-weight:600}
.inst-dash-table .pos-tp-profit{color:#cfd3ef}
@@ -0,0 +1,74 @@
/**
* 手机端:交易记录 / 复盘记录紧凑列表(币种 · 方向 · 盈亏),点击展开详情.
*/
(function (global) {
"use strict";
var resizeTimer = null;
function refreshTradeRecords() {
var UI = global.InstanceUI;
if (!UI) return;
var card = document.querySelector(".records-card");
if (!card) return;
var tableWrap = card.querySelector(".table-wrap");
var table = tableWrap && tableWrap.querySelector("table");
if (!table) return;
var listEl = card.querySelector(".mobile-record-list");
var mobile = UI.isMobileCompactRecords();
if (!mobile) {
if (listEl) listEl.remove();
return;
}
if (!listEl) {
listEl = document.createElement("div");
listEl.className = "mobile-record-list";
tableWrap.parentNode.insertBefore(listEl, tableWrap);
}
var rows = table.querySelectorAll('tr[id^="trade-row-"]');
listEl.innerHTML = rows.length
? Array.prototype.map
.call(rows, function (tr) {
return UI.renderMobileTradeRow(tr);
})
.join("")
: '<div class="journal-empty-msg">暂无交易记录</div>';
listEl.querySelectorAll(".mobile-record-row").forEach(function (btn) {
btn.addEventListener("click", function () {
var rowId = btn.getAttribute("data-row-id");
var tr = rowId && document.getElementById(rowId);
if (tr) UI.openTradeRecordDetailModal(tr);
});
});
}
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
refreshTradeRecords();
if (typeof global.loadJournals === "function" && document.getElementById("journal-list")) {
global.loadJournals();
}
}, 180);
}
function init() {
refreshTradeRecords();
global.addEventListener("resize", onResize);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
global.InstanceRecordsMobile = {
refresh: refreshTradeRecords,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,604 @@
/**
* 实例:导航显示,env 配置,改密,PM2 重启.
*/
(function (global) {
const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {};
function setStatus(el, text, isErr) {
if (!el) return;
el.textContent = text || "";
el.classList.toggle("err", !!isErr);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.msg || res.statusText || "请求失败");
}
return data;
}
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = {
show_nav_dashboard: true,
show_nav_account_ledger: true,
show_nav_system_guide: true,
};
function navPrefShow(display, key) {
if (!key) return true;
if (NAV_DEFAULT_OFF[key]) return display[key] === true;
return display[key] !== false;
}
function applyDisplayToNav(display) {
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
document
.querySelectorAll(
".embed-top-nav [data-embed-tab], .top-nav a[href^='/'], #inst-mobile-tabbar [data-embed-tab], #inst-mobile-more [data-embed-tab]"
)
.forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return;
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
a.classList.toggle("nav-hidden", !show);
a.style.display = show ? "" : "none";
});
global.__INSTANCE_DISPLAY__ = display;
}
function pageNavAllowed(tab) {
const d = DISPLAY();
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade",
strategy: "show_nav_strategy",
strategy_records: "show_nav_strategy_records",
records: "show_nav_records",
stats: "show_nav_stats",
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
const key = map[tab];
if (!key) return true;
return navPrefShow(d, key);
}
function displayPrefsRoot() {
const settingsPane = document.querySelector('.embed-tab-pane[data-embed-pane="settings"]');
if (settingsPane) {
const inSettings = settingsPane.querySelector("#display-prefs-form");
if (inSettings) return inSettings;
}
const pane = document.querySelector(".embed-tab-pane.is-active-pane");
if (pane) {
const inPane = pane.querySelector("#display-prefs-form");
if (inPane) return inPane;
}
return document.getElementById("display-prefs-form");
}
function displayPrefsStatusEl() {
const card = document.getElementById("display-prefs-card");
if (card) {
const el = card.querySelector("#display-prefs-status");
if (el) return el;
}
return document.getElementById("display-prefs-status");
}
function envConfigRoot() {
const activePane = document.querySelector(".embed-tab-pane.is-active-pane");
if (activePane) {
return activePane.querySelector(".env-config-page");
}
return document.querySelector(".env-config-page");
}
function bindEnvTabs() {
/* Tab 切换由 CSS radio+label 实现 */
}
async function loadDisplayPrefsForm(force) {
const root = displayPrefsRoot();
if (!root) return;
if (!force && root.getAttribute("data-prefs-ssr") === "1" && root.querySelector("[data-pref-key]")) {
return;
}
return loadDisplayPrefsFormIn(root);
}
async function loadDisplayPrefsFormIn(root) {
try {
const data = await fetchJson("/api/settings/display");
const display = data.display || {};
const meta = data.meta || [];
root.innerHTML = "";
meta.forEach((group) => {
const section = document.createElement("div");
section.className = "display-prefs-group";
const title = document.createElement("h3");
title.className = "settings-subcard-title";
title.textContent = group.group;
section.appendChild(title);
const grid = document.createElement("div");
grid.className = "display-prefs-checks";
(group.entries || []).forEach((item) => {
const label = document.createElement("label");
label.className = "chk-label";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.dataset.prefKey = item.key;
cb.checked = NAV_DEFAULT_OFF[item.key]
? display[item.key] === true
: display[item.key] !== false;
label.appendChild(cb);
label.appendChild(document.createTextNode(" " + item.label));
grid.appendChild(label);
});
section.appendChild(grid);
root.appendChild(section);
});
root.setAttribute("data-prefs-ssr", "1");
} catch (e) {
root.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
async function saveDisplayPrefs() {
const status = displayPrefsStatusEl();
const root = displayPrefsRoot();
if (!root) {
setStatus(status, "未找到导航设置表单", true);
return;
}
const display = {};
root.querySelectorAll("input[data-pref-key]").forEach((cb) => {
display[cb.dataset.prefKey] = !!cb.checked;
});
try {
const data = await fetchJson("/api/settings/display", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display }),
});
applyDisplayToNav(data.display || display);
setStatus(status, "已保存,导航已更新");
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
let envSchemaGroups = [];
function renderEnvFieldRow(field) {
const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label");
label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key;
label.textContent = field.label || field.key;
if (field.restart_required) {
const mark = document.createElement("span");
mark.className = "env-restart-mark";
mark.title = "需重启";
mark.textContent = "*";
label.appendChild(mark);
}
row.appendChild(label);
if (field.note) {
const note = document.createElement("div");
note.className = "env-field-note muted";
note.textContent = field.note;
row.appendChild(note);
}
let input;
if (field.type === "bool") {
input = document.createElement("select");
input.id = "env-f-" + field.key;
[["true", "开启"], ["false", "关闭"]].forEach(([v, text]) => {
const o = document.createElement("option");
o.value = v;
o.textContent = text;
input.appendChild(o);
});
const cur = (field.current || field.default || "false").toLowerCase();
input.value = cur === "true" || cur === "1" ? "true" : "false";
} else if (field.type === "select" && Array.isArray(field.options) && field.options.length) {
input = document.createElement("select");
input.id = "env-f-" + field.key;
const cur = String(field.current || field.default || "");
const seen = new Set();
field.options.forEach((opt) => {
const v = String(opt.value != null ? opt.value : "");
if (seen.has(v)) return;
seen.add(v);
const o = document.createElement("option");
o.value = v;
o.textContent = opt.label || v;
input.appendChild(o);
});
if (cur && !seen.has(cur)) {
const o = document.createElement("option");
o.value = cur;
o.textContent = cur;
input.insertBefore(o, input.firstChild);
}
input.value = cur || (field.options[0] && field.options[0].value) || "";
} else {
input = document.createElement("input");
input.id = "env-f-" + field.key;
input.type = "password";
// 防止浏览器把登录密码自动填进 API Key/Secret(保存对冲开关时曾误写入密钥)
input.autocomplete = "new-password";
input.setAttribute("data-lpignore", "true");
input.setAttribute("data-1p-ignore", "true");
input.setAttribute("data-form-type", "other");
input.readOnly = true;
input.addEventListener("focus", function () {
input.readOnly = false;
});
if (field.sensitive) {
input.dataset.envSensitive = "1";
input.dataset.envDirty = "0";
input.addEventListener("input", function () {
input.dataset.envDirty = "1";
});
if (field.has_value) {
const cur = document.createElement("div");
cur.className = "env-sensitive-current muted";
const labelSpan = document.createElement("span");
labelSpan.textContent = "已配置 ";
const masked = document.createElement("span");
masked.className = "env-masked-value";
masked.textContent = field.masked || "";
cur.appendChild(labelSpan);
cur.appendChild(masked);
row.appendChild(cur);
}
input.placeholder = field.has_value ? "修改时填写新值,留空不修改" : "请输入";
} else {
input.type = "text";
input.autocomplete = "off";
input.value = field.current || field.default || "";
}
}
input.dataset.envKey = field.key;
input.className = "env-field-input";
row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row;
}
function renderEnvConfigBody(groups) {
const body = document.createElement("div");
body.className = "env-config-body card";
body.id = "env-config-body";
body.setAttribute("data-env-ssr", "1");
groups.forEach((_group, idx) => {
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "env-section";
radio.id = "env-sec-" + idx;
radio.className = "env-tab-radio";
if (idx === 0) radio.checked = true;
body.appendChild(radio);
});
const tabBar = document.createElement("div");
tabBar.className = "env-config-tabs";
tabBar.setAttribute("role", "tablist");
const panelsWrap = document.createElement("div");
panelsWrap.className = "env-config-panels";
panelsWrap.id = "env-config-grid";
let modeSectionIdx = 0;
groups.forEach((group, idx) => {
if ((group.title || "").indexOf("期权/对冲模式") >= 0) modeSectionIdx = idx;
const label = document.createElement("label");
label.className = "env-tab-btn";
label.htmlFor = "env-sec-" + idx;
label.setAttribute("role", "tab");
label.textContent = group.title || "其他";
tabBar.appendChild(label);
const panel = document.createElement("section");
panel.className = "env-panel env-panel--" + idx;
panel.setAttribute("role", "tabpanel");
if (group.has_restart) {
const hint = document.createElement("p");
hint.className = "env-panel-hint";
hint.textContent = "本组含需重启项,修改后请点「保存并重启」.";
panel.appendChild(hint);
}
const grid = document.createElement("div");
grid.className = "env-form-grid";
(group.fields || []).forEach((field) => grid.appendChild(renderEnvFieldRow(field)));
panel.appendChild(grid);
panelsWrap.appendChild(panel);
});
body.appendChild(tabBar);
body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
return body;
}
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
budgetRow.hidden = compoundOn;
budgetRow.style.display = compoundOn ? "none" : "";
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
modeSel.dataset.modeRefreshBound = "1";
modeSel.addEventListener("change", async () => {
const status = document.getElementById("env-config-status");
const nextMode = modeSel.value;
setStatus(status, "切换交易模式并刷新配置…");
try {
await fetchJson("/api/settings/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: { OKX_TRADE_MODE: nextMode } }),
});
await loadEnvConfig(true);
const page = envConfigRoot() || document.querySelector(".env-config-page");
const newBody = page && page.querySelector("#env-config-body");
const idx = newBody && newBody.dataset.envModeSectionIdx;
if (idx != null) {
const radio = document.getElementById("env-sec-" + idx);
if (radio) radio.checked = true;
}
setStatus(status, "交易模式已切换为当前选项,配置区已刷新");
} catch (e) {
setStatus(status, e.message || "切换失败", true);
}
});
}
async function loadEnvConfig(force) {
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (!force && body && body.getAttribute("data-env-ssr") === "1" && body.querySelector("[data-env-key]")) {
return;
}
return loadEnvConfigIn(root);
}
async function loadEnvConfigIn(root) {
const page = root || envConfigRoot() || document.querySelector(".env-config-page");
if (!page) return;
const loading = document.createElement("div");
loading.className = "env-config-loading-wrap card";
loading.id = "env-config-body";
loading.innerHTML = '<div class="env-config-loading muted">加载配置中…</div>';
const oldBody = page.querySelector("#env-config-body");
const oldGrid = page.querySelector("#env-config-grid.env-config-loading-wrap");
if (oldBody) oldBody.replaceWith(loading);
else if (oldGrid) oldGrid.replaceWith(loading);
try {
const data = await fetchJson("/api/settings/env");
envSchemaGroups = data.groups || [];
loading.replaceWith(renderEnvConfigBody(envSchemaGroups));
} catch (e) {
loading.innerHTML = '<span class="err">' + (e.message || "加载失败") + "</span>";
}
}
function collectEnvValues() {
const root = envConfigRoot();
const values = {};
const scope = root || document;
scope.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => {
if (el.dataset.envSensitive === "1" && el.dataset.envDirty !== "1") {
// 未改动过的敏感项不提交,避免浏览器自动填充覆盖已有密钥
return;
}
values[el.dataset.envKey] = el.value;
});
return values;
}
async function saveEnvConfig(restartAfter) {
const status = document.getElementById("env-config-status");
setStatus(status, "保存中…");
try {
const data = await fetchJson("/api/settings/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: collectEnvValues() }),
});
const needRestart = restartAfter || data.restart_required;
if (needRestart) {
setStatus(status, "已保存,正在重启实例…");
await restartInstance();
setStatus(status, "保存并重启完成");
await loadEnvConfig(true);
} else {
setStatus(status, "已保存(即时生效项已应用)");
await loadEnvConfig(true);
}
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
async function restartInstance() {
try {
await fetchJson("/api/admin/restart", { method: "POST" });
} catch (_) {
// 重启会中断当前 HTTP 连接;只要后续 health 恢复即视为成功.
}
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
try {
const h = await fetch("/api/admin/health", { credentials: "same-origin" });
if (h.ok) return;
} catch (_) {}
}
throw new Error("重启后服务未在预期时间内恢复");
}
async function savePassword() {
const status = document.getElementById("pwd-save-status");
const body = {
old_password: (document.getElementById("pwd-old") || {}).value || "",
new_username: (document.getElementById("pwd-new-username") || {}).value || "",
new_password: (document.getElementById("pwd-new") || {}).value || "",
confirm_password: (document.getElementById("pwd-confirm") || {}).value || "",
};
try {
const data = await fetchJson("/api/settings/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (data.restart_required) {
setStatus(status, "密码已保存,正在重启…");
await restartInstance();
setStatus(status, "密码已更新,请用新密码登录");
} else {
setStatus(status, "密码已更新");
}
} catch (e) {
setStatus(status, e.message || "保存失败", true);
}
}
function installDelegatedHandlers() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => {
const target = ev.target;
if (!(target instanceof Element)) return;
if (target.closest("#display-prefs-save")) {
ev.preventDefault();
void saveDisplayPrefs();
return;
}
if (target.closest("#env-config-save")) {
ev.preventDefault();
void saveEnvConfig(false);
return;
}
if (target.closest("#env-config-save-restart")) {
ev.preventDefault();
void saveEnvConfig(true);
return;
}
if (target.closest("#env-config-reload")) {
ev.preventDefault();
void loadEnvConfig(true);
return;
}
if (target.closest("#pwd-save-btn")) {
ev.preventDefault();
void savePassword();
}
});
}
function bindClickOnce(id, handler) {
const el = document.getElementById(id);
if (!el || el.dataset.bound === "1") return;
el.dataset.bound = "1";
el.addEventListener("click", handler);
}
function bindEvents() {
bindClickOnce("display-prefs-save", saveDisplayPrefs);
bindClickOnce("env-config-save", () => saveEnvConfig(false));
bindClickOnce("env-config-save-restart", () => saveEnvConfig(true));
bindClickOnce("env-config-reload", () => loadEnvConfig(true));
bindClickOnce("pwd-save-btn", savePassword);
}
function initPage() {
installDelegatedHandlers();
bindEvents();
loadDisplayPrefsForm(false);
loadEnvConfig(false);
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (body) {
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
}
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
}
global.InstanceSettingsPrefs = {
pageNavAllowed,
applyDisplayToNav,
loadDisplayPrefsForm,
loadEnvConfig,
bindEnvTabs,
bindEvents,
restartInstance,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPage);
} else {
initPage();
}
})(typeof window !== "undefined" ? window : globalThis);
+117
View File
@@ -0,0 +1,117 @@
(function (global) {
"use strict";
var PERIODS = ["day", "week", "month", "all"];
function statsSegmentSelect() {
return document.getElementById("stats-segment-select");
}
function panelFromTrigger(triggerEl) {
if (triggerEl && triggerEl.closest) {
var fromBtn = triggerEl.closest(".stats-segment-panel");
if (fromBtn) return fromBtn;
}
return null;
}
function activeSegmentPanel(triggerEl) {
var panel = panelFromTrigger(triggerEl);
if (panel) return panel;
var sel = statsSegmentSelect();
if (!sel) return null;
var key = sel.value;
return document.querySelector(
'.stats-segment-panel[data-stats-segment="' + key + '"]'
);
}
function replaceStatsUrl(params) {
var q = new URLSearchParams(global.location.search);
Object.keys(params).forEach(function (k) {
if (params[k] == null || params[k] === "") q.delete(k);
else q.set(k, params[k]);
});
var qs = q.toString();
global.history.replaceState(
null,
"",
qs ? global.location.pathname + "?" + qs : global.location.pathname
);
}
function switchStatsPeriod(periodKey, triggerEl) {
var panel = activeSegmentPanel(triggerEl);
if (!panel) return;
var key = PERIODS.indexOf(periodKey) >= 0 ? periodKey : "day";
panel.querySelectorAll(".stats-period-pane").forEach(function (pane) {
var match = pane.getAttribute("data-stats-period") === key;
if (match) pane.removeAttribute("hidden");
else pane.setAttribute("hidden", "");
});
panel.querySelectorAll(".stats-period-tab").forEach(function (btn) {
var on = btn.getAttribute("data-stats-period") === key;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
replaceStatsUrl({ stats_period: key });
}
function switchStatsSegment() {
var sel = statsSegmentSelect();
if (!sel) return;
var key = sel.value;
document.querySelectorAll(".stats-segment-panel").forEach(function (p) {
p.style.display =
p.getAttribute("data-stats-segment") === key ? "block" : "none";
});
replaceStatsUrl({ stats_segment: key });
var period =
new URLSearchParams(global.location.search).get("stats_period") || "day";
switchStatsPeriod(period);
}
function ensurePeriodTabDelegation() {
if (global.__instanceStatsTabsDelegated) return;
global.__instanceStatsTabsDelegated = true;
document.addEventListener(
"click",
function (e) {
var btn =
e.target && e.target.closest
? e.target.closest(".stats-period-tab")
: null;
if (!btn) return;
var card = document.getElementById("stats-card");
if (!card || !card.contains(btn)) return;
switchStatsPeriod(btn.getAttribute("data-stats-period") || "day", btn);
},
true
);
}
function initStatsFromUrl() {
var sel = statsSegmentSelect();
if (!sel) return;
ensurePeriodTabDelegation();
var url = new URLSearchParams(global.location.search);
var segKey = url.get("stats_segment");
if (
segKey &&
sel.querySelector('option[value="' + segKey.replace(/"/g, "") + '"]')
) {
sel.value = segKey;
}
switchStatsSegment();
var period = url.get("stats_period") || "day";
if (PERIODS.indexOf(period) < 0) period = "day";
switchStatsPeriod(period);
}
ensurePeriodTabDelegation();
global.switchStatsSegment = switchStatsSegment;
global.switchStatsPeriod = switchStatsPeriod;
global.initStatsFromUrl = initStatsFromUrl;
global.initStatsSegmentFromUrl = initStatsFromUrl;
})(window);
File diff suppressed because it is too large Load Diff
+570
View File
@@ -0,0 +1,570 @@
/**
* 三所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动.
*/
(function (global) {
const STANDALONE_KEY = "instance-theme";
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
const META = { dark: "#0b0d14", light: "#c8d4de" };
function normalize(theme) {
return theme === "light" ? "light" : "dark";
}
function isHubLinked() {
try {
if (window.self !== window.top) return true;
} catch (_) {
return true;
}
return false;
}
function themeFromUrl() {
try {
const t = new URLSearchParams(location.search).get("hub_theme");
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function readLinkedThemeStorage() {
try {
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
if (t === "light" || t === "dark") return t;
} catch (_) {}
return null;
}
function writeLinkedThemeStorage(theme) {
if (!isHubLinked()) return;
try {
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
} catch (_) {}
}
function getStandalone() {
try {
return normalize(localStorage.getItem(STANDALONE_KEY));
} catch (_) {
return "dark";
}
}
function setStandalone(theme) {
try {
localStorage.setItem(STANDALONE_KEY, normalize(theme));
} catch (_) {}
}
let _linkedTheme = null;
let _appliedTheme = null;
function get() {
if (isHubLinked()) {
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
}
return getStandalone();
}
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
const INLINE_HEX_LIGHT = {
"#cfd3ef": "#1a2838",
"#8892b0": "#4a6078",
"#9aa3c4": "#4a6078",
"#8b95a8": "#4a6078",
"#8b95b8": "#4a6078",
"#6a7598": "#4a6078",
"#7d8799": "#4a6078",
"#6d7689": "#4a6078",
"#dbe4ff": "#142232",
"#f0f2ff": "#142232",
"#e8ecf4": "#142232",
"#c5cce0": "#4a6078",
"#b8c4ff": "#142232",
"#8fc8ff": "#006e9a",
"#6ab8ff": "#006e9a",
"#6eb5ff": "#006e9a",
"#101522": "#ffffff",
"#121726": "#ffffff",
"#141423": "#ffffff",
"#24243b": "#b8c8d8",
"#252a45": "#b8c8d8",
"#252538": "#eef3f8",
"#1a1a29": "#f6f9fc",
"#2e2e45": "#b8c8d8",
"#2b2b43": "#d0dae4",
"#151a2a": "#eef3f8",
"#141a2a": "#ffffff",
"#141923": "#ffffff",
"#141a2e": "#ffffff",
"#0f1424": "#f6f9fc",
"#0f1420": "#f6f9fc",
"#0f1117": "#d8e2ec",
"#1a2034": "#eef3f8",
"#1a2030": "#ffffff",
"#1f3a5a": "#e8eef5",
"#2f2f44": "#dde5ec",
"#2a3f6c": "rgba(0,110,154,0.14)",
"#304164": "rgba(0,95,140,0.22)",
"#2a3150": "#b8c8d8",
"#2a3152": "#b8c8d8",
"#3a5a8a": "rgba(0,95,140,0.35)",
"#2a3348": "#b8c8d8",
"#243050": "rgba(0,75,115,0.16)",
"#2a3558": "#d0dae4",
"#3a4468": "#c8d4e0",
"#3a4a66": "#b8c8d8",
"#3a3f52": "#dde5ec",
"#3d4659": "#b8c8d8",
"#1f2740": "#eef3f8",
"#1f2a44": "rgba(0,110,154,0.1)",
"#1f4a3a": "#e8f5ef",
"#2a4a7a": "#e8eef5",
"#3a3048": "#eef3f8",
"#d4b8ff": "#5b4fc7",
"#e6e8ef": "#1a2838",
};
function remapInlineStyle(style, theme) {
if (!style) return style;
if (theme !== "light") return style;
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
let out = style;
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
}
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
}
return out;
}
function syncInlineStyles(theme, root) {
const scope = root || document;
scope.querySelectorAll("[style]").forEach((el) => {
const raw = el.getAttribute("style");
if (!raw) return;
if (!el.dataset.instStyleBase) {
el.dataset.instStyleBase = raw;
}
const base = el.dataset.instStyleBase;
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
});
}
function mergeHubQueryIntoHref(href, theme) {
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
try {
const u = new URL(href, location.origin);
if (u.origin !== location.origin) return href;
if (isHubLinked()) {
u.searchParams.set("embed", "1");
if (theme === "light" || theme === "dark") {
u.searchParams.set("hub_theme", theme);
}
}
return u.pathname + u.search + u.hash;
} catch (_) {
return href;
}
}
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
.forEach((a) => {
const href = a.getAttribute("href");
if (!href) return;
const next = mergeHubQueryIntoHref(href, t);
if (next !== href) a.setAttribute("href", next);
});
}
function apply(theme, opts) {
const options = opts || {};
const linked = isHubLinked();
const t = normalize(theme);
const root = document.documentElement;
const unchanged =
!options.force &&
_appliedTheme === t &&
root.getAttribute("data-theme") === t;
if (unchanged) {
return t;
}
_appliedTheme = t;
if (linked) {
_linkedTheme = t;
writeLinkedThemeStorage(t);
root.setAttribute("data-hub-linked", "1");
} else {
root.removeAttribute("data-hub-linked");
}
if (!linked && !options.skipStore) {
setStandalone(t);
}
root.setAttribute("data-theme", t);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", META[t]);
root.style.colorScheme = t;
if (document.body) {
syncInlineStyles(t);
patchHubNavLinks(t);
} else {
document.addEventListener(
"DOMContentLoaded",
function onDom() {
syncInlineStyles(t);
patchHubNavLinks(t);
},
{ once: true }
);
}
syncToggleUI();
document.dispatchEvent(
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
);
return t;
}
function syncToggleUI(root) {
const scope = root || document;
const linked = isHubLinked();
const toggle = scope.querySelector(".instance-theme-toggle");
if (toggle) {
toggle.classList.toggle("is-hub-linked", linked);
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
}
if (linked) return;
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
const on = btn.getAttribute("data-theme-value") === getStandalone();
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function initToggleUI(root) {
const scope = root || document;
syncToggleUI(scope);
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
if (btn.dataset.themeBound === "1") return;
btn.dataset.themeBound = "1";
btn.addEventListener("click", () => {
if (isHubLinked()) return;
apply(btn.getAttribute("data-theme-value"));
});
});
}
function initMobileTopNav() {
const mq = window.matchMedia("(max-width: 720px)");
function scrollActiveTab(nav) {
const active = nav.querySelector("a.active");
if (!active) return;
requestAnimationFrame(() => {
try {
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
} catch (_) {
active.scrollIntoView(false);
}
});
}
function apply() {
if (!mq.matches) return;
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
}
apply();
mq.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.addEventListener("orientationchange", apply);
}
function initFromHubMessage(data) {
if (!data || data.type !== "hub-theme-sync") return;
if (!isHubLinked()) return;
apply(data.theme, { skipStore: true });
}
/** 交易记录页:核对开关与按钮 disabled 保持同步(含 iframe 软导航后动态挂载的 toggle) */
function syncReviewEditButtons() {
const toggle = document.getElementById("review-mode-toggle");
if (!toggle) return;
const on = !!toggle.checked;
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
btn.disabled = !on;
});
}
function initReviewEditModeSync() {
if (!global.__instReviewModeBound) {
global.__instReviewModeBound = true;
const onToggle = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
document.addEventListener("change", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
document.addEventListener("input", (ev) => {
if (ev.target && ev.target.id === "review-mode-toggle") onToggle();
});
}
const run = () => {
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
else syncReviewEditButtons();
};
run();
requestAnimationFrame(run);
setTimeout(run, 0);
if (!global.__instReviewModePageshowBound) {
global.__instReviewModePageshowBound = true;
window.addEventListener("pageshow", run);
}
}
function notifyParentFrameNavStart() {
if (!isHubLinked()) return;
try {
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
} catch (_) {}
}
function notifyParentFrameReady() {
if (!isHubLinked()) return;
dismissNavOverlay();
try {
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
} catch (_) {}
}
function ensureNavOverlay() {
const t = normalize(get());
const bg = META[t];
let el = document.getElementById("inst-nav-overlay");
if (!el) {
el = document.createElement("div");
el.id = "inst-nav-overlay";
el.setAttribute("aria-hidden", "true");
(document.body || document.documentElement).appendChild(el);
}
el.style.cssText =
"position:fixed;inset:0;z-index:2147483646;background:" +
bg +
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
return el;
}
function dismissNavOverlay() {
const el = document.getElementById("inst-nav-overlay");
if (!el) return;
el.style.opacity = "0";
window.setTimeout(() => {
try {
el.remove();
} catch (_) {}
}, 90);
}
function injectNavOverlayIntoHtml(html, theme) {
const t = normalize(theme || get());
const bg = META[t];
let out = html || "";
const guard =
'<style id="inst-nav-guard">html,body{background:' +
bg +
"!important;color-scheme:" +
t +
';}</style>';
if (out.includes("</head>")) {
out = out.replace("</head>", guard + "</head>");
} else {
out = guard + out;
}
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
if (/data-theme=/i.test(attrs)) {
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
}
return "<html" + attrs + ' data-theme="' + t + '">';
});
const overlay =
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
bg +
';opacity:1;pointer-events:auto"></div>';
if (/<body[^>]*>/i.test(out)) {
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
}
return out;
}
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白. */
function initHubEmbedInFrameNav() {
if (!isHubLinked()) return;
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
let navToken = 0;
function isSoftNavLink(a) {
if (!a || !a.getAttribute) return false;
if (a.hasAttribute("download") || a.target === "_blank") return false;
return !!a.closest(".top-nav, .strategy-subnav");
}
function softNavFetch(href) {
return fetch(href, {
credentials: "same-origin",
headers: { "X-Instance-Soft-Nav": "1" },
});
}
async function navigateInFrame(href, opts) {
const token = ++navToken;
notifyParentFrameNavStart();
ensureNavOverlay();
try {
const r = await softNavFetch(href);
if (token !== navToken) return;
if (!r.ok) {
location.assign(href);
return;
}
let html = await r.text();
if (token !== navToken) return;
html = injectNavOverlayIntoHtml(html, get());
let path = href;
try {
const u = new URL(href, location.href);
path = u.pathname + u.search + u.hash;
} catch (_) {}
if (opts && opts.replace) history.replaceState(null, "", path);
else history.pushState(null, "", path);
document.open();
document.write(html);
document.close();
} catch (_) {
if (token === navToken) location.assign(href);
}
}
document.addEventListener(
"click",
(ev) => {
const a = ev.target.closest("a[href]");
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
const rawHref = a.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
let target;
try {
target = new URL(rawHref, location.href);
} catch (_) {
return;
}
if (target.origin !== location.origin) return;
const nextHref = target.pathname + target.search + target.hash;
if (target.pathname === location.pathname && target.search === location.search) return;
ev.preventDefault();
void navigateInFrame(nextHref);
},
true
);
window.addEventListener("popstate", () => {
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
});
}
function purgeLegacySoftNavCache() {
try {
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
const key = localStorage.key(i);
if (!key) continue;
if (
key.startsWith("inst-pc:") ||
key === "inst-page-cache-index" ||
key === "inst-page-cache-days"
) {
localStorage.removeItem(key);
}
}
sessionStorage.removeItem("inst-soft-nav");
sessionStorage.removeItem("inst-cache-revalidate");
} catch (_) {}
}
function boot() {
purgeLegacySoftNavCache();
if (isHubLinked()) {
apply(get(), { skipStore: true });
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
initHubEmbedInFrameNav();
try {
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
} catch (_) {}
} else {
apply(getStandalone());
}
function observeDynamicLists() {
["journal-list", "review-list"].forEach((id) => {
const el = document.getElementById(id);
if (!el || el.dataset.instThemeObserved === "1") return;
el.dataset.instThemeObserved = "1";
new MutationObserver(() => {
syncInlineStyles(get());
patchHubNavLinks(get());
}).observe(el, {
childList: true,
subtree: true,
});
});
}
const onReady = () => {
initToggleUI();
initMobileTopNav();
initReviewEditModeSync();
syncInlineStyles(get());
patchHubNavLinks(get());
observeDynamicLists();
if (isHubLinked()) {
requestAnimationFrame(() => {
requestAnimationFrame(() => notifyParentFrameReady());
});
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onReady);
} else {
onReady();
}
document.addEventListener("instance-theme-change", (ev) => {
const t = ev.detail && ev.detail.theme;
if (t) {
syncInlineStyles(t);
patchHubNavLinks(t);
}
});
}
boot();
global.InstanceTheme = {
STANDALONE_KEY,
HUB_LINKED_THEME_KEY,
isHubLinked,
get,
apply,
initToggleUI,
syncToggleUI,
syncInlineStyles,
patchHubNavLinks,
mergeHubQueryIntoHref,
syncReviewEditButtons,
initReviewEditModeSync,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,54 @@
/* 紧接 instance_theme.js 之后加载,避免亮色下先闪暗色底 */
html {
background: #0b0d14;
color-scheme: dark;
}
html[data-theme="light"] {
background: #c8d4de;
color-scheme: light;
}
html[data-theme="light"] body {
background: #c8d4de !important;
color: #142232 !important;
}
.review-edit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
html[data-theme="light"] .header h1 {
color: #142232 !important;
}
html[data-theme="light"] .top-nav a,
html[data-theme="light"] .embed-top-nav a,
html[data-theme="light"] .strategy-subnav a {
background: #fff !important;
color: #006e9a !important;
border-color: rgba(0, 95, 140, 0.22) !important;
}
html[data-theme="light"] .top-nav a:hover,
html[data-theme="light"] .embed-top-nav a:hover,
html[data-theme="light"] .strategy-subnav a:hover {
background: rgba(0, 110, 154, 0.1) !important;
color: #004d6e !important;
}
html[data-theme="light"] .top-nav a.active,
html[data-theme="light"] .embed-top-nav a.active,
html[data-theme="light"] .strategy-subnav a.active {
background: rgba(0, 110, 154, 0.12) !important;
color: #004d6e !important;
border: 1px solid rgba(0, 95, 140, 0.28) !important;
font-weight: 600;
}
html[data-theme="light"] .card,
html[data-theme="light"] .stat-item {
background: #fff !important;
border-color: #b8c8d8 !important;
}
+456
View File
@@ -0,0 +1,456 @@
/**
* 三所实例共用 UI:复盘详情,盈亏着色等.
*/
(function (global) {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function pnlClassFromValue(val) {
const n = Number(String(val == null ? "" : val).replace(/[^\d.-]/g, ""));
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function formatPnlSpan(val, suffix) {
const sfx = suffix == null ? "U" : suffix;
const cls = pnlClassFromValue(val);
const text = escapeHtml(val == null || val === "" ? "-" : val) + sfx;
return cls ? `<span class="${cls}">${text}</span>` : text;
}
function buildJournalDetailHtml(o, formatExitLine) {
const moodTags =
Array.isArray(o.mood_issues) && o.mood_issues.length
? o.mood_issues.join(",")
: o.mood_issues || "无";
const exitText =
typeof formatExitLine === "function" ? formatExitLine(o) : o.exit_reason || "无";
const lines = [
`币种/周期:${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "-")}`,
`方向:${escapeHtml((function(){ const d = inferJournalDirection(o); return d ? d.text : "-"; })())}`,
`开仓时间:${escapeHtml(o.open_datetime || "-")}`,
`平仓时间:${escapeHtml(o.close_datetime || "-")}`,
`持仓时长:${escapeHtml(o.hold_duration || "-")}`,
`盈亏:${formatPnlSpan(o.pnl)}`,
`下单类型:${escapeHtml(o.order_type || "无")}`,
`开仓类型:${escapeHtml(o.entry_reason || "无")}`,
`平仓/离场:${escapeHtml(exitText)}`,
`预期RR:${escapeHtml(o.expect_rr || "-")}`,
`实际RR:${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`,
];
return lines.join("<br>");
}
function resolveJournalImages(o) {
if (Array.isArray(o.images) && o.images.length) return o.images;
if (o.image) return [{ tf: "", file: o.image }];
return [];
}
function setJournalDetailImages(o) {
const grid = document.getElementById("detailImages");
const legacyImg = document.getElementById("detailImage");
const images = resolveJournalImages(o || {});
if (grid) {
if (!images.length) {
grid.innerHTML = "";
grid.style.display = "none";
} else {
grid.innerHTML = images
.map(function (img) {
const tf = String(img.tf || "").trim();
const file = String(img.file || "").trim();
if (!file) return "";
const label = tf ? escapeHtml(tf) : "截图";
const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/");
return (
'<div class="journal-detail-img-cell">' +
'<span class="journal-detail-img-label">' +
label +
"</span>" +
'<img class="journal-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" onclick="showImage(this.src)">' +
"</div>"
);
})
.join("");
grid.style.display = "grid";
}
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
return;
}
if (legacyImg) {
if (images.length === 1) {
legacyImg.src = "/static/images/" + images[0].file;
legacyImg.style.display = "block";
} else {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
}
function clearJournalDetailImages() {
const grid = document.getElementById("detailImages");
if (grid) {
grid.innerHTML = "";
grid.style.display = "none";
}
const legacyImg = document.getElementById("detailImage");
if (legacyImg) {
legacyImg.src = "";
legacyImg.style.display = "none";
}
}
function setJournalDetailBody(o, formatExitLine) {
const body = document.getElementById("detailBody");
if (!body) return;
body.classList.remove("md-review", "trade-record-detail-wrap");
body.classList.add("journal-detail-meta");
body.innerHTML = buildJournalDetailHtml(o, formatExitLine);
}
function openJournalDetailModal(id, journalCache, formatExitLine) {
const o = journalCache && journalCache[id];
if (!o) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易复盘详情|${o.coin || "-"} ${o.tf || "-"}`;
}
setJournalDetailBody(o, formatExitLine);
clearDetailActions();
setJournalDetailImages(o);
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
function isMobileCompactRecords() {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(max-width: 720px)").matches;
}
function inferJournalDirection(o) {
const hint = String((o && (o.direction_hint || o.direction)) || "").toLowerCase();
if (hint === "long" || hint === "buy" || hint === "多") {
return { text: "做多", cls: "direction-long" };
}
if (hint === "short" || hint === "sell" || hint === "空") {
return { text: "做空", cls: "direction-short" };
}
const text = String((o && (o.entry_reason || o.note)) || "");
if (/做空|空头|short/i.test(text)) {
return { text: "做空", cls: "direction-short" };
}
if (/做多|多头|long/i.test(text)) {
return { text: "做多", cls: "direction-long" };
}
return null;
}
function renderJournalListHtml(data) {
if (!data || !data.length) return "";
const mobile = isMobileCompactRecords();
if (mobile) {
return data
.map(function (o) {
const dir = inferJournalDirection(o);
const pnlCls = pnlClassFromValue(o.pnl);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: `<span class="mrr-muted">-</span>`;
const id = escapeHtml(o.id);
return `<div class="mobile-record-row-wrap">
<button type="button" class="mobile-record-row" onclick="openJournalDetail('${id}')">
<span class="mrr-symbol">${escapeHtml(o.coin || "-")} ${escapeHtml(o.tf || "")}</span>
<span class="mrr-dir">${dirHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(o.pnl == null || o.pnl === "" ? "-" : o.pnl)}U</span>
</button>
<button type="button" class="mobile-record-del" title="删除" onclick="deleteJournal('${id}')">×</button>
</div>`;
})
.join("");
}
const rows = data
.map(function (o) {
const moodTags = Array.isArray(o.mood_issues)
? o.mood_issues.join(",")
: o.mood_issues || "";
const mood = moodTags || "无";
const id = escapeHtml(o.id);
const pnlCls = pnlClassFromValue(o.pnl);
const pnlTxt =
o.pnl == null || o.pnl === "" ? "-" : String(o.pnl);
const dir = inferJournalDirection(o);
const dirHtml = dir
? `<span class="badge ${dir.cls}">${escapeHtml(dir.text)}</span>`
: "-";
return `<tr id="journal-row-${id}">
<td>${escapeHtml(o.coin || "-")}</td>
<td>${escapeHtml(o.tf || "-")}</td>
<td>${dirHtml}</td>
<td>${escapeHtml(o.order_type || "-")}</td>
<td>${escapeHtml(o.entry_reason || "-")}</td>
<td><span class="${pnlCls}">${escapeHtml(pnlTxt)}</span></td>
<td>${escapeHtml((o.open_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml((o.close_datetime || "-").toString().slice(0, 16))}</td>
<td>${escapeHtml(o.hold_duration || "-")}</td>
<td>${escapeHtml(mood)}</td>
<td>
<button type="button" class="table-del" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" onclick="openJournalDetail('${id}')">查看详情</button>
<button type="button" class="table-del" onclick="deleteJournal('${id}')">删除</button>
</td>
</tr>`;
})
.join("");
return `<div class="table-wrap"><table class="rr-journals-table">
<thead><tr>
<th>品种</th><th>周期</th><th>方向</th><th>下单类型</th><th>开仓类型</th>
<th>盈亏U</th><th>开仓时间</th><th>平仓时间</th><th>持仓</th><th>心态标签</th><th>操作</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
function parseTradeRecordRow(tr) {
const cells = tr.querySelectorAll("td");
if (cells.length < 15) return null;
const dirBadge = cells[3].querySelector(".badge");
return {
rowId: tr.id,
symbol: cells[0].textContent.trim(),
type: cells[1].textContent.trim(),
entryReason: cells[2].textContent.trim(),
directionHtml: (dirBadge ? dirBadge.outerHTML : cells[3].innerHTML).trim(),
directionText: cells[3].textContent.trim(),
trigger: cells[4].textContent.trim(),
stopLoss: cells[5].textContent.trim(),
takeProfit: cells[6].textContent.trim(),
margin: cells[7].textContent.trim(),
leverage: cells[8].textContent.trim(),
holdMinutes: cells[9].textContent.trim(),
openedAt: cells[10].textContent.trim(),
closedAt: cells[11].textContent.trim(),
pnlHtml: cells[12].innerHTML.trim(),
pnlText: cells[12].textContent.trim(),
resultHtml: cells[13].innerHTML.trim(),
resultText: cells[13].textContent.trim(),
actionsHtml: cells[14].innerHTML,
};
}
function renderMobileTradeRow(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return "";
const pnlCls = pnlClassFromValue(row.pnlText);
return `<button type="button" class="mobile-record-row" data-row-id="${escapeHtml(row.rowId)}">
<span class="mrr-symbol">${escapeHtml(row.symbol)}</span>
<span class="mrr-dir">${row.directionHtml}</span>
<span class="mrr-pnl ${pnlCls}">${escapeHtml(row.pnlText || "-")}</span>
</button>`;
}
function tradeDetailRow(label, valueHtml) {
return `<div class="trd-row"><span class="trd-label">${escapeHtml(label)}</span><span class="trd-value">${valueHtml}</span></div>`;
}
function buildTradeRecordDetailHtml(row) {
return `<div class="trade-record-detail">${
tradeDetailRow("品种", escapeHtml(row.symbol)) +
tradeDetailRow("下单类型", escapeHtml(row.type)) +
tradeDetailRow("开仓类型", escapeHtml(row.entryReason || "-")) +
tradeDetailRow("方向", row.directionHtml) +
tradeDetailRow("成交价", escapeHtml(row.trigger)) +
tradeDetailRow("止损(开仓)", escapeHtml(row.stopLoss)) +
tradeDetailRow("止盈", escapeHtml(row.takeProfit)) +
tradeDetailRow("基数", escapeHtml(row.margin)) +
tradeDetailRow("杠杆", escapeHtml(row.leverage)) +
tradeDetailRow("持仓分钟", escapeHtml(row.holdMinutes)) +
tradeDetailRow("开仓时间", escapeHtml(row.openedAt)) +
tradeDetailRow("平仓时间", escapeHtml(row.closedAt)) +
tradeDetailRow("盈亏U", row.pnlHtml) +
tradeDetailRow("结果", row.resultHtml)
}</div>`;
}
function clearDetailActions() {
const el = document.getElementById("detailActions");
if (el) {
el.innerHTML = "";
el.style.display = "none";
}
}
function setDetailActionsHtml(html) {
let el = document.getElementById("detailActions");
if (!el) {
const panel = document.querySelector("#detailModal .panel");
if (!panel) return;
el = document.createElement("div");
el.id = "detailActions";
el.className = "detail-actions";
const body = document.getElementById("detailBody");
if (body && body.parentNode === panel) {
panel.insertBefore(el, body.nextSibling);
} else {
panel.appendChild(el);
}
}
el.innerHTML = html || "";
el.style.display = html ? "flex" : "none";
}
function promptReviewEntryReason(options, currentValue) {
const opts = Array.isArray(options) ? options : [];
const cur = String(currentValue == null ? "" : currentValue).trim();
return new Promise(function (resolve) {
const backdrop = document.createElement("div");
backdrop.className = "review-entry-reason-backdrop open";
const modal = document.createElement("div");
modal.className = "review-entry-reason-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("h3");
title.textContent = "开仓类型";
modal.appendChild(title);
const hint = document.createElement("p");
hint.className = "review-entry-reason-hint";
hint.textContent = "请选择下拉选项之一;选「不改该项」则保留原值.";
modal.appendChild(hint);
const select = document.createElement("select");
select.className = "review-entry-reason-select";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = "(不改该项)";
select.appendChild(emptyOpt);
const seen = new Set([""]);
if (cur && opts.indexOf(cur) < 0) {
const curOpt = document.createElement("option");
curOpt.value = cur;
curOpt.textContent = cur + "(当前)";
select.appendChild(curOpt);
seen.add(cur);
}
opts.forEach(function (opt) {
const v = String(opt || "").trim();
if (!v || seen.has(v)) return;
const o = document.createElement("option");
o.value = v;
o.textContent = v;
select.appendChild(o);
seen.add(v);
});
if (cur) select.value = cur;
modal.appendChild(select);
const actions = document.createElement("div");
actions.className = "review-entry-reason-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "review-entry-reason-cancel";
cancelBtn.textContent = "取消";
const okBtn = document.createElement("button");
okBtn.type = "button";
okBtn.className = "review-entry-reason-ok";
okBtn.textContent = "确定";
actions.appendChild(cancelBtn);
actions.appendChild(okBtn);
modal.appendChild(actions);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
function cleanup(result) {
document.removeEventListener("keydown", onKey);
backdrop.remove();
resolve(result);
}
function onKey(ev) {
if (ev.key === "Escape") cleanup(null);
}
cancelBtn.addEventListener("click", function () {
cleanup(null);
});
backdrop.addEventListener("click", function (ev) {
if (ev.target === backdrop) cleanup(null);
});
okBtn.addEventListener("click", function () {
cleanup(select.value);
});
document.addEventListener("keydown", onKey);
select.focus();
});
}
function openTradeRecordDetailModal(tr) {
const row = parseTradeRecordRow(tr);
if (!row) return;
const titleEl = document.getElementById("detailTitle");
if (titleEl) {
titleEl.innerText = `交易记录|${row.symbol}`;
}
const body = document.getElementById("detailBody");
if (body) {
body.classList.remove("md-review", "journal-detail-meta");
body.classList.add("trade-record-detail-wrap");
body.innerHTML = buildTradeRecordDetailHtml(row);
}
setDetailActionsHtml(
`<div class="detail-actions-inner">${row.actionsHtml}</div>`
);
const imgEl = document.getElementById("detailImage");
if (imgEl) {
imgEl.src = "";
imgEl.style.display = "none";
}
if (typeof setDetailModalFullscreen === "function") {
setDetailModalFullscreen(false);
}
const modal = document.getElementById("detailModal");
if (modal) modal.style.display = "flex";
}
global.InstanceUI = {
escapeHtml: escapeHtml,
pnlClassFromValue: pnlClassFromValue,
formatPnlSpan: formatPnlSpan,
buildJournalDetailHtml: buildJournalDetailHtml,
setJournalDetailBody: setJournalDetailBody,
openJournalDetailModal: openJournalDetailModal,
isMobileCompactRecords: isMobileCompactRecords,
inferJournalDirection: inferJournalDirection,
renderJournalListHtml: renderJournalListHtml,
parseTradeRecordRow: parseTradeRecordRow,
renderMobileTradeRow: renderMobileTradeRow,
buildTradeRecordDetailHtml: buildTradeRecordDetailHtml,
openTradeRecordDetailModal: openTradeRecordDetailModal,
clearDetailActions: clearDetailActions,
clearJournalDetailImages: clearJournalDetailImages,
setJournalDetailImages: setJournalDetailImages,
promptReviewEntryReason: promptReviewEntryReason,
};
})(typeof window !== "undefined" ? window : globalThis);
+175
View File
@@ -0,0 +1,175 @@
/**
* 复盘表单 AJAX 保存:避免整页刷新卡顿;配合 FormSubmitGuard 即时反馈.
* 使用 document 委托,兼容中控 embed 后插入的 #journal-form.
*/
(function (global) {
"use strict";
function $(id) {
return document.getElementById(id);
}
function toast(msg) {
if (!msg) return;
try {
if (global.InstanceTheme && typeof InstanceTheme.toast === "function") {
InstanceTheme.toast(msg);
return;
}
} catch (_) {}
try {
alert(msg);
} catch (_) {}
}
function refreshLists() {
if (global.RecordsReviewPage && typeof RecordsReviewPage.loadJournals === "function") {
try {
RecordsReviewPage.loadJournals();
} catch (_) {}
} else if (typeof global.loadJournals === "function") {
try {
global.loadJournals();
} catch (_) {}
}
// 交易记录保持可见:soft 刷新,勿「加载中…」占位
if (global.RecordsReviewPage && typeof RecordsReviewPage.loadTradeRecords === "function") {
try {
RecordsReviewPage.loadTradeRecords({ soft: true });
} catch (_) {}
} else if (typeof global.loadTradeRecords === "function") {
try {
global.loadTradeRecords({ soft: true });
} catch (_) {}
}
}
function resetJournalForm(form) {
if (!form) return;
form.reset();
["risk-amount-hint", "entry-price-hint", "stop-loss-hint", "exit-price-hint", "direction-hint"].forEach(
function (id) {
var el = $(id);
if (el) el.value = "";
}
);
if (global.JournalUploadSlots && typeof JournalUploadSlots.reset === "function") {
JournalUploadSlots.reset(form);
}
if (typeof global.syncEarlyExitNoteRequired === "function") {
try {
global.syncEarlyExitNoteRequired();
} catch (_) {}
}
if (global.RecordsReviewPage && typeof RecordsReviewPage.hideJournalCard === "function") {
try {
RecordsReviewPage.hideJournalCard();
} catch (_) {}
}
}
function onSuccess(form, data) {
var msg = (data && data.msg) || "交易复盘记录已保存";
toast(msg);
resetJournalForm(form);
refreshLists();
if (data && data.chart_pending) {
setTimeout(refreshLists, 4000);
setTimeout(refreshLists, 12000);
}
}
function parseJsonSafe(res) {
var ct = (res.headers.get("content-type") || "").toLowerCase();
if (ct.indexOf("application/json") >= 0) {
return res.json().then(function (data) {
return { kind: "json", ok: res.ok, data: data };
});
}
return res.text().then(function (text) {
return { kind: "html", ok: res.ok, redirected: !!res.redirected, text: text };
});
}
function submitAjax(form) {
if (global.FormSubmitGuard && FormSubmitGuard.isLocked(form)) return;
if (global.FormSubmitGuard) FormSubmitGuard.lock(form, "保存中…");
var fd = new FormData(form);
fd.set("ajax", "1");
var action = form.getAttribute("action") || "/add_journal";
fetch(action, {
method: "POST",
body: fd,
credentials: "same-origin",
redirect: "manual",
headers: {
"X-Requested-With": "XMLHttpRequest",
Accept: "application/json",
},
})
.then(function (res) {
// opaque redirect:后端未认 AJAX,仍走了 302
if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
throw new Error("保存接口未返回 JSON,请硬刷新页面后重试");
}
return parseJsonSafe(res);
})
.then(function (result) {
if (result.kind !== "json") {
throw new Error("保存接口未返回 JSON,请硬刷新页面后重试");
}
if (!result.ok || !result.data || result.data.ok === false) {
var err =
(result.data && (result.data.msg || result.data.error)) || "保存失败";
throw new Error(err);
}
onSuccess(form, result.data);
})
.catch(function (err) {
toast((err && err.message) || "保存失败,请稍后重试");
})
.finally(function () {
if (global.FormSubmitGuard) FormSubmitGuard.unlock(form);
});
}
function onSubmit(ev) {
var form = ev.target;
if (!form || form.id !== "journal-form") return;
if (typeof global.validateJournalEntryReason === "function") {
if (!global.validateJournalEntryReason()) {
ev.preventDefault();
if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation();
return;
}
}
ev.preventDefault();
if (typeof ev.stopImmediatePropagation === "function") ev.stopImmediatePropagation();
submitAjax(form);
}
function bind(form) {
// 保留显式 bind 入口(embed 切 tab 时可再调);真正拦截靠 document 委托
if (form) form.dataset.journalAjaxBound = "1";
}
function init() {
if (document.documentElement.dataset.journalFormSaveDelegated === "1") {
bind($("journal-form"));
return;
}
document.documentElement.dataset.journalFormSaveDelegated = "1";
document.addEventListener("submit", onSubmit, true);
bind($("journal-form"));
}
global.JournalFormSave = { init: init, bind: bind };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
+153
View File
@@ -0,0 +1,153 @@
/**
* 复盘表单:四周期截图即时上传与状态展示.
*/
(function (global) {
"use strict";
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
}
var s = "";
for (var i = 0; i < 32; i++) {
s += Math.floor(Math.random() * 16).toString(16);
}
return s;
}
function ensureDraftId(root) {
var scope = root || document;
var el = scope.querySelector("#journal-draft-id");
if (!el) return "";
if (!el.value) {
el.value = newDraftId();
}
return el.value;
}
function rowParts(input) {
var row = input.closest(".journal-upload-row");
if (!row) return {};
return {
row: row,
status: row.querySelector(".journal-upload-status"),
hidden: row.querySelector(".journal-upload-hidden-file"),
};
}
function setStatus(statusEl, text, kind) {
if (!statusEl) return;
statusEl.textContent = text || "";
statusEl.classList.remove(
"journal-upload-status--pending",
"journal-upload-status--ok",
"journal-upload-status--err"
);
if (kind) {
statusEl.classList.add("journal-upload-status--" + kind);
}
}
function uploadSlotFile(input, file) {
var parts = rowParts(input);
var draftId = ensureDraftId(input.form || document);
if (!draftId || !file) {
setStatus(parts.status, "上传失败", "err");
return;
}
setStatus(parts.status, "上传中…", "pending");
if (parts.hidden) {
parts.hidden.value = "";
}
var fd = new FormData();
fd.append("journal_draft_id", draftId);
fd.append("tf", input.getAttribute("data-tf") || "");
fd.append("file", file);
fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" })
.then(function (res) {
return res.json().then(function (data) {
return { ok: res.ok, data: data };
});
})
.then(function (result) {
if (!result.ok || !result.data || !result.data.ok) {
throw new Error(
(result.data && result.data.error) || "upload failed"
);
}
var fname = String(result.data.file || "").trim();
if (parts.hidden) {
parts.hidden.value = fname;
}
input.value = "";
setStatus(parts.status, "上传成功 " + fname, "ok");
})
.catch(function () {
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "上传失败", "err");
});
}
function isOptionsReviewSlot(input) {
if (!input) return false;
if (input.classList && input.classList.contains("or-upload-input")) return true;
return !!(input.closest && input.closest("#or-upload-slots, #options-review-root"));
}
function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return;
// 期权复盘槽位由 options_review.js 处理,勿被合约复盘上传抢走
if (isOptionsReviewSlot(input)) return;
input.dataset.journalSlotBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) {
var parts = rowParts(input);
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "", "");
return;
}
uploadSlotFile(input, file);
});
}
function resetSlots(root) {
var scope = root || document;
var draftEl = scope.querySelector("#journal-draft-id");
if (draftEl) {
draftEl.value = newDraftId();
}
scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) {
el.value = "";
});
scope.querySelectorAll(".journal-upload-status").forEach(function (el) {
setStatus(el, "", "");
});
}
function init(root) {
var scope = root || document;
ensureDraftId(scope);
scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput);
}
global.JournalUploadSlots = { init: init, reset: resetSlots };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+160
View File
@@ -0,0 +1,160 @@
/**
* 关键位监控添加表单:类型切换显隐,成交量排名校验(三所实例共用).
*/
(function (global) {
const RS_TYPES = new Set([
"关键支撑阻力",
"关键阻力位",
"关键支撑位",
]);
function syncKeyMonitorFormFields() {
const typeEl = document.querySelector('#key-form [name="type"]');
const dirEl = document.getElementById("key-direction");
const modeEl = document.getElementById("key-sl-tp-mode");
const manualTp = document.getElementById("key-manual-tp");
const beWrap = document.getElementById("key-breakeven-wrap");
if (!typeEl) return;
const t = (typeEl.value || "").trim();
const autoTypes = new Set(["箱体突破", "收敛突破"]);
const fibTypes = new Set(["斐波回调0.618", "斐波回调0.786"]);
const fbTypes = new Set(["假突破"]);
const teTypes = new Set(["回调触价开仓", "突破触价开仓", "触价开仓"]);
const showAuto = autoTypes.has(t);
const showFb = fbTypes.has(t);
const showTe = teTypes.has(t);
const showBe = showAuto || fibTypes.has(t) || showFb || showTe;
const showDir = !RS_TYPES.has(t);
const upperEl = document.getElementById("key-upper");
const lowerEl = document.getElementById("key-lower");
const fbPriceEl = document.getElementById("key-fb-price");
const teEntryEl = document.getElementById("key-trigger-entry");
const teSlEl = document.getElementById("key-trigger-sl");
const teTpEl = document.getElementById("key-trigger-tp");
if (dirEl) {
dirEl.style.display = showDir ? "" : "none";
dirEl.required = showDir;
if (!showDir) dirEl.value = "";
}
if (modeEl) modeEl.style.display = showAuto ? "" : "none";
if (manualTp) {
const trend = showAuto && modeEl && modeEl.value === "trend_manual";
manualTp.style.display = trend ? "" : "none";
manualTp.required = !!trend;
}
if (beWrap) beWrap.style.display = showBe ? "inline-flex" : "none";
if (global.TimeCloseUI) global.TimeCloseUI.syncKeyTimeCloseVisibility(showBe);
const hideBounds = showFb || showTe;
if (upperEl) {
upperEl.style.display = hideBounds ? "none" : "";
upperEl.required = !hideBounds;
if (hideBounds) upperEl.value = "";
}
if (lowerEl) {
lowerEl.style.display = hideBounds ? "none" : "";
lowerEl.required = !hideBounds;
if (hideBounds) lowerEl.value = "";
}
if (fbPriceEl) {
fbPriceEl.style.display = showFb ? "" : "none";
fbPriceEl.required = showFb;
if (!showFb) fbPriceEl.value = "";
fbPriceEl.placeholder =
dirEl && dirEl.value === "short"
? "高点(阻力)"
: dirEl && dirEl.value === "long"
? "低点(支撑)"
: "做空填高点/做多填低点";
}
[teEntryEl, teSlEl, teTpEl].forEach((el) => {
if (!el) return;
el.style.display = showTe ? "" : "none";
el.required = showTe;
if (!showTe) el.value = "";
});
}
function submitKeyForm(keyForm, label) {
if (
document.body &&
document.body.getAttribute("data-embed-shell") === "1" &&
global.InstanceEmbed &&
typeof global.InstanceEmbed.postFormAndReload === "function"
) {
global.InstanceEmbed.postFormAndReload(keyForm, label || "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.nativeSubmitOnce(keyForm, label || "提交中…");
else keyForm.submit();
}
function bindKeyMonitorForm() {
const keyForm = document.getElementById("key-form");
const keyTypeSel = document.querySelector('#key-form [name="type"]');
const keyModeSel = document.getElementById("key-sl-tp-mode");
const keyDirSel = document.getElementById("key-direction");
if (keyTypeSel) keyTypeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyModeSel) keyModeSel.addEventListener("change", syncKeyMonitorFormFields);
if (keyDirSel) keyDirSel.addEventListener("change", syncKeyMonitorFormFields);
syncKeyMonitorFormFields();
if (global.TimeCloseUI) {
global.TimeCloseUI.bindTimeCloseForm(
"key-time-close-cb",
"key-time-close-hours",
"key-time-close-wrap"
);
}
if (!keyForm || keyForm.dataset.keyFormBound === "1") return;
keyForm.dataset.keyFormBound = "1";
keyForm.addEventListener("submit", (e) => {
e.preventDefault();
if (global.FormSubmitGuard && global.FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if (!symbol) {
alert("请先输入交易对");
return;
}
const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || "";
if (typeVal === "假突破") {
submitKeyForm(keyForm, "提交中…");
return;
}
if (global.FormSubmitGuard) global.FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then((r) => r.json().then((d) => ({ status: r.status, data: d })))
.then(({ status, data }) => {
if (status >= 400 || !data.ok) {
alert((data && data.msg) || "日成交量排名读取失败");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if (data.rank == null || !inTop) {
alert(
`${data.symbol} 当前日成交量排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截.`
);
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
return;
}
submitKeyForm(keyForm, "提交中…");
})
.catch(() => {
alert("日成交量排名检查失败,请稍后重试");
if (global.FormSubmitGuard) global.FormSubmitGuard.unlock(keyForm);
});
});
}
global.KeyMonitorForm = {
syncFields: syncKeyMonitorFormFields,
init: bindKeyMonitorForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bindKeyMonitorForm);
} else {
bindKeyMonitorForm();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,340 @@
/**
* 实盘下单:填完币种与止盈止损后,在表单下方显示预估风险 / 预估盈利 / 预估盈亏比.
* 以损定仓:风险 = 当前交易基数 × risk%.
* 全仓杠杆:风险 = 可用保证金×缓冲 × 杠杆 × |SL-入场|/入场(与开仓 calc_risk_amount_from_plan 一致).
*/
(function (global) {
"use strict";
let debounceMs = 400;
let minRr = 1.5;
let debounceTimer = null;
let fetchSeq = 0;
function $(id) {
return document.getElementById(id);
}
function num(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatRr(rr) {
if (rr === null || typeof rr === "undefined") return "—";
const n = Number(rr);
if (!Number.isFinite(n)) return "—";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function formatU(v) {
if (v === null || typeof v === "undefined" || !Number.isFinite(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function setMetric(el, label, valueText) {
if (!el) return;
el.innerHTML = label + ":<strong>" + valueText + "</strong>";
}
function sizingMode() {
return (document.body && document.body.getAttribute("data-position-sizing-mode")) || "risk";
}
function isFullMarginMode() {
return sizingMode() === "full_margin";
}
function fullMarginBuffer() {
const n = Number(document.body && document.body.getAttribute("data-full-margin-buffer"));
return Number.isFinite(n) && n > 0 ? n : 0.9;
}
function leverageForSymbol(sym) {
const u = (sym || "").trim().toUpperCase();
const btc = Number(document.body && document.body.getAttribute("data-btc-leverage"));
const alt = Number(document.body && document.body.getAttribute("data-alt-leverage"));
if (u.startsWith("BTC") || u.startsWith("ETH")) {
return Number.isFinite(btc) && btc > 0 ? btc : 10;
}
return Number.isFinite(alt) && alt > 0 ? alt : 5;
}
function riskPercent() {
const form = $("add-order-form");
const raw =
(form && form.getAttribute("data-risk-percent")) ||
(document.body && document.body.getAttribute("data-risk-percent")) ||
"";
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : 1;
}
function calcRiskFraction(direction, entry, sl) {
const e = num(entry);
const s = num(sl);
if (e === null || s === null || e <= 0 || s <= 0) return null;
let risk = 0;
if (direction === "short") {
risk = s - e;
} else {
risk = e - s;
}
if (risk <= 0) return null;
return risk / e;
}
function calcRr(direction, entry, sl, tp) {
const e = num(entry);
const s = num(sl);
const t = num(tp);
if (e === null || s === null || t === null) return null;
if (direction === "short") {
if (s <= e || t >= e) return null;
return (e - t) / (s - e);
}
if (s >= e || t <= e) return null;
return (t - e) / (e - s);
}
function calcRrFromPct(slPct, tpPct) {
const sl = num(slPct);
const tp = num(tpPct);
if (sl === null || tp === null || sl <= 0 || tp <= 0) return null;
return tp / sl;
}
function calcTpFromFixedRr(direction, entry, sl, rr) {
const e = num(entry);
const s = num(sl);
const r = num(rr);
if (e === null || s === null || r === null || r <= 0) return null;
if (direction === "short") {
if (s <= e) return null;
return e - (s - e) * r;
}
if (s >= e) return null;
return e + (e - s) * r;
}
function resolveSlPrice(mode, direction, entry) {
if (mode === "pct") {
const slPct = num($("order-sl-pct") && $("order-sl-pct").value);
if (slPct === null || slPct <= 0) return null;
if (direction === "short") return entry * (1 + slPct / 100);
return entry * (1 - slPct / 100);
}
return num($("order-sl") && $("order-sl").value);
}
function currentMode() {
return ($("sltp-mode") && $("sltp-mode").value) || "fixed_rr";
}
function currentDirection() {
return ($("order-direction") && $("order-direction").value) || "long";
}
function currentSymbol() {
return (($("order-symbol") && $("order-symbol").value) || "").trim();
}
function inputsComplete(m) {
const dir = currentDirection();
if (!currentSymbol() || !dir) return false;
if (m === "pct") {
const sl = num($("order-sl-pct") && $("order-sl-pct").value);
const tp = num($("order-tp-pct") && $("order-tp-pct").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
if (m === "fixed_rr") {
const sl = num($("order-sl") && $("order-sl").value);
const rr = num($("order-fixed-rr") && $("order-fixed-rr").value);
return sl !== null && rr !== null && sl > 0 && rr > 0;
}
const sl = num($("order-sl") && $("order-sl").value);
const tp = num($("order-tp") && $("order-tp").value);
return sl !== null && tp !== null && sl > 0 && tp > 0;
}
function paintEmpty() {
setMetric($("order-risk-preview"), "预估风险", "—");
setMetric($("order-profit-preview"), "预估盈利", "—");
setMetric($("order-rr-preview"), "预估盈亏比", "—");
}
function paintLoading() {
setMetric($("order-risk-preview"), "预估风险", "计算中…");
setMetric($("order-profit-preview"), "预估盈利", "计算中…");
setMetric($("order-rr-preview"), "预估盈亏比", "计算中…");
}
function paintFail(kind) {
const msg = kind === "fetch_fail" ? "取价失败" : "无效";
setMetric($("order-risk-preview"), "预估风险", msg);
setMetric($("order-profit-preview"), "预估盈利", msg);
setMetric($("order-rr-preview"), "预估盈亏比", msg);
}
function paintOk(riskU, profitU, rr) {
setMetric($("order-risk-preview"), "预估风险", formatU(riskU));
setMetric($("order-profit-preview"), "预估盈利", formatU(profitU));
const rrEl = $("order-rr-preview");
const rrText = formatRr(rr);
setMetric(rrEl, "预估盈亏比", rrText);
if (rrEl && rr !== null && Number.isFinite(Number(rr))) {
rrEl.classList.toggle("order-preview-rr-low", Number(rr) < minRr);
rrEl.classList.toggle("order-preview-rr-ok", Number(rr) >= minRr);
}
}
function plannedRiskFromRiskMode(capital) {
const cap = num(capital);
if (cap === null || cap <= 0) return null;
return Math.round((cap * riskPercent()) / 100 * 100) / 100;
}
function plannedRiskFromFullMargin(availableUsdt, symbol, direction, entry, sl) {
const avail = num(availableUsdt);
if (avail === null || avail <= 0) return null;
const slPx = num(sl);
const entryPx = num(entry);
if (slPx === null || entryPx === null) return null;
const rf = calcRiskFraction(direction, entryPx, slPx);
if (rf === null) return null;
const margin = Math.round(avail * fullMarginBuffer() * 100) / 100;
const lev = leverageForSymbol(symbol);
return Math.round(margin * lev * rf * 100) / 100;
}
function resolvePreviewRr(m, dir, entry) {
if (m === "pct") {
return calcRrFromPct(
$("order-sl-pct") && $("order-sl-pct").value,
$("order-tp-pct") && $("order-tp-pct").value
);
}
const sl = num($("order-sl") && $("order-sl").value);
if (m === "fixed_rr") {
const fixed = num($("order-fixed-rr") && $("order-fixed-rr").value);
if (fixed !== null && fixed > 0) return fixed;
const tp = calcTpFromFixedRr(dir, entry, sl, fixed);
return calcRr(dir, entry, sl, tp);
}
const tp = num($("order-tp") && $("order-tp").value);
return calcRr(dir, entry, sl, tp);
}
function refreshNow() {
if (!$("order-plan-preview")) return;
const m = currentMode();
if (!inputsComplete(m)) {
paintEmpty();
return;
}
const sym = currentSymbol();
const dir = currentDirection();
const seq = ++fetchSeq;
paintLoading();
const defaultsP = fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
).then(function (r) {
return r.json();
});
const capitalP = fetch("/api/account_snapshot").then(function (r) {
return r.json();
});
Promise.all([defaultsP, capitalP])
.then(function (results) {
if (seq !== fetchSeq) return;
const data = results[0];
const account = results[1] || {};
if (!data.ok) {
paintFail("fetch_fail");
return;
}
const entry = num(data.last_price != null ? data.last_price : data.price);
if (entry === null) {
paintFail("fetch_fail");
return;
}
const rr = resolvePreviewRr(m, dir, entry);
if (rr === null) {
paintFail("invalid");
return;
}
let riskU = null;
if (isFullMarginMode()) {
const slPx = resolveSlPrice(m, dir, entry);
const avail =
data.available_trading_usdt != null
? data.available_trading_usdt
: account.available_trading_usdt;
riskU = plannedRiskFromFullMargin(avail, sym, dir, entry, slPx);
} else {
riskU = plannedRiskFromRiskMode(account.current_capital);
}
if (riskU === null) {
paintFail("fetch_fail");
return;
}
const profitU = Math.round(riskU * rr * 100) / 100;
paintOk(riskU, profitU, rr);
})
.catch(function () {
if (seq !== fetchSeq) return;
paintFail("fetch_fail");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshNow, debounceMs);
}
function wire(opts) {
opts = opts || {};
if (opts.minRr != null && Number.isFinite(Number(opts.minRr))) {
minRr = Number(opts.minRr);
}
if (opts.debounceMs != null && Number.isFinite(Number(opts.debounceMs))) {
debounceMs = Number(opts.debounceMs);
}
[
"order-symbol",
"order-direction",
"sltp-mode",
"order-sl",
"order-tp",
"order-sl-pct",
"order-tp-pct",
"order-fixed-rr",
"order-leverage",
].forEach(function (id) {
const el = $(id);
if (!el || el._rrPreviewBound) return;
el._rrPreviewBound = true;
el.addEventListener("input", schedule);
el.addEventListener("change", schedule);
});
schedule();
}
global.ManualOrderRrPreview = {
wire: wire,
schedule: schedule,
refresh: refreshNow,
calcRr: calcRr,
calcRrFromPct: calcRrFromPct,
calcRiskFraction: calcRiskFraction,
formatRr: formatRr,
};
})(typeof window !== "undefined" ? window : globalThis);
+55
View File
@@ -0,0 +1,55 @@
/**
* 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等).
*/
(function (global) {
function apply(data) {
const d = data || {};
const btn =
document.getElementById("om-submit-btn") ||
document.querySelector("#add-order-form button.om-submit");
const noteEl = document.getElementById("om-open-block-note");
if (!btn && !noteEl) return;
const canTrade = d.can_trade !== false;
let note = (d.open_block_note || "").trim();
const fc = d.force_close || {};
const rs = d.risk_status || {};
if (!note && fc.enabled && fc.executing) {
const grace = fc.grace_minutes != null ? fc.grace_minutes : 5;
note =
"强制清仓窗口内(北京时间 " +
(fc.hour_label || "--:--") +
" 起 " +
grace +
" 分钟),暂不可开仓";
}
if (!note && rs.can_trade === false && rs.reason) {
note = String(rs.reason);
}
if (!note && !canTrade) {
note = "当前不可开仓";
}
if (btn) {
btn.disabled = !canTrade;
btn.classList.toggle("is-blocked", !canTrade);
btn.setAttribute("aria-disabled", canTrade ? "false" : "true");
if (!canTrade) {
btn.title = note || "当前不可开仓";
} else {
btn.removeAttribute("title");
}
}
if (noteEl) {
if (!canTrade && note) {
noteEl.hidden = false;
noteEl.textContent = note;
} else {
noteEl.hidden = true;
noteEl.textContent = "";
}
}
}
global.OpenSubmitGate = { apply: apply };
})(window);
@@ -0,0 +1,58 @@
/**
* 期权到期倒计时(实例期权页 + 中控监控/看板共用)
*/
(function (global) {
function normalizeExpMs(v) {
if (v == null || v === "") return null;
var n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
if (n < 1e12) n *= 1000;
return n;
}
function formatCountdown(expMs, nowMs) {
var ms = normalizeExpMs(expMs);
if (ms == null) return "—";
var now = nowMs != null ? nowMs : Date.now();
var rem = Math.max(0, Math.floor((ms - now) / 1000));
if (rem <= 0) return "已到期";
var d = Math.floor(rem / 86400);
var h = Math.floor((rem % 86400) / 3600);
var m = Math.floor((rem % 3600) / 60);
var s = rem % 60;
var pad = function (x) {
return String(x).padStart(2, "0");
};
if (d > 0) return d + "天 " + pad(h) + ":" + pad(m) + ":" + pad(s);
return pad(h) + ":" + pad(m) + ":" + pad(s);
}
function tick(root) {
var scope = root && root.querySelectorAll ? root : document;
var now = Date.now();
scope.querySelectorAll("[data-opt-exp-ms]").forEach(function (el) {
var exp = el.getAttribute("data-opt-exp-ms");
var text = formatCountdown(exp, now);
el.textContent = text;
var expMs = normalizeExpMs(exp);
el.classList.toggle("opt-expiry-cd--urgent", expMs != null && expMs - now > 0 && expMs - now < 3600000);
el.classList.toggle("opt-expiry-cd--expired", text === "已到期");
});
}
var timer = null;
function ensureTimer() {
tick();
if (timer) return;
timer = setInterval(function () {
tick();
}, 1000);
}
global.OptionsExpiryCountdown = {
normalizeExpMs: normalizeExpMs,
format: formatCountdown,
tick: tick,
ensureTimer: ensureTimer,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
(function (global) {
"use strict";
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
}
function fmtDisplay(v, fallback) {
if (v !== null && v !== undefined && String(v).trim() !== "") return String(v);
if (fallback !== undefined) return fmtDisplay(fallback);
return "—";
}
function fmtOptionPx(v, tickSz) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const tick = Number(tickSz);
if (!tickSz || Number.isNaN(tick) || tick <= 0) {
let s = n.toFixed(4).replace(/\.?0+$/, "");
return s || "0";
}
let decimals = 0;
if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick)));
else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length;
let s = n.toFixed(decimals);
// 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137
if (decimals > 0) s = s.replace(/\.?0+$/, "");
return s || "0";
}
function fmtUsdc(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(2);
}
function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
function sourceText(p) {
const lab = (p && p.source_label) || "纯期权";
const src = (p && p.source) || "option";
let pid = p && p.source_plan_id;
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
pid = p.hedge_plan_target.plan_id;
}
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
return lab;
}
function sourceBadgeHtml(p) {
const src = (p && p.source) || "option";
const cls =
src === "options_options"
? "opt-source-badge opt-source-badge--oo"
: src === "perp_options"
? "opt-source-badge opt-source-badge--po"
: "opt-source-badge opt-source-badge--plain";
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
}
function pnlCls(upl, hub) {
if (upl > 0) return hub ? "pnl-pos" : "pos-pnl-profit";
if (upl < 0) return hub ? "pnl-neg" : "pos-pnl-loss";
return "";
}
function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price;
const s = Number(sz);
const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s);
return price + "/" + size;
}
/** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */
function fmtCloseLevels(preview, tickSz) {
if (preview && preview.bid_invalid) {
return "暂无有效买盘";
}
const levels = ((preview && preview.levels) || []).slice(0, 5);
if (!levels.length) return "—";
return levels.map(function (x, idx) {
const levelNo = x.level != null ? x.level : idx + 1;
const liq = x.available_sheets != null ? x.available_sheets : x.sz;
return "买" + levelNo + " " + fmtPxSz(x.px, liq, tickSz);
}).join(" · ");
}
function closeGateHint(preview) {
if (!preview) return "";
if (preview.bid_invalid || preview.manual_close_blocked) {
return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓";
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
}
return "";
}
function netPnlFromPos(p) {
const preview = (p && p.close_preview) || {};
if (preview.bid_invalid) {
const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
}
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
return Number(preview.estimated_pnl);
}
const covered = Number(preview.covered_sheets);
const recv = Number(preview.total_received);
const prem = Number(p && p.premium_paid);
if (
preview.total_received != null &&
Number.isFinite(covered) &&
covered > 0 &&
!Number.isNaN(recv) &&
!Number.isNaN(prem)
) {
return recv - prem;
}
const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
}
function netRoiFromPos(p, net) {
const preview = (p && p.close_preview) || {};
if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) {
return Number(preview.estimated_pnl_ratio_pct);
}
const prem = Number(p && p.premium_paid);
if (net == null || Number.isNaN(prem) || prem <= 0) return null;
return (net / prem) * 100;
}
function fmtClosePreview(preview, premiumPaid, hub) {
if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received);
let cls = "";
const prem = Number(premiumPaid);
const recv = Number(preview.total_received);
if (!Number.isNaN(prem) && !Number.isNaN(recv)) {
if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub);
}
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
}
function expiryCdHtml(expMs) {
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
if (!ms) return "—";
return '<span class="opt-expiry-cd" data-opt-exp-ms="' + ms + '">—</span>';
}
function renderCardInner(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const readOnly = !!opts.readOnly;
const hidePnl = !!opts.hidePnl;
const net = hidePnl ? null : netPnlFromPos(p);
const roi = hidePnl ? null : netRoiFromPos(p, net);
const uplCls = hidePnl ? "" : pnlCls(net, hub);
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {};
const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = "";
if (!readOnly) {
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
headActions =
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + (p.inst_id || "") + '" data-sheets="' + closeSheets + '">买一平仓</button>' +
"</div>";
}
const pnlCells = hidePnl
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
sourceBadgeHtml(p) +
"</div>" +
headActions +
"</div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") +
"</div>" +
'<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
pnlCells +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
})() +
(p.target_index != null
? (function () {
const eth = p.eth_amount != null ? Number(p.eth_amount)
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
const strike = Number(p.strike);
const tgt = Number(p.target_index);
const prem = Number(p.premium_paid);
let profit = null;
let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
const o = String(p.opt_type || "").toUpperCase();
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
const profitSpan = hidePnl
? ""
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
return (
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
profitSpan +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
"</span></div>"
);
})()
: "")
);
}
function renderCard(p, opts) {
opts = opts || {};
const hub = !!opts.hub;
const extraCls = hub ? " hub-pos-card hub-opt-pos-card" : " opt-pos-card";
return (
'<div class="pos-card' + extraCls + '" data-inst="' + (p.inst_id || "") + '">' +
renderCardInner(p, opts) +
"</div>"
);
}
global.OptionsPositionCards = {
renderCardInner: renderCardInner,
renderCard: renderCard,
};
})(typeof window !== "undefined" ? window : globalThis);
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
(function () {
"use strict";
const root = document.getElementById("options-settings-root");
if (!root) return;
const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"];
const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"];
async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
return r.json();
}
function refreshFundsAfterMutation() {
if (typeof refreshAccountSnapshot !== "function") return;
refreshAccountSnapshot({ force: true });
setTimeout(function () {
refreshAccountSnapshot({ force: true, silent: true });
}, 1500);
}
function setMsg(id, text, isErr) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text || "";
el.classList.toggle("opt-error", !!isErr);
el.classList.toggle("opt-success", !!text && !isErr);
}
function fmtAmt(amount, ccy) {
return `${Number(amount).toFixed(2)} ${ccy}`;
}
function accountLabel(acct) {
return acct === "trading" ? "交易账户" : "资金账户";
}
function swapDirLabel(dir) {
return dir === "usdc_to_usdt" ? "USDC → USDT" : "USDT → USDC";
}
function confirmOk(message) {
return window.confirm(message);
}
function setButtonsBusy(btnIds, busy, busyText) {
btnIds.forEach(function (id) {
const btn = document.getElementById(id);
if (!btn) return;
if (busy) {
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent;
btn.disabled = true;
if (busyText) btn.textContent = busyText;
} else {
btn.disabled = false;
if (btn.dataset.origText) {
btn.textContent = btn.dataset.origText;
delete btn.dataset.origText;
}
}
});
const amountIds = {
"opt-set-swap-btn": "opt-set-swap-amount",
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-btn": "opt-set-int-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-btn": "opt-set-cross-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
};
btnIds.forEach(function (id) {
const input = document.getElementById(amountIds[id]);
if (input) input.disabled = busy;
});
}
function roundAvail(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 100) / 100;
}
async function loadBalances(force, scope) {
const parts = [];
if (force) parts.push("force=1");
if (scope && scope !== "main") parts.push("scope=" + encodeURIComponent(scope));
const q = parts.length ? "?" + parts.join("&") : "";
const d = await apiJson("/api/options/balances" + q);
if (!d.ok) throw new Error(d.msg || "余额拉取失败");
return d;
}
function pickBalance(bal, account, ccy) {
const acct = account === "trading" ? "trading" : "funding";
const c = String(ccy || "").toLowerCase();
const availKey = acct + "_" + c + "_avail";
const totalKey = acct + "_" + c;
return roundAvail(bal[availKey] != null ? bal[availKey] : bal[totalKey]);
}
async function resolveSwapMaxAmount(dir) {
const bal = await loadBalances(true, "main");
const ccy = dir === "usdc_to_usdt" ? "USDC" : "USDT";
// 币种兑换走资金账户现货;统一账户下 USDT 有时在交易户,市价单仍可能成交
let amount = pickBalance(bal, "funding", ccy);
let source = "funding";
if (!amount && ccy === "USDT") {
const tradingAmt = pickBalance(bal, "trading", ccy);
if (tradingAmt) {
amount = tradingAmt;
source = "trading";
}
}
return { amount, bal, ccy, source };
}
async function resolveMaxAmount(account, ccy, scope) {
const bal = await loadBalances(true, scope || "main");
return pickBalance(bal, account, ccy);
}
async function submitSwap(amount) {
setButtonsBusy(SWAP_BTNS, true, "兑换中…");
setMsg("opt-set-swap-msg", "兑换中,市价成交可能有延时…", false);
try {
const d = await apiJson("/api/options/spot/swap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
direction: document.getElementById("opt-set-swap-dir").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-swap-msg", "兑换成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-swap-msg", "兑换失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(SWAP_BTNS, false);
}
}
const swapBtn = document.getElementById("opt-set-swap-btn");
if (swapBtn) {
swapBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-swap-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-swap-msg", "请输入有效数量", true);
return;
}
await submitSwap(amount);
});
}
const swapAllBtn = document.getElementById("opt-set-swap-all-btn");
if (swapAllBtn) {
swapAllBtn.addEventListener("click", async function () {
try {
const dir = document.getElementById("opt-set-swap-dir").value;
const { amount, bal, ccy, source } = await resolveSwapMaxAmount(dir);
if (!amount) {
const fu = bal.funding_usdt_avail != null ? bal.funding_usdt_avail : bal.funding_usdt;
const tu = bal.trading_usdt_avail != null ? bal.trading_usdt_avail : bal.trading_usdt;
const fc = bal.funding_usdc_avail != null ? bal.funding_usdc_avail : bal.funding_usdc;
setMsg(
"opt-set-swap-msg",
"资金账户可用 " +
ccy +
" 不足(资金户 USDT:" +
(fu != null ? fu : "—") +
" USDC:" +
(fc != null ? fc : "—") +
"; 交易户 USDT:" +
(tu != null ? tu : "—") +
")",
true
);
return;
}
const srcLabel = source === "trading" ? "交易账户" : "资金账户";
const msg =
"确认全部兑换?\n\n" +
"方向:" + swapDirLabel(dir) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n" +
"来源:" + srcLabel + "\n\n" +
"将按该账户可用余额发起市价兑换(可能有延时)。请确认。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-swap-amount").value = String(amount);
await submitSwap(amount);
} catch (e) {
setMsg("opt-set-swap-msg", "兑换失败:" + (e.message || "余额拉取失败"), true);
}
});
}
async function submitInternalTransfer(amount) {
setButtonsBusy(INT_BTNS, true, "划转中…");
setMsg("opt-set-int-msg", "划转中…", false);
try {
const d = await apiJson("/api/options/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ccy: document.getElementById("opt-set-int-ccy").value,
from: document.getElementById("opt-set-int-from").value,
to: document.getElementById("opt-set-int-to").value,
amount: amount,
}),
});
if (d.ok) {
setMsg("opt-set-int-msg", "划转成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-int-msg", "划转失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(INT_BTNS, false);
}
}
const intBtn = document.getElementById("opt-set-int-btn");
if (intBtn) {
intBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-int-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-int-msg", "请输入有效数量", true);
return;
}
await submitInternalTransfer(amount);
});
}
const intAllBtn = document.getElementById("opt-set-int-all-btn");
if (intAllBtn) {
intAllBtn.addEventListener("click", async function () {
try {
const ccy = document.getElementById("opt-set-int-ccy").value;
const from = document.getElementById("opt-set-int-from").value;
const to = document.getElementById("opt-set-int-to").value;
const amount = await resolveMaxAmount(from, ccy, "main");
if (!amount) {
setMsg("opt-set-int-msg", "划出账户可用余额不足", true);
return;
}
const msg =
"确认全部划转?\n\n" +
"币种:" + ccy + "\n" +
"划出:" + accountLabel(from) + "\n" +
"划入:" + accountLabel(to) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
"将划转该账户全部可用余额。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-int-amount").value = String(amount);
await submitInternalTransfer(amount);
} catch (e) {
setMsg("opt-set-int-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
}
});
}
function hardenAmountAutofill(ids) {
ids.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
function wipe() {
const v = String(el.value || "").trim();
if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = "";
}
wipe();
el.setAttribute("readonly", "readonly");
el.addEventListener("focus", function () {
el.removeAttribute("readonly");
});
el.addEventListener("blur", function () {
if (!el.value) el.setAttribute("readonly", "readonly");
});
setTimeout(wipe, 200);
setTimeout(wipe, 800);
setTimeout(wipe, 2000);
});
}
// 全部划转/兑换前去掉 readonly,避免写不进数量
["opt-set-swap-all-btn", "opt-set-int-all-btn"].forEach(function (btnId) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener(
"click",
function () {
const map = {
"opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-all-btn": "opt-set-int-amount",
};
const input = document.getElementById(map[btnId]);
if (input) input.removeAttribute("readonly");
},
true
);
});
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount"]);
})();
+214
View File
@@ -0,0 +1,214 @@
(function (global) {
var delegated = false;
function queryInScope(scope, id) {
if (scope && scope.querySelector) return scope.querySelector("#" + id);
return document.getElementById(id);
}
function categoriesData() {
return global.ORDER_ENTRY_MODEL_CATEGORIES || [];
}
function codeToCategoryMap() {
return global.ORDER_ENTRY_MODEL_CODE_TO_CATEGORY || {};
}
function tradeStyleForCode(code, modelSel) {
if (modelSel && code) {
var opt = modelSel.querySelector('option[value="' + code.replace(/"/g, '\\"') + '"]');
if (opt) {
var ds = opt.getAttribute("data-trade-style");
if (ds === "swing" || ds === "trend") return ds;
}
}
var map = global.ORDER_ENTRY_MODEL_TRADE_STYLE || {};
return map[code] || "trend";
}
function findOption(catKey, code) {
var cats = categoriesData();
for (var i = 0; i < cats.length; i++) {
if (cats[i].key !== catKey) continue;
var opts = cats[i].options || [];
for (var j = 0; j < opts.length; j++) {
if (opts[j].code === code) return opts[j];
}
}
return null;
}
function filterDomSubOptions(modelSel, catKey, preserveCode) {
var tagged = modelSel.querySelectorAll("option[data-entry-category]");
if (!tagged.length) return false;
var any = false;
for (var i = 0; i < tagged.length; i++) {
var opt = tagged[i];
var show = !!catKey && opt.getAttribute("data-entry-category") === catKey;
opt.hidden = !show;
opt.disabled = !show;
if (show) any = true;
}
modelSel.disabled = !any;
if (!any) {
modelSel.value = "";
return true;
}
var pick = preserveCode || "";
if (pick) {
var picked = modelSel.querySelector('option[value="' + pick.replace(/"/g, '\\"') + '"]:not([disabled])');
if (picked) {
modelSel.value = pick;
return true;
}
}
var visible = [];
for (var k = 0; k < tagged.length; k++) {
if (!tagged[k].disabled) visible.push(tagged[k]);
}
if (visible.length === 1) modelSel.value = visible[0].value;
else modelSel.value = "";
return true;
}
function rebuildFromCategories(modelSel, catKey, preserveCode) {
var cats = categoriesData();
var cat = null;
for (var i = 0; i < cats.length; i++) {
if (cats[i].key === catKey) {
cat = cats[i];
break;
}
}
modelSel.innerHTML = "";
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "类型";
modelSel.appendChild(placeholder);
if (!cat || !cat.options || !cat.options.length) {
modelSel.disabled = true;
modelSel.value = "";
return;
}
modelSel.disabled = false;
var pick = preserveCode || "";
for (var k = 0; k < cat.options.length; k++) {
var o = cat.options[k];
var opt = document.createElement("option");
opt.value = o.code;
opt.textContent = o.label;
if (o.trade_style) opt.setAttribute("data-trade-style", o.trade_style);
+641
View File
@@ -0,0 +1,641 @@
/**
* 三所 /records:交易记录分页 + 复盘表单显隐 + 复盘/AI 列表分页(soft,每页5).
*/
(function (global) {
"use strict";
var PAGE_SIZE = 5;
var tradesPage = 0;
var tradesPages = 1;
var journalsAll = [];
var journalsPage = 0;
var journalsPages = 1;
var reviewsAll = [];
var reviewsPage = 0;
var reviewsPages = 1;
var tradesCache = {};
var booted = false;
function $(id) {
return document.getElementById(id);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listQs() {
if (typeof global.listWindowQueryString === "function") {
return global.listWindowQueryString() || "";
}
return "";
}
function fmtNum(v, digits) {
if (v == null || v === "") return "—";
var n = Number(v);
if (!Number.isFinite(n)) return esc(v);
return n.toFixed(digits == null ? 2 : digits);
}
/** 优先用后端交易所精度字符串;否则回退量级格式(与 formatPriceForInput 一致). */
function fmtPx(display, raw) {
if (display != null && display !== "") return esc(display);
if (raw == null || raw === "") return "—";
var n = Number(raw);
if (!Number.isFinite(n)) return esc(raw);
var av = Math.abs(n);
var d;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
else if (av >= 0.0001) d = 8;
else d = 10;
var text = n.toFixed(d);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
function fmtTime(s) {
if (!s) return "—";
return esc(String(s).slice(0, 16));
}
function resultBadge(result) {
var er = String(result || "").trim();
if (["止盈", "保本止盈", "移动止盈"].indexOf(er) >= 0) {
return '<span class="badge profit">' + esc(er) + "</span>";
}
if (["止损", "强制清仓", "手动平仓"].indexOf(er) >= 0) {
return '<span class="badge loss">' + esc(er) + "</span>";
}
if (er === "时间平仓") return '<span class="badge miss">' + esc(er) + "</span>";
return '<span class="badge">' + esc(er || "-") + "</span>";
}
function pnlClass(v) {
var n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-profit" : "pnl-loss";
}
function beginSoft(wrapId, soft) {
var wrap = $(wrapId);
if (!wrap) return null;
if (soft) {
if (!wrap.style.minHeight) {
wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px";
}
wrap.classList.add("rr-list-loading");
} else {
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
return wrap;
}
function endSoft(wrap) {
if (!wrap) return;
wrap.classList.remove("rr-list-loading");
wrap.style.minHeight = "";
}
function updatePager(kind) {
var map = {
trades: {
page: tradesPage,
pages: tradesPages,
label: "rr-trades-page-label",
prev: "rr-trades-prev",
next: "rr-trades-next",
},
journals: {
page: journalsPage,
pages: journalsPages,
label: "rr-journals-page-label",
prev: "rr-journals-prev",
next: "rr-journals-next",
},
reviews: {
page: reviewsPage,
pages: reviewsPages,
label: "rr-reviews-page-label",
prev: "rr-reviews-prev",
next: "rr-reviews-next",
},
};
var m = map[kind];
if (!m) return;
var label = $(m.label);
var prev = $(m.prev);
var next = $(m.next);
if (label) label.textContent = "第 " + (m.page + 1) + " / " + m.pages + " 页";
if (prev) prev.disabled = m.page <= 0;
if (next) next.disabled = m.page + 1 >= m.pages;
}
function fillPayload(t) {
return {
symbol: t.symbol,
monitor_type: t.monitor_type,
key_signal_type: t.key_signal_type || "",
direction: t.direction,
trigger_price: t.trigger_price,
stop_loss: t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
risk_amount: t.risk_amount,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function editPayload(t) {
return {
id: t.id,
opened_at: t.effective_opened_at,
closed_at: t.effective_closed_at,
stop_loss: t.effective_stop_loss || t.initial_stop_loss || t.stop_loss,
take_profit: t.effective_take_profit || t.take_profit,
pnl_amount: t.effective_pnl_amount,
result: t.effective_result,
miss_reason: t.effective_miss_reason,
effective_entry_reason: t.effective_entry_reason || "",
};
}
function renderTradesRows(rows) {
var tbody = $("rr-trades-tbody");
if (!tbody) return;
tradesCache = {};
if (!rows || !rows.length) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">暂无交易记录</td></tr>';
return;
}
tbody.innerHTML = rows
.map(function (t) {
tradesCache[t.id] = t;
var mon = esc(t.monitor_type || "");
if (t.key_signal_type) mon += " · " + esc(t.key_signal_type);
var stopShow = t.display_open_stop_loss || t.initial_stop_loss || t.stop_loss;
var tpShow = t.effective_take_profit || t.take_profit;
var pnl = t.effective_pnl_amount;
var pnlSrc = "";
if (t.display_pnl_source === "exchange") {
pnlSrc = '<span style="font-size:.68rem;color:#6ab88a">所</span>';
} else if (t.display_pnl_source !== "reviewed") {
pnlSrc = '<span style="font-size:.68rem;color:#8892b0">估</span>';
}
var dirCls = t.direction === "long" ? "direction-long" : "direction-short";
var dirTxt = t.direction === "long" ? "做多" : "做空";
var margin =
t.margin_capital != null && t.margin_capital !== ""
? fmtNum(t.margin_capital, 2)
: "-";
return (
'<tr id="trade-row-' +
esc(t.id) +
'">' +
"<td>" +
esc(t.symbol) +
"</td>" +
"<td>" +
mon +
"</td>" +
"<td>" +
esc(t.effective_entry_reason || "-") +
"</td>" +
'<td><span class="badge ' +
dirCls +
'">' +
dirTxt +
"</span></td>" +
"<td>" +
fmtPx(t.trigger_price_display, t.trigger_price) +
"</td>" +
"<td>" +
fmtPx(t.stop_loss_display, stopShow) +
"</td>" +
"<td>" +
fmtPx(t.take_profit_display, tpShow) +
"</td>" +
"<td>" +
margin +
"</td>" +
"<td>" +
esc(t.leverage != null ? t.leverage : "-") +
"</td>" +
"<td>" +
esc(t.effective_hold_minutes || 0) +
"</td>" +
"<td>" +
fmtTime(t.effective_opened_at) +
"</td>" +
"<td>" +
fmtTime(t.effective_closed_at || t.created_at) +
"</td>" +
'<td><span class="' +
pnlClass(pnl) +
'">' +
fmtNum(pnl, 2) +
"</span>" +
pnlSrc +
"</td>" +
"<td>" +
resultBadge(t.effective_result) +
"</td>" +
"<td>" +
'<button type="button" class="table-del rr-fill-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'">填入复盘</button> ' +
'<button type="button" class="table-del review-edit-btn" style="background:#1f3a5a;color:#8fc8ff;margin-right:6px" data-id="' +
esc(t.id) +
'" disabled>核对修改</button> ' +
'<button type="button" class="table-del" onclick="deleteTradeRecord(' +
Number(t.id) +
')">删除</button>' +
"</td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".rr-fill-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
showJournalCard();
if (typeof global.fillJournalFromTrade === "function") {
global.fillJournalFromTrade(fillPayload(t));
}
});
});
tbody.querySelectorAll(".review-edit-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("data-id");
var t = tradesCache[id];
if (!t) return;
if (typeof global.editTradeRecordReview === "function") {
global.editTradeRecordReview(editPayload(t));
}
});
});
if (typeof global.toggleReviewMode === "function") {
global.toggleReviewMode();
}
}
function loadTradeRecords(opts) {
opts = opts || {};
var soft = !!opts.soft;
var tbody = $("rr-trades-tbody");
if (!tbody) return;
var wrap = beginSoft("rr-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载中…</td></tr>';
}
var qs = listQs();
var p = new URLSearchParams(qs || "");
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(tradesPage * PAGE_SIZE));
fetch("/api/trade_records?" + p.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
return;
}
tradesPages = Math.max(1, Number(data.pages) || 1);
if (tradesPage >= tradesPages) {
tradesPage = Math.max(0, tradesPages - 1);
updatePager("trades");
if (Number(data.total || 0) > 0) {
loadTradeRecords(opts);
return;
}
}
updatePager("trades");
renderTradesRows(data.items || []);
endSoft(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="15" class="muted">加载失败</td></tr>';
endSoft(wrap);
});
}
function renderJournalsPage(soft) {
var box = $("journal-list");
if (!box) return;
var wrap = beginSoft("journal-list-wrap", soft);
var total = journalsAll.length;
journalsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (journalsPage >= journalsPages) journalsPage = Math.max(0, journalsPages - 1);
updatePager("journals");
var hint = $("rr-journals-hint");
if (hint) {
hint.textContent =
total > 0
? "已保存的复盘(共" + total + "条,每页5条)."
: "已保存的复盘(每页5条).";
}
var slice = journalsAll.slice(
journalsPage * PAGE_SIZE,
journalsPage * PAGE_SIZE + PAGE_SIZE
);
if (global.InstanceUI && typeof InstanceUI.renderJournalListHtml === "function") {
var html = InstanceUI.renderJournalListHtml(slice);
box.innerHTML = html || "<div class='journal-empty-msg'>暂无数据</div>";
} else {
box.innerHTML = "<div class='journal-empty-msg'>暂无数据</div>";
}
endSoft(wrap);
}
function renderReviewsPage(soft) {
var box = $("review-list");
if (!box) return;
var wrap = beginSoft("review-list-wrap", soft);
var total = reviewsAll.length;
reviewsPages = Math.max(1, Math.ceil(total / PAGE_SIZE) || 1);
if (reviewsPage >= reviewsPages) reviewsPage = Math.max(0, reviewsPages - 1);
updatePager("reviews");
var slice = reviewsAll.slice(
reviewsPage * PAGE_SIZE,
reviewsPage * PAGE_SIZE + PAGE_SIZE
);
if (!slice.length) {
box.innerHTML = "<div class='entry'>暂无数据</div>";
endSoft(wrap);
return;
}
var html = "";
slice.forEach(function (r) {
if (global.reviewCache) global.reviewCache[r.id] = r;
var preview = (r.content || "").replace(/\s+/g, " ").trim();
var shortText = preview.length > 90 ? preview.slice(0, 90) + "..." : preview;
html +=
'<div class="entry">' +
"<div><strong>" +
(r.review_type === "daily" ? "日复盘" : "周复盘") +
"</strong> | " +
esc(r.target_date) +
"</div>" +
'<div style="font-size:12px;color:#9aa">' +
esc(r.created_at || "") +
"</div>" +
'<div style="margin-top:4px;color:#c9d2ff">' +
esc(shortText || "(空)") +
"</div>" +
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px">' +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', false)\">查看</button>" +
'<button type="button" class="btn-del" style="border:none;cursor:pointer;background:#1f3a5a;color:#8fc8ff" onclick="openReviewDetail(\'' +
esc(r.id) +
"', true)\">全屏</button>" +
'<a class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff" href="/export/review_md/' +
esc(r.id) +
'">导出MD</a>' +
'<button type="button" class="btn-del" onclick="deleteReview(\'' +
esc(r.id) +
"')\">删除</button>" +
"</div></div>";
});
box.innerHTML = html;
endSoft(wrap);
}
function loadJournalsPaged() {
var qs = listQs();
fetch("/api/journals" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
journalsAll = Array.isArray(data) ? data : [];
if (global.journalCache) {
Object.keys(global.journalCache).forEach(function (k) {
delete global.journalCache[k];
});
journalsAll.forEach(function (o) {
global.journalCache[o.id] = o;
});
}
journalsPage = 0;
renderJournalsPage(false);
});
}
function loadReviewsPaged() {
var qs = listQs();
fetch("/api/reviews" + (qs ? "?" + qs : ""), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
reviewsAll = Array.isArray(data) ? data : [];
if (global.reviewCache) {
Object.keys(global.reviewCache).forEach(function (k) {
delete global.reviewCache[k];
});
} else {
global.reviewCache = {};
}
reviewsAll.forEach(function (r) {
global.reviewCache[r.id] = r;
});
reviewsPage = 0;
renderReviewsPage(false);
});
}
function showJournalCard() {
var card = $("journal-card");
if (card) card.classList.remove("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
}
function hideJournalCard() {
var card = $("journal-card");
if (card) card.classList.add("hidden");
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "none";
}
function patchFillJournalFromTrade() {
var prev = global.fillJournalFromTrade;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.fillJournalFromTrade = function (t) {
showJournalCard();
prev(t);
var hint = $("rr-journal-fill-hint");
if (hint) hint.style.display = "";
};
global.fillJournalFromTrade.__rrPatched = true;
}
function patchDeleteTradeRecord() {
var prev = global.deleteTradeRecord;
if (typeof prev !== "function") return;
if (prev.__rrPatched) return;
global.deleteTradeRecord = function (id) {
if (!confirm("确定删除这条交易记录?")) return;
fetch("/delete_trade_record/" + id, { method: "POST", credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data && data.ok) {
loadTradeRecords({ soft: true });
return;
}
if (typeof prev === "function") {
/* fallthrough reload */
}
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
})
.catch(function () {
global.location.href =
(global.location.pathname || "/records") + "?_ts=" + Date.now();
});
};
global.deleteTradeRecord.__rrPatched = true;
}
function bindPagers() {
var tp = $("rr-trades-prev");
var tn = $("rr-trades-next");
var jp = $("rr-journals-prev");
var jn = $("rr-journals-next");
var rp = $("rr-reviews-prev");
var rn = $("rr-reviews-next");
var hideBtn = $("rr-journal-hide-btn");
if (tp) {
tp.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage <= 0) return;
tradesPage -= 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (tn) {
tn.addEventListener("click", function (ev) {
ev.preventDefault();
if (tradesPage + 1 >= tradesPages) return;
tradesPage += 1;
updatePager("trades");
loadTradeRecords({ soft: true });
});
}
if (jp) {
jp.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage <= 0) return;
journalsPage -= 1;
renderJournalsPage(true);
});
}
if (jn) {
jn.addEventListener("click", function (ev) {
ev.preventDefault();
if (journalsPage + 1 >= journalsPages) return;
journalsPage += 1;
renderJournalsPage(true);
});
}
if (rp) {
rp.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage <= 0) return;
reviewsPage -= 1;
renderReviewsPage(true);
});
}
if (rn) {
rn.addEventListener("click", function (ev) {
ev.preventDefault();
if (reviewsPage + 1 >= reviewsPages) return;
reviewsPage += 1;
renderReviewsPage(true);
});
}
if (hideBtn) {
hideBtn.addEventListener("click", function (ev) {
ev.preventDefault();
hideJournalCard();
});
}
}
function init(opts) {
opts = opts || {};
if (!$("records-panel-root")) return;
var tbody = $("rr-trades-tbody");
var stuckLoading =
!!tbody &&
tbody.querySelectorAll("tr").length <= 1 &&
/加载中/.test(String(tbody.textContent || ""));
if (booted) {
if (opts.refresh || stuckLoading) {
loadTradeRecords({ soft: !stuckLoading });
loadJournalsPaged();
loadReviewsPaged();
}
patchFillJournalFromTrade();
patchDeleteTradeRecord();
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
return;
}
booted = true;
if (!global.journalCache) global.journalCache = {};
if (!global.reviewCache) global.reviewCache = {};
global.loadJournals = loadJournalsPaged;
global.loadReviews = loadReviewsPaged;
global.loadTradeRecords = loadTradeRecords;
patchFillJournalFromTrade();
patchDeleteTradeRecord();
bindPagers();
updatePager("trades");
updatePager("journals");
updatePager("reviews");
if (global.JournalFormSave && typeof global.JournalFormSave.init === "function") {
global.JournalFormSave.init();
}
loadTradeRecords({ soft: false });
loadJournalsPaged();
loadReviewsPaged();
}
global.RecordsReviewPage = {
init: init,
loadTradeRecords: loadTradeRecords,
loadJournals: loadJournalsPaged,
loadReviews: loadReviewsPaged,
showJournalCard: showJournalCard,
hideJournalCard: hideJournalCard,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
+169
View File
@@ -0,0 +1,169 @@
/**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
*/
(function (global) {
"use strict";
const DEFAULT_DEBOUNCE_MS = 350;
const DEFAULT_POLL_MS = 5000;
const bound = new WeakSet();
function $(id) {
return id ? document.getElementById(id) : null;
}
function symbolValue(el) {
if (!el) return "";
return (el.value || "").trim();
}
function directionValue(dirId) {
const el = dirId ? $(dirId) : null;
const v = (el && el.value ? el.value : "long").trim().toLowerCase();
return v === "short" ? "short" : "long";
}
function formatPrice(px, sym) {
const n = Number(px);
if (!Number.isFinite(n)) return "—";
const u = (sym || "").trim().toUpperCase();
let digits = 4;
if (u.startsWith("BTC") || u.startsWith("ETH") || n >= 1000) digits = 2;
else if (n >= 10) digits = 3;
else if (n >= 1) digits = 4;
else if (n >= 0.01) digits = 5;
else digits = 6;
return n.toFixed(digits);
}
function pollMs() {
const raw =
(document.body && document.body.getAttribute("data-price-refresh-ms")) || "";
const n = Number(raw);
return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS;
}
function paint(el, sym, px, err) {
if (!el) return;
if (err) {
el.textContent = "现价:—";
el.classList.add("symbol-live-price--err");
el.classList.remove("symbol-live-price--ok");
el.title = err;
return;
}
if (px === null || typeof px === "undefined") {
el.textContent = "现价:—";
el.classList.remove("symbol-live-price--ok", "symbol-live-price--err");
el.title = sym ? "无法读取交易所价格" : "";
return;
}
const label = sym ? sym.toUpperCase().replace(/\/USDT.*/, "") : "";
el.textContent = label ? label + " 现价 " + formatPrice(px, sym) : "现价 " + formatPrice(px, sym);
el.classList.add("symbol-live-price--ok");
el.classList.remove("symbol-live-price--err");
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
}
function bindOne(el) {
if (!el || bound.has(el)) return;
bound.add(el);
const symId = el.getAttribute("data-symbol-input");
const dirId = el.getAttribute("data-direction-input") || "";
let debounceTimer = null;
let pollTimer = null;
let fetchSeq = 0;
function clearPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPoll() {
clearPoll();
pollTimer = setInterval(refresh, pollMs());
}
function refresh() {
const symEl = $(symId);
const sym = symbolValue(symEl);
if (!sym) {
paint(el, "", null, "");
clearPoll();
return;
}
const dir = directionValue(dirId);
const seq = ++fetchSeq;
el.classList.add("symbol-live-price--loading");
fetch(
"/api/order_defaults?symbol=" +
encodeURIComponent(sym) +
"&direction=" +
encodeURIComponent(dir)
)
.then(function (r) {
return r.json().then(function (d) {
return { status: r.status, data: d };
}).catch(function () {
return { status: r.status, data: null };
});
})
.then(function (res) {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败");
return;
}
const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格");
return;
}
paint(el, data.symbol || sym, px, "");
if (!pollTimer) startPoll();
})
.catch(function () {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
paint(el, sym, null, "网络错误");
});
}
function schedule() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, DEFAULT_DEBOUNCE_MS);
}
const symEl = $(symId);
if (symEl) {
symEl.addEventListener("input", schedule);
symEl.addEventListener("change", schedule);
}
const dirEl = dirId ? $(dirId) : null;
if (dirEl) {
dirEl.addEventListener("change", schedule);
}
schedule();
}
function init(root) {
const scope = root || document;
scope.querySelectorAll(".symbol-live-price").forEach(bindOne);
}
global.SymbolLivePrice = { init: init, bind: bindOne };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
init(document);
});
} else {
init(document);
}
})(typeof window !== "undefined" ? window : globalThis);
+194
View File
@@ -0,0 +1,194 @@
/**
* 时间平仓 + 整点强制清仓:表单开关 + 持仓/顶栏倒计时.
*/
(function (global) {
"use strict";
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
function formatCountdown(sec) {
const s = Math.max(0, parseInt(sec, 10) || 0);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return pad2(h) + ":" + pad2(m) + ":" + pad2(r);
}
function isForceCloseActive(wrap) {
if (!wrap) return false;
const raw =
wrap.dataset.forceCloseActive ||
wrap.getAttribute("data-force-close-active") ||
"";
return raw === "1" || raw === "true";
}
function bindTimeCloseForm(checkboxId, selectId, wrapId) {
const cb = document.getElementById(checkboxId);
const sel = document.getElementById(selectId);
const wrap = wrapId ? document.getElementById(wrapId) : null;
if (!cb || !sel) return;
function sync() {
const on = !!cb.checked;
sel.disabled = false;
sel.tabIndex = 0;
if (wrap) wrap.classList.toggle("is-disabled", !on);
}
sel.addEventListener("mousedown", function (ev) {
ev.stopPropagation();
});
sel.addEventListener("click", function (ev) {
ev.stopPropagation();
});
cb.addEventListener("change", sync);
sync();
}
function paintCountdownEl(cd, rem, active) {
if (!cd) return;
if (active) {
cd.textContent = "执行中";
return;
}
cd.textContent = Number.isFinite(rem) ? formatCountdown(rem) : "--:--:--";
}
function paintOrderTimeClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-time-close-wrap-" + order.id);
const cd = document.getElementById("order-time-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!(order.time_close_enabled || order.time_close_at_ms);
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const hours = order.time_close_hours;
const label = order.time_close_label || (hours ? "时间平仓 " + hours + "h" : "时间平仓");
const labelEl = wrap.querySelector(".pos-time-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.time_close_remaining_sec != null
? Number(order.time_close_remaining_sec)
: null;
if ((rem == null || !Number.isFinite(rem)) && order.time_close_at_ms) {
rem = Math.max(0, Math.floor((Number(order.time_close_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, false);
wrap.dataset.closeAtMs = order.time_close_at_ms ? String(order.time_close_at_ms) : "";
}
function paintOrderForceClose(order) {
if (!order || order.id == null) return;
const wrap = document.getElementById("order-force-close-wrap-" + order.id);
const cd = document.getElementById("order-force-close-cd-" + order.id);
if (!wrap || !cd) return;
const enabled = !!order.force_close_enabled;
if (!enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = order.force_close_label || "强制清仓";
const labelEl = wrap.querySelector(".pos-force-close-label");
if (labelEl) labelEl.textContent = label;
let rem =
order.force_close_remaining_sec != null
? Number(order.force_close_remaining_sec)
: null;
const atMs = order.force_close_at_ms;
if ((rem == null || !Number.isFinite(rem)) && atMs) {
rem = Math.max(0, Math.floor((Number(atMs) - Date.now()) / 1000));
}
const active = !!order.force_close_active;
paintCountdownEl(cd, rem, active);
wrap.dataset.forceCloseAtMs = atMs ? String(atMs) : "";
wrap.dataset.forceCloseActive = active ? "1" : "0";
}
function paintForceCloseHeader(state) {
const wrap = document.getElementById("force-close-header-badge");
if (!wrap) return;
if (!state || !state.enabled) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
const label = state.label || "强制清仓";
const labelPrefix = label + " 已开启 · ";
let prefixNode = wrap.querySelector(".force-close-header-prefix");
if (!prefixNode) {
wrap.textContent = "";
prefixNode = document.createElement("span");
prefixNode.className = "force-close-header-prefix";
prefixNode.textContent = labelPrefix;
wrap.appendChild(prefixNode);
const cd = document.createElement("span");
cd.className = "force-close-header-cd";
wrap.appendChild(cd);
} else {
prefixNode.textContent = labelPrefix;
}
const cd = wrap.querySelector(".force-close-header-cd");
let rem = state.remaining_sec != null ? Number(state.remaining_sec) : null;
if ((rem == null || !Number.isFinite(rem)) && state.next_at_ms) {
rem = Math.max(0, Math.floor((Number(state.next_at_ms) - Date.now()) / 1000));
}
paintCountdownEl(cd, rem, !!state.active);
wrap.dataset.forceCloseAtMs = state.next_at_ms ? String(state.next_at_ms) : "";
wrap.dataset.forceCloseActive = state.active ? "1" : "0";
}
function tickLocalCountdowns() {
document.querySelectorAll("[data-close-at-ms]").forEach(function (wrap) {
const closeAtRaw = wrap.dataset.closeAtMs || wrap.getAttribute("data-close-at-ms") || "";
const cd = wrap.querySelector(".pos-time-close-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
cd.textContent = formatCountdown(rem);
});
document.querySelectorAll("[data-force-close-at-ms]").forEach(function (wrap) {
const closeAtRaw =
wrap.dataset.forceCloseAtMs || wrap.getAttribute("data-force-close-at-ms") || "";
const cd = wrap.querySelector(".pos-force-close-cd, .force-close-header-cd");
if (!cd) return;
const closeAt = Number(closeAtRaw);
if (!closeAt) return;
const rem = Math.max(0, Math.floor((closeAt - Date.now()) / 1000));
paintCountdownEl(cd, rem, isForceCloseActive(wrap));
});
}
function paintOrders(orders) {
(orders || []).forEach(function (order) {
paintOrderTimeClose(order);
paintOrderForceClose(order);
});
}
function syncKeyTimeCloseVisibility(show) {
const wrap = document.getElementById("key-time-close-wrap");
if (!wrap) return;
wrap.style.display = show ? "inline-flex" : "none";
}
global.TimeCloseUI = {
bindTimeCloseForm: bindTimeCloseForm,
paintOrderTimeClose: paintOrderTimeClose,
paintOrderForceClose: paintOrderForceClose,
paintForceCloseHeader: paintForceCloseHeader,
paintOrders: paintOrders,
tickLocalCountdowns: tickLocalCountdowns,
syncKeyTimeCloseVisibility: syncKeyTimeCloseVisibility,
formatCountdown: formatCountdown,
};
if (!global.__timeCloseCountdownTimer) {
global.__timeCloseCountdownTimer = setInterval(tickLocalCountdowns, 1000);
}
})(typeof window !== "undefined" ? window : globalThis);
+171
View File
@@ -0,0 +1,171 @@
/* 交易日历:内照明心 + 三所统计分析共用,随 data-theme 浅/深切换 */
.trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, rgba(0, 0, 0, 0.22));
--trade-cal-cell-bg: var(--section-surface, var(--inset-surface, rgba(0, 0, 0, 0.32)));
--trade-cal-cell-border: rgba(255, 255, 255, 0.14);
--trade-cal-cell-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
--trade-cal-cell-empty-bg: color-mix(in srgb, var(--trade-cal-cell-bg) 72%, transparent);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #6366f1) 12%, var(--trade-cal-cell-bg));
--trade-cal-cell-hover-border: color-mix(in srgb, var(--accent, #6366f1) 45%, transparent);
--trade-cal-selected-border: rgba(59, 130, 246, 0.85);
--trade-cal-selected-bg: color-mix(in srgb, #3b82f6 16%, var(--trade-cal-cell-bg));
--trade-cal-selected-shadow: rgba(59, 130, 246, 0.45);
--trade-cal-sick-bg: color-mix(in srgb, var(--red, #ef4444) 14%, var(--trade-cal-cell-bg));
--trade-cal-sick-border: color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
--trade-cal-sick-shadow: color-mix(in srgb, var(--red, #ef4444) 45%, transparent);
--trade-cal-sick-tag-bg: color-mix(in srgb, var(--red, #ef4444) 25%, transparent);
--trade-cal-sick-tag-fg: color-mix(in srgb, var(--red, #ef4444) 70%, #fff);
--trade-cal-pos: var(--green, #22c55e);
--trade-cal-neg: var(--red, #ef4444);
margin-top: 4px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border-soft, rgba(120, 140, 200, 0.28));
background: var(--trade-cal-wrap-bg);
}
.stats-calendar-wrap {
margin-bottom: 14px;
}
.trade-cal-wrap button.trade-cal-cell {
background: var(--trade-cal-cell-bg) !important;
background-image: none !important;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
padding: 6px 4px;
min-height: 72px;
width: 100%;
line-height: 1.15;
font-size: inherit;
text-align: center;
}
.trade-cal-wrap button.trade-cal-cell:not(.has-trade) {
background: var(--trade-cal-cell-empty-bg) !important;
cursor: default;
}
.trade-cal-wrap button.trade-cal-cell:disabled {
opacity: 1;
cursor: default;
}
.trade-cal-wrap .trade-cal-head .btn,
.trade-cal-wrap .trade-cal-head button {
min-height: 0;
min-width: 34px;
padding: 4px 12px;
line-height: 1.2;
}
.trade-cal-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.trade-cal-title {
font-size: 0.95rem;
font-weight: 600;
min-width: 120px;
text-align: center;
color: var(--text, #e8ecff);
}
.trade-cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.trade-cal-wd {
text-align: center;
font-size: 0.72rem;
color: var(--muted, #8892b0);
}
.trade-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.trade-cal-cell {
min-height: 72px;
padding: 6px 4px;
border-radius: 8px;
border: 1px solid var(--trade-cal-cell-border);
box-shadow: var(--trade-cal-cell-shadow);
background: var(--trade-cal-cell-bg);
color: inherit;
font: inherit;
cursor: default;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
}
.trade-cal-cell.has-trade {
cursor: pointer;
}
.trade-cal-wrap button.trade-cal-cell.has-trade:hover {
background: var(--trade-cal-cell-hover-bg) !important;
background-image: none !important;
border-color: var(--trade-cal-cell-hover-border);
}
.trade-cal-cell.is-selected {
border-color: var(--trade-cal-selected-border);
background: var(--trade-cal-selected-bg);
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-cell.is-sick-day {
border-color: var(--trade-cal-sick-border);
background: var(--trade-cal-sick-bg);
}
.trade-cal-cell.is-sick-day.is-selected {
border-color: var(--trade-cal-selected-border);
background: color-mix(in srgb, #3b82f6 14%, var(--trade-cal-sick-bg));
box-shadow: 0 0 0 2px var(--trade-cal-selected-shadow);
}
.trade-cal-day-num {
font-size: 0.78rem;
font-weight: 600;
color: var(--text, #e8ecff);
}
.trade-cal-pnl {
font-size: 0.72rem;
font-weight: 600;
line-height: 1.1;
color: var(--text, #e8ecff);
}
.trade-cal-cell.pnl-pos .trade-cal-pnl {
color: var(--trade-cal-pos);
}
.trade-cal-cell.pnl-neg .trade-cal-pnl {
color: var(--trade-cal-neg);
}
.trade-cal-cnt {
font-size: 0.65rem;
color: var(--muted, #8892b0);
font-weight: 500;
}
.trade-cal-sick-tag {
font-size: 0.62rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--trade-cal-sick-tag-bg);
color: var(--trade-cal-sick-tag-fg);
font-weight: 600;
}
.trade-cal-pad {
background: transparent;
border: none;
min-height: 0;
}
html[data-theme="light"] .trade-cal-wrap {
--trade-cal-wrap-bg: var(--inset-surface, #eef3f8);
--trade-cal-cell-bg: #ffffff;
--trade-cal-cell-empty-bg: #f6f9fc;
--trade-cal-cell-border: rgba(0, 75, 115, 0.18);
--trade-cal-cell-shadow: 0 1px 4px rgba(30, 60, 100, 0.08);
--trade-cal-cell-hover-bg: color-mix(in srgb, var(--accent, #2563eb) 10%, #ffffff);
--trade-cal-selected-border: rgba(37, 99, 235, 0.75);
--trade-cal-selected-bg: color-mix(in srgb, #2563eb 12%, #ffffff);
--trade-cal-selected-shadow: rgba(37, 99, 235, 0.35);
--trade-cal-sick-tag-fg: #b91c1c;
}
+314
View File
@@ -0,0 +1,314 @@
/**
* 交易日历组件:内照明心档案 + 三所统计分析共用.
*/
(function (global) {
"use strict";
var WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function monthLabel(y, m) {
return y + "年" + m + "月";
}
function formatCalPnl(pnl) {
var n = Number(pnl);
if (!Number.isFinite(n)) n = 0;
return (n >= 0 ? "+" : "") + n.toFixed(1) + "U";
}
function dayHasTrade(info) {
if (!info) return false;
var cnt = Number(info.open_count);
if (Number.isFinite(cnt) && cnt > 0) return true;
var pnl = Number(info.pnl_total);
return Number.isFinite(pnl) && Math.abs(pnl) > 0.0001;
}
function dayOpenCount(info) {
var cnt = Number(info && info.open_count);
return Number.isFinite(cnt) && cnt > 0 ? cnt : 0;
}
function dayPnl(info) {
return Number(info && info.pnl_total) || 0;
}
function TradeStatsCalendar(config) {
this.gridEl = config.gridEl;
this.titleEl = config.titleEl;
this.prevBtn = config.prevBtn || null;
this.nextBtn = config.nextBtn || null;
this.apiUrl = config.apiUrl || "/api/stats/calendar";
this.buildQuery =
config.buildQuery ||
function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
return q;
};
this.parseResponse =
config.parseResponse ||
function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
};
this.fetchFn = config.fetchFn || null;
this.showSick = config.showSick !== false;
this.selectedDay = config.selectedDay || "";
this.onDayClick = config.onDayClick || null;
this.onMonthChange = config.onMonthChange || null;
this.year = config.year || 0;
this.month = config.month || 0;
this.days = {};
this.monthPnlTotal = 0;
this.monthOpenCount = 0;
this._navBound = false;
this._bindNav();
}
TradeStatsCalendar.prototype.ensureMonth = function (ref) {
if (this.year > 0 && this.month > 0) return;
var d;
if (ref instanceof Date) d = ref;
else if (typeof ref === "string" && ref.length >= 7) {
var p = ref.slice(0, 10).split("-");
this.year = parseInt(p[0], 10) || new Date().getFullYear();
this.month = parseInt(p[1], 10) || new Date().getMonth() + 1;
return;
} else d = new Date();
this.year = d.getFullYear();
this.month = d.getMonth() + 1;
};
TradeStatsCalendar.prototype.applyPayload = function (data) {
if (!data) return;
var y = Number(data.year);
var m = Number(data.month);
if (Number.isFinite(y) && y > 0) this.year = y;
if (Number.isFinite(m) && m > 0) this.month = m;
this.days = this.parseResponse(data) || {};
this.monthPnlTotal = Number(data.month_pnl_total) || 0;
this.monthOpenCount = Number(data.month_open_count) || 0;
if (!this.monthOpenCount) {
var self = this;
Object.keys(this.days).forEach(function (k) {
if (dayHasTrade(self.days[k])) {
self.monthOpenCount += dayOpenCount(self.days[k]);
self.monthPnlTotal += dayPnl(self.days[k]);
}
});
this.monthPnlTotal = Math.round(this.monthPnlTotal * 10000) / 10000;
}
};
function readStatsCalendarBootstrap() {
var el = document.getElementById("stats-calendar-bootstrap");
if (!el || !el.textContent) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
console.warn("[trade calendar] bootstrap parse", e);
return null;
}
}
TradeStatsCalendar.prototype.setSelectedDay = function (day) {
this.selectedDay = day || "";
this.render();
};
TradeStatsCalendar.prototype.render = function () {
if (!this.gridEl || !this.titleEl) return;
if (this.year <= 0 || this.month <= 0) this.ensureMonth(new Date());
var title = monthLabel(this.year, this.month);
if (this.monthOpenCount > 0) {
title +=
" · " + formatCalPnl(this.monthPnlTotal) + " · " + this.monthOpenCount + "笔";
}
this.titleEl.textContent = title;
var first = new Date(this.year, this.month - 1, 1);
var lastDay = new Date(this.year, this.month, 0).getDate();
var startWd = first.getDay();
var html =
'<div class="trade-cal-weekdays">' +
WEEKDAYS.map(function (w) {
return '<span class="trade-cal-wd">' + w + "</span>";
}).join("") +
'</div><div class="trade-cal-grid">';
var i;
for (i = 0; i < startWd; i++) {
html += '<span class="trade-cal-cell trade-cal-pad"></span>';
}
for (var d = 1; d <= lastDay; d++) {
var dayStr =
this.year +
"-" +
String(this.month).padStart(2, "0") +
"-" +
String(d).padStart(2, "0");
var info = this.days[dayStr];
var hasTrade = dayHasTrade(info);
var sick = this.showSick && info && info.has_sick;
var pnl = hasTrade ? dayPnl(info) : null;
var cnt = hasTrade ? dayOpenCount(info) : 0;
var cls =
"trade-cal-cell" +
(hasTrade ? " has-trade" : "") +
(sick ? " is-sick-day" : "") +
(this.selectedDay === dayStr ? " is-selected" : "") +
(pnl != null && pnl > 0.0001
? " pnl-pos"
: pnl != null && pnl < -0.0001
? " pnl-neg"
: "");
var body = '<span class="trade-cal-day-num">' + d + "</span>";
if (hasTrade) {
body +=
'<span class="trade-cal-pnl">' +
esc(formatCalPnl(pnl)) +
"</span>" +
'<span class="trade-cal-cnt">' +
cnt +
"笔</span>";
if (sick) body += '<span class="trade-cal-sick-tag">犯病</span>';
}
html +=
'<button type="button" class="' +
cls +
'" data-day="' +
dayStr +
'" data-sick="' +
(sick ? "1" : "0") +
'"' +
(hasTrade ? "" : " disabled") +
">" +
body +
"</button>";
}
html += "</div>";
this.gridEl.innerHTML = html;
var self = this;
this.gridEl.querySelectorAll(".trade-cal-cell[data-day]").forEach(function (btn) {
btn.addEventListener("click", function () {
var day = btn.getAttribute("data-day");
if (!day || !self.onDayClick) return;
self.selectedDay = day;
self.render();
self.onDayClick(day, btn.getAttribute("data-sick") === "1", self.days[day] || null);
});
});
};
TradeStatsCalendar.prototype.load = async function () {
this.ensureMonth(new Date());
this.render();
var q = this.buildQuery(this.year, this.month);
if (!q.has("year")) q.set("year", String(this.year));
if (!q.has("month")) q.set("month", String(this.month));
try {
var data;
if (this.fetchFn) {
data = await this.fetchFn(q);
} else {
var resp = await fetch(this.apiUrl + "?" + q.toString(), {
credentials: "same-origin",
});
if (!resp.ok) {
console.warn("[trade calendar] api", resp.status);
this.render();
return;
}
data = await resp.json();
}
this.applyPayload(data);
this.render();
if (this.onMonthChange) this.onMonthChange(this.year, this.month, this.days);
} catch (e) {
console.warn("[trade calendar]", e);
this.render();
}
};
TradeStatsCalendar.prototype.shiftMonth = function (delta) {
this.ensureMonth(new Date());
this.month += delta;
if (this.month > 12) {
this.month = 1;
this.year += 1;
} else if (this.month < 1) {
this.month = 12;
this.year -= 1;
}
void this.load();
};
TradeStatsCalendar.prototype._bindNav = function () {
if (this._navBound) return;
var self = this;
if (this.prevBtn) {
this.prevBtn.addEventListener("click", function () {
self.shiftMonth(-1);
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener("click", function () {
self.shiftMonth(1);
});
}
this._navBound = true;
};
global.TradeStatsCalendar = TradeStatsCalendar;
global.statsCalendarWidget = null;
global.initInstanceStatsCalendar = function () {
var grid = document.getElementById("stats-calendar");
if (!grid || !global.TradeStatsCalendar) return null;
var bootstrap = readStatsCalendarBootstrap();
if (
global.statsCalendarWidget &&
global.statsCalendarWidget.gridEl === grid
) {
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
}
global.statsCalendarWidget = new TradeStatsCalendar({
gridEl: grid,
titleEl: document.getElementById("stats-cal-title"),
prevBtn: document.getElementById("stats-cal-prev"),
nextBtn: document.getElementById("stats-cal-next"),
apiUrl: "/api/stats/calendar",
showSick: false,
buildQuery: function (year, month) {
var q = new URLSearchParams();
q.set("year", String(year));
q.set("month", String(month));
var sel = document.getElementById("stats-segment-select");
if (sel) q.set("segment", sel.value || "all");
return q;
},
parseResponse: function (data) {
if (data && data.ok === false) return {};
return (data && data.days) || {};
},
});
if (bootstrap) global.statsCalendarWidget.applyPayload(bootstrap);
global.statsCalendarWidget.render();
void global.statsCalendarWidget.load();
return global.statsCalendarWidget;
};
global.initStatsCalendarWidget = global.initInstanceStatsCalendar;
})(window);
+117
View File
@@ -0,0 +1,117 @@
"""企业微信机器人 Webhook 推送(多实例共用)."""
from __future__ import annotations
import re
from typing import Optional
import requests
def strip_markdown_for_text(content: str) -> str:
s = str(content or "")
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
s = re.sub(r"`([^`]+)`", r"\1", s)
s = re.sub(r"^#+\s*", "", s, flags=re.MULTILINE)
s = re.sub(r"^---\s*$", "", s, flags=re.MULTILINE)
return s.strip()
def looks_like_wechat_markdown(content: str) -> bool:
if not content:
return False
if re.search(r"^#+\s", content, re.MULTILINE):
return True
return "**" in content or "`" in content
def send_wechat_webhook(
webhook_url: str,
content: str,
*,
timeout: int = 10,
prefix: str = "【加密货币】",
) -> bool:
url = (webhook_url or "").strip()
if not url or "replace-me" in url:
return False
body = str(content or "").strip()
if prefix:
full = f"{prefix}\n{body}" if body else prefix
else:
full = body
if not full.strip():
return False
payloads = []
if looks_like_wechat_markdown(full):
payloads.append({"msgtype": "markdown", "markdown": {"content": full}})
plain = strip_markdown_for_text(full) if looks_like_wechat_markdown(full) else full
payloads.append({"msgtype": "text", "text": {"content": plain}})
seen = set()
for payload in payloads:
key = payload["msgtype"]
if key in seen:
continue
seen.add(key)
try:
resp = requests.post(url, json=payload, timeout=timeout)
if resp.status_code != 200:
continue
data = resp.json()
if int(data.get("errcode", -1)) == 0:
return True
except Exception:
continue
return False
def wechat_direction_label(direction: str) -> str:
d = (direction or "").strip().lower()
if d == "long":
return "多头(long)"
if d == "short":
return "空头(short)"
return "双向(watch)"
def build_wechat_rs_level_message(
*,
symbol: str,
monitor_type: str,
account_label: str,
trigger_time: str,
upper_txt: str,
lower_txt: str,
close_txt: str,
edge_txt: str,
break_label: str,
direction: str,
notify_index: int,
notify_max: int,
interval_min: int,
extra_note: Optional[str] = None,
) -> str:
"""阻力/支撑突破提醒(与开平仓推送一致的 emoji 纯文本风格)."""
head = "📈" if (direction or "").strip().lower() == "long" else "📉"
dir_txt = wechat_direction_label(direction)
lines = [
f"{head} {symbol} 关键位突破提醒({notify_index}/{notify_max})",
f"💼 账户:{account_label}",
"",
"🧾 突破概要",
f"📌 类型:{monitor_type}",
f"⏱ 触发时间:{trigger_time}",
f"📊 上沿:{upper_txt}|下沿:{lower_txt}",
f"💹 触发收盘:{close_txt}",
f"🎯 {break_label}({dir_txt})",
f"📍 突破价位:{edge_txt}",
"",
"📎 说明",
f"· 人工盯盘,共推送 {notify_max} 次(间隔约 {interval_min} 分钟)",
"· 推送完毕后本条监控自动结案",
"· 不参与自动开仓",
]
if extra_note:
lines.append(f"· {extra_note}")
return "\n".join(lines)
+121
View File
@@ -0,0 +1,121 @@
"""读写实例目录 .env(行级 upsert,原子落盘)."""
from __future__ import annotations
import os
import re
import tempfile
from typing import Optional
_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$")
def parse_env_lines(text: str) -> list[str]:
return text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
def read_env_lines(path: str) -> list[str]:
if not os.path.isfile(path):
return []
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return parse_env_lines(f.read())
def env_get(lines: list[str], key: str) -> Optional[str]:
for line in lines:
m = _KEY_LINE.match(line)
if m and m.group(2) == key:
raw = m.group(3).strip()
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
return raw[1:-1]
return raw
return None
def env_get_all(lines: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for line in lines:
m = _KEY_LINE.match(line)
if m:
key = m.group(2)
raw = m.group(3).strip()
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
out[key] = raw[1:-1]
else:
out[key] = raw
return out
def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]:
pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
out: list[str] = []
replaced = False
safe = value if value is not None else ""
if any(c in safe for c in (' ', '#', '"', "'")):
safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"'
new_line = f"{key}={safe}"
for line in lines:
if pat.match(line):
if not replaced:
out.append(new_line)
replaced = True
continue
out.append(line)
if not replaced:
if out and out[-1].strip():
out.append("")
out.append(new_line)
return out
def write_env_lines_atomic(path: str, lines: list[str]) -> None:
directory = os.path.dirname(os.path.abspath(path)) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines))
if lines:
f.write("\n")
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]:
lines = read_env_lines(path)
changed: list[str] = []
for key, value in updates.items():
if value is None:
continue
old = env_get(lines, key)
if old == value:
continue
lines = upsert_env_line(lines, key, value)
changed.append(key)
if changed:
write_env_lines_atomic(path, lines)
return changed
def load_env_file_into_environ(path: str) -> None:
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
if text.startswith("\ufeff"):
text = text[1:]
for line in parse_env_lines(text):
s = line.strip()
if not s or s.startswith("#"):
continue
if "=" not in s:
continue
k, _, v = s.partition("=")
clean_key = k.strip()
clean_val = v.strip().strip('"').strip("'")
if clean_key:
os.environ[clean_key] = clean_val
+418
View File
@@ -0,0 +1,418 @@
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
from __future__ import annotations
import os
import re
from typing import Any, Optional
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$")
_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$")
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
RESTART_REQUIRED_EXACT = frozenset({
"APP_HOST",
"APP_PORT",
"APP_DEBUG",
"DB_PATH",
"UPLOAD_DIR",
"FLASK_SECRET_KEY",
"POSITION_SIZING_MODE",
"LIVE_TRADING_ENABLED",
"OKX_TD_MODE",
"OKX_POS_MODE",
"OKX_POSITION_INST_TYPE",
"BINANCE_MARGIN_MODE",
"BINANCE_POSITION_MODE",
"GATE_TD_MODE",
"GATE_POS_MODE",
"PM2_APP_NAME",
})
RESTART_REQUIRED_PREFIXES = (
"OKX_API_",
"OKX_OPTIONS_API_",
"BINANCE_API_",
"GATE_API_",
"OKX_SOCKS_",
"OKX_HTTP_",
"OKX_HTTPS_",
"BINANCE_HTTP_",
"BINANCE_HTTPS_",
"GATE_HTTP_",
"GATE_HTTPS_",
)
HOT_RELOAD_EXACT = frozenset({
"RISK_PERCENT",
"MAX_ACTIVE_POSITIONS",
"MANUAL_MIN_PLANNED_RR",
"DAILY_OPEN_ALERT_THRESHOLD",
"DAILY_OPEN_HARD_LIMIT",
"TRADING_DAY_RESET_HOUR",
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"RISK_CONTROL_ENABLED",
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"TRADE_DIRECTION_RESTRICT_ENABLED",
"TRADE_DIRECTION",
"TRADE_SYMBOL_RESTRICT_ENABLED",
"TRADE_SYMBOL_WHITELIST",
"BALANCE_REFRESH_SECONDS",
"PRICE_REFRESH_SECONDS",
"MONITOR_POLL_SECONDS",
"AUTO_TRANSFER_ENABLED",
"AUTO_TRANSFER_AMOUNT",
"AUTO_TRANSFER_FROM",
"AUTO_TRANSFER_TO",
"AUTO_TRANSFER_BJ_HOUR",
"TRANSFER_CCY",
"FORCE_CLOSE_ENABLED",
"FORCE_CLOSE_BJ_HOUR",
"FORCE_CLOSE_GRACE_MINUTES",
"BTC_LEVERAGE",
"ALT_LEVERAGE",
"DAILY_START_CAPITAL",
"DAILY_LOSS_CAPITAL",
"DAILY_PROFIT_CAPITAL",
"FULL_MARGIN_BUFFER_RATIO",
"APP_USERNAME",
"APP_PASSWORD",
"APP_AUTH_DISABLED",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
})
SENSITIVE_EXACT = frozenset({
"APP_PASSWORD",
"FLASK_SECRET_KEY",
"OPENAI_API_KEY",
})
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
# env 配置页下拉:value → 中文标签
SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
"OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
"BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")),
"GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")),
"POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")),
"TRADE_DIRECTION": (
("both", "双向均可"),
("long_only", "仅做多"),
("short_only", "仅做空"),
),
"AUTO_TRANSFER_FROM": (
("funding", "funding 资金账户"),
("swap", "swap 交易账户"),
("spot", "spot 现货"),
),
"AUTO_TRANSFER_TO": (
("swap", "swap 交易账户"),
("funding", "funding 资金账户"),
("spot", "spot 现货"),
),
"TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
"OKX_TRADE_MODE": (
("options", "单独期权"),
("perp_options", "永期对冲"),
("options_options", "期期对冲"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"TRANSFER_CCY": {"usdt": "USDT"},
}
def _is_sensitive(key: str) -> bool:
if key in SENSITIVE_EXACT:
return True
return any(s in key for s in SENSITIVE_SUBSTR)
def select_options_for(key: str) -> list[dict[str, str]]:
opts = SELECT_OPTIONS.get(key) or ()
return [{"value": v, "label": lab} for v, lab in opts]
def normalize_select_value(key: str, value: Optional[str]) -> str:
raw = (value or "").strip()
if not raw:
return ""
low = raw.lower()
aliases = _SELECT_ALIASES.get(key) or {}
if low in aliases:
return aliases[low]
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
allowed_by_lower = {v.lower(): v for v in allowed}
if low in allowed:
return low
if raw in allowed:
return raw
if low in allowed_by_lower:
return allowed_by_lower[low]
return raw
def _restart_required(key: str) -> bool:
if key in HOT_RELOAD_EXACT:
return False
if key in RESTART_REQUIRED_EXACT:
return True
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
def _hot_reload(key: str) -> bool:
if key in HOT_RELOAD_EXACT:
return True
if _restart_required(key):
return False
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
def _field_type(key: str, value: str) -> str:
if key in SELECT_OPTIONS:
return "select"
low = (value or "").strip().lower()
if low in ("true", "false"):
return "bool"
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
"OKX_SHOW_PERP_FUNDS",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
):
return "bool"
try:
if "." in low:
float(low)
return "float"
int(low)
return "int"
except ValueError:
pass
return "text"
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
if value is None or value == "":
return {"value": "", "masked": "", "tail": "", "has_value": False}
if not _is_sensitive(key):
return {"value": value, "masked": value, "tail": "", "has_value": True}
tail = value[-4:] if len(value) >= 4 else value
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
if not os.path.isfile(example_path):
return []
lines = read_env_lines(example_path)
groups: list[dict[str, Any]] = []
group_map: dict[str, dict[str, Any]] = {}
current_group = "基础配置"
pending_note: list[str] = []
in_section_block = False
section_title_set = False
allow_section_blocks = False
def _ensure_group(title: str) -> dict[str, Any]:
title = (title or "").strip() or "其他"
if title not in group_map:
group_map[title] = {"title": title, "fields": []}
groups.append(group_map[title])
return group_map[title]
for raw in lines:
line = raw.rstrip()
stripped = line.strip()
if not stripped:
pending_note = []
continue
if _SEPARATOR_RE.match(stripped):
if not allow_section_blocks:
continue
if not in_section_block:
in_section_block = True
section_title_set = False
else:
in_section_block = False
continue
if in_section_block and stripped.startswith("#"):
note = stripped.lstrip("#").strip()
if note and not section_title_set:
current_group = note
_ensure_group(current_group)
section_title_set = True
elif note:
pending_note.append(note)
continue
gm = _GROUP_RE.match(stripped)
if gm:
title = gm.group(1).strip()
if title and title != "=":
current_group = title
_ensure_group(current_group)
in_section_block = False
section_title_set = False
pending_note = []
continue
dash = _SECTION_DASH_RE.match(stripped)
if dash:
allow_section_blocks = True
current_group = dash.group(1).strip()
_ensure_group(current_group)
in_section_block = False
section_title_set = False
pending_note = []
continue
if stripped.startswith("#"):
note = stripped.lstrip("#").strip()
if note and not note.startswith("="):
pending_note.append(note)
continue
km = _KEY_LINE.match(stripped)
if not km:
continue
key = km.group(1)
allow_section_blocks = True
default_val = env_get(lines, key) or ""
grp = _ensure_group(current_group)
note = " ".join(pending_note).strip()
grp["fields"].append(
{
"key": key,
"label": key,
"note": note,
"default": default_val,
"type": _field_type(key, default_val),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
}
)
pending_note = []
return [g for g in groups if g.get("fields")]
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
groups = parse_env_example_schema(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
for group in groups:
for field in group.get("fields") or []:
key = field["key"]
val = values.get(key)
if val is None:
val = field.get("default") or ""
masked = _mask_value(key, val)
field["current"] = masked["value"] if not field["sensitive"] else ""
field["masked"] = masked["masked"]
field["has_value"] = masked["has_value"]
return {"groups": groups}
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
allowed = {}
for group in groups:
for field in group.get("fields") or []:
allowed[field["key"]] = field
clean: dict[str, str] = {}
errors: list[str] = []
for key, value in (updates or {}).items():
if key not in allowed:
errors.append(f"未知配置项: {key}")
continue
if value is None:
continue
val = str(value).strip()
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
continue
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
if key.endswith("_API_KEY") and 0 < len(val) < 16:
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
continue
ftype = allowed[key].get("type")
if ftype == "bool":
low = val.lower()
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
errors.append(f"{key} 须为 true/false")
continue
val = "true" if low in ("true", "1", "yes", "on") else "false"
elif ftype == "select" or key in SELECT_OPTIONS:
allowed_vals = {
str(o.get("value") if isinstance(o, dict) else o[0]).lower()
for o in (allowed[key].get("options") or select_options_for(key))
}
norm = normalize_select_value(key, val)
if allowed_vals and norm.lower() not in allowed_vals:
labels = " / ".join(
f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})"
for o in (allowed[key].get("options") or select_options_for(key))
)
errors.append(f"{key} 须为: {labels}")
continue
val = norm
clean[key] = val
return clean, errors
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
field_map = {}
for group in groups:
for field in group.get("fields") or []:
field_map[field["key"]] = field
for key in changed_keys:
meta = field_map.get(key) or {}
if meta.get("restart_required"):
return True
if not meta.get("hot_reload"):
return True
return False
+561
View File
@@ -0,0 +1,561 @@
"""env 配置页 UI 白名单:中文标签,按交易所过滤."""
from __future__ import annotations
import os
from typing import Any, Optional
from lib.env.env_file_lib import env_get_all, read_env_lines
from lib.env.env_schema import (
_field_type,
_hot_reload,
_is_sensitive,
_mask_value,
_restart_required,
normalize_select_value,
parse_env_example_schema,
select_options_for,
)
# 各所「交易所与实盘」字段(顺序即页面顺序)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
(
"OKX_SHOW_PERP_FUNDS",
"显示永续资金",
"默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
),
],
"binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
}
_SHARED_SECTIONS: list[dict[str, Any]] = [
{
"title": "企业微信",
"fields": [
("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
],
},
{
"title": "交易执行",
"fields": [
("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"),
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
("ALT_LEVERAGE", "山寨默认杠杆", ""),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"), ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
],
},
{
"title": "交易风控",
"fields": [
("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
],
},
{
"title": "账户冷静期",
"fields": [
("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
{
"title": "自动划转",
"fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", ""),
],
},
{
"title": "当日资金",
"fields": [
("DAILY_START_CAPITAL", "日起始基数(U)", ""),
("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
],
},
]
_MODE_SECTION: dict[str, Any] = {
"title": "期权/对冲模式",
"exchanges": frozenset({"okx"}),
"fields": [
(
"OKX_TRADE_MODE",
"交易模式",
"三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
),
],
}
_OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户",
"exchanges": frozenset({"okx"}),
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数",
"默认 14;下拉到期日只出现该天数内的合约(含明天)",
),
(
"OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
),
],
}
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
(
"MAX_ACTIVE_HEDGE_PLANS",
"对冲组数上限",
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
),
]
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
(
"HEDGE_PLAN_OPTION_PRIMARY",
"永期模式(以期权为主/保险)",
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
(
"HEDGE_PLAN_ITM_MAX_DIST_USD",
"永期实值最大深度(U)",
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
),
(
"HEDGE_PLAN_MIN_OPTION_HOURS",
"对冲期权最低剩余小时",
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
),
(
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
"对冲期权最低杠杆(S/ask)",
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
),
]
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
]
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
_HEDGE_PLAN_SECTION: dict[str, Any] = {
"title": "对冲计划",
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
*_HEDGE_COMMON_FIELDS,
*_HEDGE_PO_FIELDS,
*_HEDGE_OO_FIELDS,
],
}
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_CONTROL_ENABLED": "true",
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_SHOW_PERP_FUNDS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_TRADE_MODE": "options",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "OKX_TRADE_MODE":
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
if file_val:
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
return normalize_okx_trade_mode(file_val) or file_val
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
pass
if key in file_values:
file_val = str(file_values.get(key) or "").strip()
if file_val:
return file_val
runtime = os.getenv(key)
if runtime is not None and str(runtime).strip() != "":
return str(runtime).strip()
if schema_default:
return schema_default
return _RUNTIME_ENV_DEFAULTS.get(key, "")
def _env_truthy(raw: str) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path):
for field in group.get("fields") or []:
out[field["key"]] = dict(field)
return out
def _build_field(
key: str,
label: str,
note: str,
schema: dict[str, dict[str, Any]],
values: dict[str, str],
) -> dict[str, Any]:
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
if options:
ftype = "select"
val = normalize_select_value(key, val) or val
masked = _mask_value(key, val)
out: dict[str, Any] = {
"key": key,
"label": label,
"note": note or meta.get("note") or "",
"default": val,
"type": ftype,
"sensitive": meta.get("sensitive", _is_sensitive(key)),
"restart_required": meta.get("restart_required", _restart_required(key)),
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
"current": masked["value"] if not _is_sensitive(key) else "",
"masked": masked["masked"],
"tail": masked.get("tail") or "",
"has_value": masked["has_value"],
}
if options:
cur = (out["current"] or out["default"] or "").strip()
opt_vals = {o["value"] for o in options}
if cur and cur not in opt_vals:
options = [{"value": cur, "label": cur}] + options
out["options"] = options
return out
def _okx_mode_for_env_ui() -> str:
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
return "options"
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
fields = list(_OPTIONS_SECTION["fields"])
if mode != "options":
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
return fields
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
if mode == "perp_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
if mode == "options_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
return []
def ui_sections_for_exchange(
exchange_key: str,
*,
mode: str | None = None,
) -> list[dict[str, Any]]:
ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()):
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
m = normalize_okx_trade_mode(mode) if mode else ""
if not m:
m = _okx_mode_for_env_ui()
sections.append(_MODE_SECTION)
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
hedge_fields = _hedge_fields_for_mode(m)
if hedge_fields:
title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
sections.append({"title": title, "fields": hedge_fields})
return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]:
keys.add(item[0])
ex = (exchange_key or "").strip().lower()
if ex == "okx":
keys.add("OKX_TRADE_MODE")
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0])
for item in _OPTIONS_SECTION["fields"]:
keys.add(item[0])
return frozenset(keys)
def build_env_ui_payload(
exchange_key: str,
example_path: str,
env_path: str,
) -> list[dict[str, Any]]:
schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
):
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
]
fields = _mark_compound_budget_hidden(fields)
groups.append({
"title": sec["title"],
"fields": fields,
"has_restart": any(f.get("restart_required") for f in fields),
})
return groups
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
compound_on = True
for f in fields:
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
break
if not compound_on:
return fields
out: list[dict[str, Any]] = []
for f in fields:
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
item = dict(f)
item["hidden"] = True
out.append(item)
else:
out.append(f)
return out
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed}
def validate_env_ui_updates(
exchange_key: str,
example_path: str,
updates: dict[str, str],
) -> tuple[dict[str, str], list[str]]:
from lib.env.env_schema import validate_env_updates
schema = _schema_field_map(example_path)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(exchange_key):
fields: list[dict[str, Any]] = []
for key, _label, _note in sec["fields"]:
if key in schema:
field = dict(schema[key])
opts = select_options_for(key)
if opts:
field["type"] = "select"
field["options"] = opts
fields.append(field)
else:
default = ""
fields.append(
{
"key": key,
"type": _field_type(key, default),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
"options": select_options_for(key),
}
)
groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates)
def coerce_hedge_partial_close_with_manual(
clean: dict[str, str],
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
+190
View File
@@ -0,0 +1,190 @@
"""Local AI env helpers (standalone project)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines
from lib.env.env_schema import (
_field_type,
_hot_reload,
_is_sensitive,
_mask_value,
_restart_required,
parse_env_example_schema,
validate_env_updates,
)
from lib.paths import REPO_ROOT
AI_ENV_FIELDS: list[tuple[str, str, str]] = [
("AI_PROVIDER", "AI 提供方", "openai 或 ollama"),
("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"),
("OPENAI_API_KEY", "API 密钥", "留空表示不修改"),
("OPENAI_MODEL", "云端模型", ""),
("OLLAMA_API", "Ollama 地址", "本地服务 URL"),
("AI_MODEL", "Ollama 模型", ""),
("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"),
]
AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS)
def local_env_path() -> str:
return str(REPO_ROOT / ".env")
def local_example_path() -> str:
return str(REPO_ROOT / ".env.example")
def instance_example_path(exchange_key: str = "okx") -> str:
return local_example_path()
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path):
for field in group.get("fields") or []:
out[field["key"]] = dict(field)
return out
def _build_field(
key: str,
label: str,
note: str,
schema: dict[str, dict[str, Any]],
values: dict[str, str],
) -> dict[str, Any]:
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = values.get(key, "")
if val == "" and schema_default:
val = schema_default
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
return {
"key": key,
"label": label,
"note": note or meta.get("note") or "",
"default": val,
"type": ftype,
"sensitive": meta.get("sensitive", _is_sensitive(key)),
"restart_required": meta.get("restart_required", _restart_required(key)),
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
"current": masked["value"] if not _is_sensitive(key) else "",
"masked": masked["masked"],
"tail": masked.get("tail") or "",
"has_value": masked["has_value"],
}
def build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]:
env_path = env_path or local_env_path()
example_path = example_path or local_example_path()
schema = _schema_field_map(example_path)
values = env_get_all(read_env_lines(env_path))
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in AI_ENV_FIELDS
]
sync_status = ai_sync_status()
return {
"title": "AI 复盘",
"fields": fields,
"sync_status": sync_status,
}
def ai_sync_status() -> dict[str, Any]:
"""Standalone project: no multi-instance hub sync."""
path = local_env_path()
if not os.path.isfile(path):
return {"all_synced": False, "instances": {"local": {"ok": False, "msg": "缺少 .env"}}}
return {"all_synced": True, "instances": {"local": {"ok": True, "mismatched_keys": []}}}
def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]:
schema = _schema_field_map(example_path)
fields: list[dict[str, Any]] = []
for key, _label, _note in AI_ENV_FIELDS:
if key in schema:
fields.append(schema[key])
else:
fields.append(
{
"key": key,
"type": _field_type(key, ""),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
}
)
return [{"title": "AI 复盘", "fields": fields}]
def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]:
example_path = example_path or local_example_path()
groups = _ai_validate_groups(example_path)
filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS}
unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS]
errors = [f"未知配置项: {k}" for k in unknown]
clean, val_errors = validate_env_updates(groups, filtered)
errors.extend(val_errors)
return clean, errors
def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]:
"""Write AI keys to local .env only."""
clean, errors = validate_ai_env_updates(updates)
if errors:
return {"ok": False, "errors": errors, "changed": {}}
if not clean:
return {"ok": True, "changed": {}, "restart_required": False}
path = local_env_path()
changed_keys = apply_env_updates(path, clean)
if changed_keys:
load_env_file_into_environ(path)
restart_required = any(
(not _hot_reload(k)) or _restart_required(k) for k in changed_keys
)
return {
"ok": True,
"changed": {"local": list(changed_keys)},
"restart_required": restart_required,
}
def restart_local_pm2() -> dict[str, Any]:
"""Restart this app via PM2 if configured."""
return _restart_pm2_app("crypto_okx")
def _restart_pm2_app(app_name: str) -> dict[str, Any]:
try:
proc = subprocess.run(
["pm2", "restart", app_name, "--update-env"],
capture_output=True,
text=True,
timeout=120,
)
return {
"ok": proc.returncode == 0,
"msg": (proc.stdout or proc.stderr or "").strip()[:500],
"returncode": proc.returncode,
}
except FileNotFoundError:
return {"ok": False, "msg": "未找到 pm2 命令"}
except subprocess.TimeoutExpired:
return {"ok": False, "msg": "pm2 restart 超时"}
except Exception as e:
return {"ok": False, "msg": str(e)}
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+187
View File
@@ -0,0 +1,187 @@
"""Binance:交易账户 futures income;资金账户 deposits/withdrawals/transfers.USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _paginate_income(exchange, *, start_ms: int, end_ms: int, max_pages: int = 15) -> list[dict]:
out: list[dict] = []
cursor = int(start_ms)
end = int(end_ms)
for _ in range(max_pages):
try:
if hasattr(exchange, "fapiPrivateGetIncome"):
batch = exchange.fapiPrivateGetIncome(
{"startTime": cursor, "endTime": end, "limit": 1000}
)
else:
batch = exchange.fetch_ledger(
"USDT", cursor, 1000, {"type": "swap", "until": end}
)
# already unified
return batch or []
except Exception:
break
if not batch:
break
out.extend(batch)
if len(batch) < 1000:
break
last_t = batch[-1].get("time") or batch[-1].get("timestamp")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i >= end:
break
cursor = last_i + 1
return out
def _income_to_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# raw fapi income
if "income" in raw or "incomeType" in raw:
amt = raw.get("income")
ts = raw.get("time")
ccy = raw.get("asset") or "USDT"
raw_type = str(raw.get("incomeType") or "")
ref = str(raw.get("tranId") or raw.get("tradeId") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", ccy, ts, amt, raw_type),
raw_type=raw_type,
symbol=str(raw.get("symbol") or ""),
note=str(raw.get("info") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
return from_ccxt_ledger_entry(raw, account=ACCOUNT_TRADING)
def _dep_wd_to_row(entry: dict, *, kind: str) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("insertTime") or info.get("applyTime")
ccy = entry.get("currency") or info.get("coin") or "USDT"
status = entry.get("status") or info.get("status") or ""
ref = str(entry.get("id") or info.get("txId") or info.get("id") or "")
amt = amount
try:
af = float(amount)
if kind == "withdraw" and af > 0:
af = -af
amt = af
except Exception:
pass
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", kind, ccy, ts, amount),
raw_type=kind,
note=str(status),
kind=kind,
)
def _transfer_to_row(entry: dict) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("timestamp")
ccy = entry.get("currency") or info.get("asset") or "USDT"
ref = str(entry.get("id") or info.get("tranId") or info.get("id") or "")
frm = str(entry.get("fromAccount") or info.get("from") or "")
to = str(entry.get("toAccount") or info.get("to") or "")
try:
amt = float(amount)
except Exception:
return None
# 资金侧视角:从资金转出为负,转入为正(粗分)
note = f"{frm}->{to}".strip("->")
raw_type = "transfer"
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", "transfer", ccy, ts, amt),
raw_type=raw_type,
note=note,
kind=kind_from_raw("transfer", amt),
)
def fetch_binance_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
# 交易账户
try:
raw = _paginate_income(exchange, start_ms=start_ms, end_ms=end_ms)
for e in raw:
n = _income_to_row(e)
if n and n["ccy"] == "USDT":
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
# 资金账户:充提 + 划转
for label, fn, kind in (
("deposits", "fetch_deposits", "deposit"),
("withdrawals", "fetch_withdrawals", "withdraw"),
):
try:
meth = getattr(exchange, fn, None)
if not callable(meth):
continue
batch = meth("USDT", int(start_ms), 1000, {"until": int(end_ms)}) or []
for e in batch:
n = _dep_wd_to_row(e, kind=kind)
if n:
rows.append(n)
except Exception as e:
errors.append(f"{label}:{e}")
try:
if hasattr(exchange, "fetch_transfers"):
batch = (
exchange.fetch_transfers("USDT", int(start_ms), 1000, {"until": int(end_ms)})
or []
)
for e in batch:
n = _transfer_to_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"transfers:{e}")
return rows, errors
+9
View File
@@ -0,0 +1,9 @@
"""Gate.io ccxt 构造(ccxt 4.x 起类名由 gateio 改为 gate)."""
from __future__ import annotations
import ccxt
def gate_ccxt_class():
"""返回 ccxt Gate 交易所类(兼容旧版 gateio 名称)."""
return getattr(ccxt, "gate", None) or ccxt.gateio
+212
View File
@@ -0,0 +1,212 @@
"""Gate:资金账户(spot account_book) + 交易账户(futures account_book),USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _sec(ms: int) -> int:
return max(0, int(int(ms) // 1000))
def _paginate_spot_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
# Gate spot account_book: from/to 为秒
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateSpotGetAccountBook(
{
"currency": "USDT",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time") or batch[-1].get("create_time")
try:
last_i = int(float(last_t))
except Exception:
break
# spot 返回秒
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _paginate_swap_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateFuturesGetSettleAccountBook(
{
"settle": "usdt",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _spot_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
amt = raw.get("change")
ts = raw.get("time") or raw.get("create_time")
# 秒 → 毫秒
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or raw.get("change_type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or raw.get("txid") or "")
return normalize_row(
account=ACCOUNT_FUNDING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", raw_type, ts, amt),
raw_type=raw_type,
balance_after=bal,
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def _swap_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# futures account_book: change, balance, type, text, time, contract...
amt = raw.get("change")
ts = raw.get("time")
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", raw_type, ts, amt, raw.get("contract")),
raw_type=raw_type,
balance_after=bal,
symbol=str(raw.get("contract") or ""),
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def fetch_gate_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
try:
for e in _paginate_spot_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _spot_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{e}")
# 回退 ccxt fetch_ledger
try:
batch = exchange.fetch_ledger(
"USDT", int(start_ms), 100, {"type": "spot", "until": int(end_ms)}
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"funding_fallback:{e2}")
try:
for e in _paginate_swap_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _swap_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
try:
batch = exchange.fetch_ledger(
"USDT",
int(start_ms),
100,
{"type": "swap", "settle": "usdt", "until": int(end_ms)},
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"trading_fallback:{e2}")
return rows, errors
+66
View File
@@ -0,0 +1,66 @@
"""Gate 平仓历史匹配(fetch_positions_history),供 reconcile / 中控全平同步共用."""
from __future__ import annotations
def unified_symbol_for_match(symbol_str: str) -> str:
x = (symbol_str or "").strip().upper()
if ":" in x:
x = x.split(":")[0]
return x
def pick_gate_position_close(
hist: list[dict],
symbol: str,
direction: str,
*,
opened_at_ms: int | None = None,
closed_at_ms: int | None = None,
used_keys: set[str] | None = None,
max_close_delta_ms: int = 25 * 60 * 1000,
) -> dict | None:
"""
Gate 平仓历史列表中选取与 symbol/direction/开仓时间最匹配的一条.
返回 normalize 后的 dict( close_ms,pnl,sync_key ),无匹配则 None.
"""
if not hist:
return None
sym_u = unified_symbol_for_match(symbol)
dir_l = (direction or "long").strip().lower()
if dir_l not in ("long", "short"):
return None
used = used_keys or set()
ref_ms = closed_at_ms or opened_at_ms
best = None
best_d = None
for h in hist:
if not isinstance(h, dict):
continue
sk = h.get("sync_key")
if not sk or sk in used:
continue
if h.get("symbol_u") != sym_u:
continue
if (h.get("side") or "").strip().lower() != dir_l:
continue
cm = h.get("close_ms")
if cm is None:
continue
if opened_at_ms is not None:
if cm < opened_at_ms - 15 * 60 * 1000:
continue
if cm > opened_at_ms + 15 * 86400 * 1000:
continue
if ref_ms is not None:
d = abs(int(cm) - int(ref_ms))
else:
d = 0
if best_d is None or d < best_d:
best_d = d
best = h
if best is None or best_d is None:
return None
if ref_ms is not None and best_d > max_close_delta_ms:
return None
return best
+56
View File
@@ -0,0 +1,56 @@
"""Gate.io 资金划转(crypto_monitor_gate 共用)."""
from __future__ import annotations
from typing import Any, Callable, Optional
INVALID_KEY_HINT = (
".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
"④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单."
)
def execute_transfer_usdt(
exchange,
amount: float,
from_account: str,
to_account: str,
*,
transfer_ccy: str = "USDT",
ensure_live_ready: Callable[[], tuple[bool, str]],
ensure_markets_loaded: Optional[Callable[[], None]] = None,
) -> tuple[bool, str, Any]:
if amount <= 0:
return False, "划转金额必须大于0", None
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
ok_live, reason = ensure_live_ready()
if not ok_live:
return False, reason, None
if ensure_markets_loaded:
try:
ensure_markets_loaded()
except Exception:
pass
try:
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
if "INVALID_KEY" in msg or "Invalid key" in msg:
msg += INVALID_KEY_HINT
return False, msg, None
def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int:
"""自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划."""
n = int(count_order_monitors(conn) or 0)
if n > 0:
return n
try:
row = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans "
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
).fetchone()
return int(row[0] or 0) if row else 0
except Exception:
return n
+99
View File
@@ -0,0 +1,99 @@
"""OKX:资金账户 asset bills + 交易账户 account bills;USDT + USDC."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
)
OKX_LEDGER_CCYS = ("USDT", "USDC")
def _fetch_one(
exchange,
*,
code: str,
since: int,
until: int,
method: str,
max_pages: int = 10,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
after = None
for _ in range(max_pages):
params: dict[str, Any] = {"method": method, "until": int(until)}
if after:
params["after"] = after
try:
batch = exchange.fetch_ledger(code, int(since), 100, params) or []
except Exception:
# archive / bills 窗口差异:失败则停
break
if not batch:
break
out.extend(batch)
if len(batch) < 100:
break
# OKX 翻页用 billId
last = batch[-1]
info = last.get("info") if isinstance(last.get("info"), dict) else {}
bid = last.get("id") or info.get("billId")
if not bid:
break
after = str(bid)
return out
def fetch_okx_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
for ccy in OKX_LEDGER_CCYS:
# 资金账户
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method="privateGetAssetBills",
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{ccy}:{e}")
# 交易账户:近 3 月 archive + 近 7 日 bills(去重靠 upsert)
for method in ("privateGetAccountBillsArchive", "privateGetAccountBills"):
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method=method,
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{ccy}:{method}:{e}")
return rows, errors
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
"""
OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger).
交易所 App止盈止损页多为 orders-algo-pending, fetch_open_orders 默认拿不到.
"""
from __future__ import annotations
from typing import Any
def _order_dedupe_key(order: dict) -> str:
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
def _okx_algo_cancel_id(order_id: str) -> str:
oid = str(order_id or "")
if ":" in oid:
return oid.split(":", 1)[0]
return oid
def _okx_order_needs_stop_cancel_param(order: dict) -> bool:
"""OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败."""
if not isinstance(order, dict):
return False
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None:
return True
if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"):
return True
typ = str(order.get("type") or info.get("ordType") or "").lower()
for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"):
if token in typ:
return True
return False
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
"""合并 OKX 普通挂单与算法挂单(去重)."""
if not exchange_symbol:
return []
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
seen: set[str] = set()
out: list[dict] = []
def add_batch(batch: list | None) -> None:
for o in batch or []:
if not isinstance(o, dict):
continue
k = _order_dedupe_key(o)
if not k or k in seen:
continue
seen.add(k)
out.append(o)
try:
add_batch(ex.fetch_open_orders(sym))
except Exception:
pass
for params in (
{"ordType": "conditional"},
{"ordType": "oco"},
{"trigger": True},
):
try:
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
except Exception:
pass
return out
def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int:
"""
撤销某合约全部挂单(普通 + 条件/算法).
OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉.
"""
if not exchange_symbol:
return 0
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
n = 0
for o in fetch_okx_all_open_orders(ex, sym):
oid = _order_dedupe_key(o)
if not oid:
continue
cancel_id = _okx_algo_cancel_id(oid)
params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None
try:
ex.cancel_order(cancel_id, sym, params)
n += 1
continue
except Exception:
pass
try:
ex.cancel_order(oid, sym, params)
n += 1
except Exception:
pass
try:
ex.cancel_all_orders(sym)
except Exception:
pass
return n
+1
View File
@@ -0,0 +1 @@
# hedge_plan package
@@ -0,0 +1,86 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止单独开期权"
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return "获取期权持仓失败,暂禁止启动对冲计划"
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止启动对冲计划"
return None
+782
View File
@@ -0,0 +1,782 @@
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
from __future__ import annotations
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
return float(contracts) * float(contract_size or 1.0)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
return float(ask) * float(sheets) * float(ct_mult or 0.01)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值权利金 实值/=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/; Put: spot=K实值/.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional(
*,
notional: float,
entry: float,
contract_size: float,
) -> float:
if entry <= 0 or contract_size <= 0 or notional <= 0:
return 0.0
return float(notional) / (float(entry) * float(contract_size))
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
import math
raw = float(contracts or 0.0)
if raw <= 0:
return 0.0
try:
d = int(decimals)
except (TypeError, ValueError):
d = 0
if d <= 0:
return float(math.floor(raw + 1e-12))
scale = 10**d
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
entry: float,
tp: float,
sl: float,
contracts: float,
contract_size: float,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
index_px: Optional[float] = None,
) -> dict[str, Any]:
"""
永期情景.
止盈账:永续止盈盈利 - 权利金.
止损账:期权到期内在( SL ) - 永续止损亏损额.
"""
d = (direction or "long").strip().lower()
pnl_tp_perp = perp_pnl(
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
)
pnl_sl_perp = perp_pnl(
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
)
# 止盈统计口径
tp_total = float(pnl_tp_perp) - float(premium_paid)
# 止损:期权按 SL 价结算内在 - |永续亏损|
opt_at_sl = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=sl,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
pnl_sl_perp
)
# 有符号相加更稳:期权盈亏 + 永续盈亏
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
spot = float(index_px) if index_px is not None else float(entry)
opt_flat = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
flat_total = 0.0 + float(opt_flat)
opt_at_tp = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=tp,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
return {
"plan_type": "perp_options",
"direction": d,
"contracts": contracts,
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
"premium_paid": round(float(premium_paid), 6),
"scenarios": [
{
"id": "tp",
"label": "止盈(计划结束口径)",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(-float(premium_paid), 4),
"total": round(tp_total, 4),
"note": "止盈盈利 权利金;期权可不强平",
},
{
"id": "sl",
"label": "止损(计划结束口径)",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(sl_total_signed, 4),
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
},
{
"id": "flat",
"label": "到期·现价附近",
"spot": spot,
"perp_pnl": 0.0,
"options_pnl": round(opt_flat, 4),
"total": round(flat_total, 4),
"note": "示意:永续未动,期权按到期内在",
},
{
"id": "expiry_tp",
"label": "到期·止盈价",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(opt_at_tp, 4),
"total": round(pnl_tp_perp + opt_at_tp, 4),
"note": "若期权拿到 TP 价到期(参考)",
},
{
"id": "expiry_sl",
"label": "到期·止损价",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(pnl_sl_perp + opt_at_sl, 4),
"note": "与止损口径相近(期权用内在)",
},
],
"summary": {
"tp_total": round(tp_total, 4),
"sl_total": round(sl_total_signed, 4),
"premium_paid": round(float(premium_paid), 4),
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
},
}
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
if loss <= 1e-12:
return None
if float(opt_pnl) <= 0:
return 0.0
return round(float(opt_pnl) / loss * 100.0, 2)
def build_options_options_preview(
*,
target_price: float | None = None,
target_price_up: float | None = None,
target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float,
leg_a: dict[str, Any],
leg_b: dict[str, Any],
) -> dict[str, Any]:
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% .
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl(
opt_type=str(leg.get("opt_type") or ""),
strike=float(leg["strike"]),
spot=spot,
sheets=float(leg.get("sheets") or 0),
ct_mult=float(leg.get("ct_mult") or 0.01),
premium_paid=float(leg.get("premium_paid") or 0),
)
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"id": "expiry_flat",
"label": "到期·现价",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price
if up is None or down is None:
raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up)
down_f = float(down)
a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up
win_up = "a" if a_up >= b_up else "b"
a_dn = _leg_pnl(leg_a, down_f)
b_dn = _leg_pnl(leg_b, down_f)
at_dn = a_dn + b_dn
win_dn = "a" if a_dn >= b_dn else "b"
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
expiry_loss = flat_total if flat_total <= 0 else flat_total
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破
"target_price_up": up_f,
"target_price_down": down_f,
"winner_at_up": win_up,
"winner_at_down": win_dn,
"winner_at_target": win_up,
"scenarios": [
{
"id": "target_up",
"label": "上破目标",
"spot": up_f,
"leg_a_pnl": round(a_up, 4),
"leg_b_pnl": round(b_up, 4),
"total": round(at_up, 4),
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
},
{
"id": "target_down",
"label": "下破目标",
"spot": down_f,
"leg_a_pnl": round(a_dn, 4),
"leg_b_pnl": round(b_dn, 4),
"total": round(at_dn, 4),
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
},
{
"id": "expiry_flat",
"label": "到期·现价(无突破)",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
},
}
def gate_status(
*,
hedge_enabled: bool,
sizing_mode: str,
plan_type: str,
options_enabled: bool,
live_order: bool = False,
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
full = is_full_margin_mode(sizing_mode)
pt = (plan_type or "").strip().lower()
can_preview = True
can_start = True
reasons: list[str] = []
if not hedge_enabled:
can_start = False
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
if not options_enabled:
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
if not live_trading:
can_start = False
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
elif pt == "options_options":
pass
else:
can_start = False
reasons.append("未知计划类型")
if can_start:
reasons = []
return {
"hedge_enabled": hedge_enabled,
"options_enabled": options_enabled,
"sizing_mode": sizing_mode,
"is_full_margin": full,
"plan_type": pt,
"live_order": live_order,
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
}
+468
View File
@@ -0,0 +1,468 @@
"""对冲计划 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")
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);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,
p.profit_rr, 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()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
if target_f is None or target_f <= 0:
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],
}
+285
View File
@@ -0,0 +1,285 @@
"""对冲计划虚实值选约与校验.
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
期期(options_options):两腿仅允许平值或虚值(禁实值).
"""
from __future__ import annotations
import os
from typing import Any, Optional
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def itm_max_dist_usd() -> float:
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
if raw:
try:
return max(0.0, float(raw))
except ValueError:
pass
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
def min_option_hours() -> float:
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
def min_option_leverage() -> float:
"""指数/卖一 最低杠杆门槛;0=不启用."""
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
o = str(opt_type or "").strip().upper()
if o in ("C", "CALL"):
return "C"
if o in ("P", "PUT"):
return "P"
inst = str(inst_id or "").upper()
if inst.endswith("-C") or inst.endswith("-CALL"):
return "C"
if inst.endswith("-P") or inst.endswith("-PUT"):
return "P"
return ""
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
from lib.options.options_pricing_lib import option_moneyness
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("itm", "atm"):
return True
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k <= s + 1e-9
if o == "P":
return k >= s - 1e-9
return False
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("atm", "otm"):
return True
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k >= s - 1e-9 # 平值带内或虚值
if o == "P":
return k <= s + 1e-9
return False
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C" and k < s:
return s - k
if o == "P" and k > s:
return k - s
return 0.0
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
parts = str(inst_id or "").strip().upper().split("-")
if len(parts) < 5:
return None
return _sf(parts[-2])
def pick_itm_or_atm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
itm_max_dist: Optional[float] = None,
) -> Optional[dict[str, Any]]:
"""在合约列表中选距标的最近的实值/平值腿."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
continue
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
if max_dist > 0 and depth > max_dist:
continue
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def pick_atm_or_otm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
prefer: str = "atm",
) -> Optional[dict[str, Any]]:
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
prefer_l = (prefer or "atm").strip().lower()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
continue
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
if prefer_l == "otm" and m != "otm":
continue
if prefer_l == "atm" and m == "otm":
# 仍可入选,但排序靠后(先 atm)
cands.append((1_000_000 + abs(k - index_px), k, c))
else:
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def recommend_oo_legs(
contracts: list[dict[str, Any]],
*,
index_px: float,
template: str = "atm_straddle",
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
tpl = (template or "atm_straddle").strip().lower()
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
call = pick_atm_or_otm_contract(
contracts, opt_type="C", index_px=index_px, prefer=prefer
)
put = pick_atm_or_otm_contract(
contracts, opt_type="P", index_px=index_px, prefer=prefer
)
if not call or not put:
return None
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
return None
return call, put
def validate_po_option_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
hours_to_expiry: Any = None,
) -> Optional[str]:
"""永期保险腿校验;返回错误文案或 None."""
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效,无法校验虚实值"
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "永期保险腿须为实值或平值,不可选虚值"
max_dist = itm_max_dist_usd()
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
if max_dist > 0 and depth > max_dist:
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
min_h = min_option_hours()
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
min_lev = min_option_leverage()
a = _sf(ask)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_oo_leg_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
role: str = "",
) -> Optional[str]:
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return f"{role}期权类型无效"
if k is None or s is None or s <= 0:
return f"{role}行权价或指数无效,无法校验虚实值"
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m == "itm":
return f"{role}须为平值或虚值,不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return f"{role}须为平值或虚值"
return None
def validate_oo_legs_moneyness(
leg_a: dict[str, Any],
leg_b: dict[str, Any],
*,
index_px: Any,
) -> Optional[str]:
err = validate_oo_leg_moneyness(
opt_type=leg_a.get("opt_type"),
strike=leg_a.get("strike"),
index_px=index_px,
role="腿A",
)
if err:
return err
err = validate_oo_leg_moneyness(
opt_type=leg_b.get("opt_type"),
strike=leg_b.get("strike"),
index_px=index_px,
role="腿B",
)
if err:
return err
return None
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.hedge_plan.hedge_plan_db import update_plan
def _fmt(v: Any, d: int = 2) -> str:
try:
if v is None or v == "":
return ""
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _type_label(plan_type: str) -> str:
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
def _dir_label(direction: str) -> str:
d = (direction or "").lower()
if d == "long":
return "做多"
if d == "short":
return "做空"
return ""
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
pt = plan.get("plan_type") or ""
lines = [
f"🟢 对冲计划启动 #{plan.get('id')}",
f"📌 类型:{_type_label(pt)}",
f"🪙 标的:{plan.get('underlying') or ''}",
]
if pt == "perp_options":
lines.extend(
[
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
if legs:
for leg in legs:
role = leg.get("leg_role") or ""
if role == "perp":
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
else:
lines.append(
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
f"×{_fmt(leg.get('size'), 0)}{leg.get('inst_id') or ''}"
)
lines.append("📎 独立模块推送,不进普通交易复盘")
return "\n".join(lines)
def build_hedge_end_message(plan: dict[str, Any]) -> str:
reason = plan.get("close_reason") or ""
total = plan.get("realized_pnl_total")
try:
tv = float(total) if total is not None else None
except (TypeError, ValueError):
tv = None
head = "🔴" if (tv is not None and tv < 0) else "🟢"
reason_map = {
"perp_tp": "永续止盈(期权默认不平)",
"perp_sl": "永续止损(期权强制平)",
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
"manual": "人工结束",
"partial_fail": "半腿失败收尾",
"cancelled": "已取消",
}
lines = [
f"{head} 对冲计划结束 #{plan.get('id')}",
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
f"🪙 标的:{plan.get('underlying') or ''}",
f"📎 原因:{reason_map.get(reason, reason)}",
f"💰 合计≈U:{_fmt(total)}",
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
f"⏱ 开仓:{plan.get('opened_at') or ''}|结束:{plan.get('closed_at') or ''}",
]
return "\n".join(lines)
def build_hedge_alert_message(
*,
title: str,
plan_id: Any = None,
detail: str = "",
) -> str:
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
if detail:
lines.append(str(detail)[:800])
return "\n".join(lines)
def notify_hedge(
cfg: dict[str, Any],
content: str,
) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def notify_plan_start(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
legs: Optional[list[dict[str, Any]]] = None,
) -> bool:
if int(plan.get("wechat_start_sent") or 0):
return False
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
plan["wechat_start_sent"] = 1
return ok
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
if int(plan.get("wechat_end_sent") or 0):
return False
# 中间态 target_win_leg 不算正式结束推送(用告警)
if (plan.get("close_reason") or "") in (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
cr = str(plan.get("close_reason") or "")
if "profit_rr" in cr:
side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=detail,
),
)
return True
ok = notify_hedge(cfg, build_hedge_end_message(plan))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
plan["wechat_end_sent"] = 1
return ok
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
detail = msg
if results:
try:
detail = f"{msg}\n路径结果:{results}"[:800]
except Exception:
pass
return notify_hedge(
cfg,
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
)
@@ -0,0 +1,527 @@
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
from __future__ import annotations
import math
import os
from typing import Any, Optional
PREMIUM_EXEC_FACTOR = 0.95
DEFAULT_MIN_HOURS = 36.0
DEFAULT_STRIKE_INTERVAL = 15.0
DEFAULT_PERP_LEVERAGE = 100
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
DEFAULT_OPT_LEVERAGE_OTM = 200.0
DEFAULT_RATIO_ITM_ATM = 2.0
DEFAULT_RATIO_OTM = 4.0
OTM_LEV_FLOOR = 180.0
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
if not body_or_plan:
return False
v = body_or_plan.get("option_primary")
if v in (True, 1, "1", "true", "yes", "on"):
return True
try:
return int(v or 0) == 1
except (TypeError, ValueError):
return False
def fee_rate() -> float:
try:
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
except (TypeError, ValueError):
return 0.0005
def floor2(v: float) -> float:
"""ETH 数量向下取两位小数."""
if v <= 0:
return 0.0
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
def opt_type_for_view(direction: str) -> str:
"""看法做多→Call,做空→Put."""
return "P" if str(direction or "").strip().lower() == "short" else "C"
def perp_direction_for_view(direction: str) -> str:
"""看法做多→永续空,做空→永续多."""
return "long" if str(direction or "").strip().lower() == "short" else "short"
def default_opt_leverage(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
def default_ratio(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
cfg = _sf(configured)
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
if (moneyness or "").strip().lower() == "otm":
return max(base, OTM_LEV_FLOOR)
return base
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
exp = _sf(exp_ms)
if exp is None or exp <= 0:
return None
# OKX exp 多为毫秒
if exp < 1e12:
exp *= 1000.0
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
return (exp - now) / 3600000.0
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤KN.点数须 >0."""
n = float(points or 0)
k = float(strike)
s = float(index_px)
if n <= 0 or k <= 0 or s <= 0:
return False
side = str(view_side or "").strip().lower()
if side == "short":
return s <= (k - n)
return s >= (k + n)
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
b = _sf(bid)
if b is None or b <= 0:
return False, "暂无买一报价,无法平期权"
sz = _sf(bid_sz)
if sz is not None and sz <= 0:
return False, "买一深度为 0,无法平期权"
need = float(need_sheets or 0)
if need > 0 and sz is not None and sz + 1e-12 < need:
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
return True, ""
def size_from_premium(
*,
premium_budget: float,
ask: float,
ct_mult: float,
ratio: float,
contract_size: float,
exec_factor: float = PREMIUM_EXEC_FACTOR,
) -> dict[str, Any]:
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
budget = float(premium_budget or 0)
a = float(ask or 0)
ct = float(ct_mult or 0.01)
r = float(ratio or 0)
cs = float(contract_size or 0.01)
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
return {
"ok": False,
"msg": "定仓参数无效",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# ask 为每 1 币权利金;ETH 数量 = usable / ask
eth_qty = floor2(usable / a)
if eth_qty <= 0:
return {
"ok": False,
"msg": "权利金不足以买入 0.01 ETH 名义期权",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
sheets = eth_qty / ct
# 张数向下取整到整数张(OKX 期权常见整张)
sheets_i = float(math.floor(sheets + 1e-12))
if sheets_i <= 0:
return {
"ok": False,
"msg": "换算期权张数不足 1 张",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# 用整张回写 ETH,保持与下单一致
eth_qty = round(sheets_i * ct, 2)
perp_eth = eth_qty / r
contracts = perp_eth / cs
premium_est = a * sheets_i * ct
return {
"ok": True,
"msg": "",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": sheets_i,
"perp_eth": round(perp_eth, 6),
"contracts": contracts,
"premium_est": round(premium_est, 4),
"ratio": r,
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
}
def estimate_combo_net_pnl(
*,
view_side: str,
strike: float,
index_px: float,
ask_open: float,
bid: float,
sheets: float,
ct_mult: float,
perp_direction: str,
perp_entry: float,
perp_mark: float,
contracts: float,
contract_size: float,
fee: Optional[float] = None,
) -> dict[str, Any]:
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
fr = fee if fee is not None else fee_rate()
ct = float(ct_mult or 0.01)
sh = float(sheets or 0)
a = float(ask_open or 0)
b = float(bid or 0)
premium = a * sh * ct
opt_proceeds = b * sh * ct
opt_open_fee = premium * fr
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
coins = float(contracts or 0) * float(contract_size or 0.01)
entry = float(perp_entry or 0)
mark = float(perp_mark or 0)
pd = str(perp_direction or "").strip().lower()
if pd == "short":
perp_gross = (entry - mark) * coins
else:
perp_gross = (mark - entry) * coins
perp_notional_open = abs(entry * coins)
perp_notional_close = abs(mark * coins)
perp_open_fee = perp_notional_open * fr
perp_close_fee = perp_notional_close * fr
perp_net = perp_gross - perp_open_fee - perp_close_fee
total = opt_net + perp_net
return {
"opt_net": round(opt_net, 4),
"perp_net": round(perp_net, 4),
"net": round(total, 4),
"fee_rate": fr,
"premium": round(premium, 4),
"opt_proceeds": round(opt_proceeds, 4),
}
def validate_option_primary_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
moneyness: str = "atm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
hours_to_expiry: Any = None,
min_opt_leverage: Any = None,
) -> Optional[str]:
from lib.hedge_plan.hedge_plan_moneyness_lib import (
classify_moneyness,
is_atm_or_otm,
is_itm_or_atm,
normalize_opt_type,
)
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效"
m_want = (moneyness or "atm").strip().lower()
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m_want == "itm":
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "所选须为实值或平值"
elif m_want == "atm":
# 平值:距指数在间隔内即可(不强制 classify==atm)
pass
elif m_want == "otm":
if m_got == "itm":
return "虚值模式不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return "虚值模式须选虚值或平值档"
else:
return "期权类型(实/平/虚)无效"
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
if interval > 0 and abs(k - s) > interval + 1e-9:
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
a = _sf(ask)
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
"""盯盘启动校验:只要参数,不要求已选具体合约."""
need = (
"direction",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
"option_leverage",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
if float(body["option_leverage"]) <= 0:
return "期权杠杆须大于 0"
lev_perp = _sf(body.get("leverage"))
if lev_perp is not None and lev_perp <= 0:
return "永续杠杆须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
if moneyness not in ("itm", "atm", "otm"):
return "期权类型(实/平/虚)无效"
return None
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
need = (
"direction",
"contracts",
"opt_inst_id",
"sheets",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0"
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
want = opt_type_for_view(direction)
if opt_type != want:
return f"以期权为主时做{'' if direction == 'long' else ''}须用 {'Call' if want == 'C' else 'Put'}"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
strike = body.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
index_px = body.get("index_px") or body.get("entry")
return validate_option_primary_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
moneyness=moneyness,
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
hours_to_expiry=body.get("hours_to_expiry"),
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
)
def pick_option_primary_candidate(
chain: dict[str, Any],
*,
direction: str,
moneyness: str = "otm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
min_opt_leverage: Any = None,
) -> Optional[dict[str, Any]]:
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
want = opt_type_for_view(direction)
m_want = (moneyness or "otm").strip().lower()
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
try:
idx = float(chain.get("index_px") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
return None
best: Optional[dict[str, Any]] = None
best_dist: Optional[float] = None
for exp in chain.get("expiries") or []:
h = hours_to_expiry_from_ms(exp.get("exp_time"))
if min_h > 0 and h is not None and h < min_h:
continue
for c in exp.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
try:
k = float(c.get("strike") or 0)
ask = float(c.get("ask") or 0)
except (TypeError, ValueError):
continue
if k <= 0 or ask <= 0:
continue
if interval > 0 and abs(k - idx) > interval + 1e-9:
continue
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
if m_want == "itm" and m_got not in ("itm", "atm"):
continue
if m_want == "atm" and m_got != "atm":
continue
if m_want == "otm" and m_got == "itm":
continue
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
if min_lev > 0 and idx / ask < min_lev - 1e-9:
continue
dist = abs(k - idx)
if best is None or best_dist is None or dist < best_dist:
best = {
**dict(c),
"hours_to_expiry": h,
"exp_time": exp.get("exp_time"),
"moneyness": m_got,
"index_px": idx,
"leverage": round(idx / ask, 1),
}
best_dist = dist
return best
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
"""情景:期权目标 / 永续目标粗估净利."""
view = str(body.get("direction") or "long").lower()
strike = float(body["strike"])
n = float(body.get("option_target_points") or 0)
m = float(body.get("perp_target_points") or 0)
ask = float(body.get("ask") or 0)
sheets = float(body.get("sheets") or 0)
ct = float(body.get("ct_mult") or 0.01)
contracts = float(body.get("contracts") or 0)
cs = float(body.get("contract_size") or 0.01)
entry = float(body.get("entry") or body.get("index_px") or 0)
perp_dir = perp_direction_for_view(view)
# 粗估到点时期权卖价:按内在价值近似(下限 0)
def intrinsic(spot: float) -> float:
o = opt_type_for_view(view)
if o == "C":
return max(0.0, spot - strike)
return max(0.0, strike - spot)
scenarios = []
for label, pts, reason in (
("期权目标", n, "opt_target_points"),
("永续目标", m, "perp_target_points"),
):
spot = strike + pts if view != "short" else strike - pts
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
net = estimate_combo_net_pnl(
view_side=view,
strike=strike,
index_px=spot,
ask_open=ask,
bid=bid_est,
sheets=sheets,
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=entry,
perp_mark=spot,
contracts=contracts,
contract_size=cs,
)
scenarios.append(
{
"label": label,
"reason": reason,
"index": spot,
"perp_pnl": net["perp_net"],
"options_pnl": net["opt_net"],
"total": net["net"],
"note": "扣费净利估价;平仓费按买入费率",
}
)
premium = ask * sheets * ct
return {
"plan_type": "perp_options",
"option_primary": True,
"summary": {
"premium_paid": round(premium, 4),
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
"opt_target_total": scenarios[0]["total"] if scenarios else None,
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
"perp_direction": perp_dir,
"opt_type": opt_type_for_view(view),
},
"scenarios": scenarios,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
exp = leg_exp_ms(leg)
if exp is None:
return False
now = int(now_ms if now_ms is not None else time.time() * 1000)
return now >= int(exp)
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
"""按到期结算口径估算腿盈亏(USDC)."""
premium = float(leg.get("premium") or 0)
strike = _sf(leg.get("strike"))
if strike is None:
return -premium
sheets = float(leg.get("size") or 1)
# ct_mult 未入库时默认 0.01
ct = float(leg.get("ct_mult") or 0.01)
return float(
option_expiry_pnl(
opt_type=str(leg.get("opt_type") or "P"),
strike=float(strike),
spot=float(spot),
sheets=sheets,
ct_mult=ct,
premium_paid=premium,
)
)
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
opts = [
x
for x in legs
if str(x.get("leg_role") or "").startswith("option")
and str(x.get("status") or "") in ("open", "hold_to_expiry")
]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
close_ms = _parse_opened_ms(leg.get("closed_at"))
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
info = resolve_option_close_from_history(
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
)
pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
+103
View File
@@ -0,0 +1,103 @@
"""OKX 期权/对冲三选一模式(env: OKX_TRADE_MODE).
options 仅单独期权(隐藏对冲导航与对冲 env 配置)
perp_options 仅永期对冲(不可单独开期权;对冲组数上限 MAX_ACTIVE_HEDGE_PLANS)
options_options 仅期期对冲(同上)
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_PERP,
"perp": MODE_PERP,
"永期": MODE_PERP,
"永期对冲": MODE_PERP,
"oo": MODE_OO,
"期期": MODE_OO,
"期期对冲": MODE_OO,
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
return ""
def legacy_infer_okx_trade_mode() -> str:
"""未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变."""
if not _env_bool("HEDGE_PLAN_ENABLED", False):
return MODE_OPTIONS
show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
if show_po and not show_oo:
return MODE_PERP
if show_oo and not show_po:
return MODE_OO
if show_po:
return MODE_PERP
if show_oo:
return MODE_OO
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
if m:
return m
return legacy_infer_okx_trade_mode()
def hedge_module_enabled() -> bool:
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
def show_perp_options() -> bool:
return get_okx_trade_mode() == MODE_PERP
def show_options_options() -> bool:
return get_okx_trade_mode() == MODE_OO
def standalone_options_open_allowed() -> bool:
return get_okx_trade_mode() == MODE_OPTIONS
def mode_label(mode: Optional[str] = None) -> str:
m = mode or get_okx_trade_mode()
return {
MODE_OPTIONS: "单独期权",
MODE_PERP: "永期对冲",
MODE_OO: "期期对冲",
}.get(m, m or "")
def block_standalone_open_by_mode_msg() -> Optional[str]:
if standalone_options_open_allowed():
return None
return (
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
"请在 env「交易模式」切换为「单独期权」"
)
@@ -0,0 +1,408 @@
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
{% endif %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
{% if hedge_plan_show_perp_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
</div>
<p class="muted" id="hp-gate-line"></p>
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
</div>
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card hp-po-perp-card">
<h2>
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
<span id="hp-po-card-title">执行参数</span>
· <span id="hp-perp-uly-label">ETH</span>
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
</h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
</div>
<div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
<label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div id="hp-po-fields-option-primary">
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
<label class="hp-po-field">
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆</span>
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
<label class="hp-po-field">
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权间隔 <em></em></span>
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--type">
<span class="hp-po-field-lab">类型</span>
<select id="hp-money-select" aria-label="虚实值类型">
<option value="otm" selected>虚值</option>
<option value="itm">实值/平值</option>
<option value="atm">仅平值</option>
</select>
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
<div class="hp-po-fields hp-po-fields--section">
<label class="hp-po-field">
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
</div>
<div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
</div>
<div class="card hp-po-right-card">
<div class="hp-po-right-stack">
<div class="hp-po-inner-card hp-po-perp-quote-card">
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
<div class="hp-po-quote-head">
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
</div>
<div class="hp-po-inner-card hp-opt-card">
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
</span>
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
<table class="options-strike-table" id="hp-strike-table">
<thead>
<tr>
<th>行权价</th>
<th>实虚值</th>
<th title="指数÷卖一">杠杆</th>
<th>卖一/张</th>
<th>买一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="hp-strike-tbody">
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
</div>
</div>
<div class="form-row hp-action-row hp-po-action-row">
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row hp-oo-target-row">
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true"></span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值平</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
</div>
</div>
<div class="card hp-opt-card">
<h2>期权 T 型报价</h2>
<div class="form-row">
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true"></span>平/虚</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅平值</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅虚值</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐跨式</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐双虚</button>
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>显示全部</button>
</div>
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
<thead>
<tr>
<th colspan="4" class="opt-t-head-call">Call</th>
<th class="opt-t-head-mid">行权</th>
<th colspan="4" class="opt-t-head-put">Put</th>
</tr>
<tr>
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
<th>K</th>
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
</tr>
</thead>
<tbody id="hp-oo-tbody">
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc"></strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc"></strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>进行中的计划</h2>
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-active-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>历史记录</h2>
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-history-tbody">
<tr><td colspan="10" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>统计分析</h2>
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
<div id="hp-stats-box" class="muted">加载中…</div>
</div>
</div>
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
<div class="hp-modal-head">
<h3 id="hp-detail-title">成交细节</h3>
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
</div>
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=46"></script>
+1
View File
@@ -0,0 +1 @@
"""Shared library package."""
+187
View File
@@ -0,0 +1,187 @@
"""实盘/关键位放大 K 线:订单元数据与交易所浮盈,价格展示精度."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.market.ohlcv_lib import (
normalize_price_tick,
price_tick_from_market,
round_ohlcv_bars_to_tick,
)
from lib.trade.order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
)
def resolve_kline_price_tick(
exchange: Any,
exchange_symbol: str,
*,
ensure_markets_fn: Callable[[], None],
) -> Optional[float]:
"""交易所最小价格变动单位,供 lightweight-charts 右侧刻度与标记线对齐."""
if not exchange_symbol:
return None
try:
ensure_markets_fn()
return normalize_price_tick(price_tick_from_market(exchange, exchange_symbol))
except Exception:
return None
def align_candles_to_price_tick(
candles: list[dict[str, Any]],
price_tick: Optional[float],
) -> None:
if price_tick is not None and candles:
round_ohlcv_bars_to_tick(candles, price_tick)
def kline_api_price_fields(
exchange: Any,
exchange_symbol: str,
candles: list[dict[str, Any]],
*,
ensure_markets_fn: Callable[[], None],
) -> dict[str, Any]:
tick = resolve_kline_price_tick(
exchange, exchange_symbol, ensure_markets_fn=ensure_markets_fn
)
align_candles_to_price_tick(candles, tick)
return {"price_tick": tick}
def load_swap_positions_for_order_kline(
exchange: Any,
*,
private_configured: bool,
ensure_markets_fn: Callable[[], None],
settle: str = "usdt",
) -> list:
if not private_configured:
return []
try:
ensure_markets_fn()
try:
return exchange.fetch_positions(None, {"settle": settle}) or []
except Exception:
return exchange.fetch_positions() or []
except Exception:
return []
def metrics_for_order_item(
order_item: dict[str, Any],
positions: list,
*,
resolve_ex_sym_fn: Callable[[Any], str],
select_live_fn: Callable[[list, str, str], Any],
parse_metrics_fn: Callable[..., Optional[dict]],
) -> Optional[dict]:
if not positions:
return None
ex_sym = resolve_ex_sym_fn(order_item)
direction = order_item.get("direction") or "long"
prow = select_live_fn(positions, ex_sym, direction)
if not prow:
return None
lev = order_item.get("leverage")
return parse_metrics_fn(prow, order_leverage=lev)
def build_order_kline_order_payload(
order_item: dict[str, Any],
*,
ticker_price: Any,
format_price_fn: Callable[[Any, Any], str],
calc_pnl_fn: Callable[..., float],
calc_rr_ratio_fn: Callable[..., Optional[float]],
ex_metrics: Optional[dict] = None,
) -> dict[str, Any]:
sym = order_item.get("symbol") or ""
direction = order_item.get("direction") or "long"
margin = float(order_item.get("margin_capital") or 0)
leverage = float(order_item.get("leverage") or 0)
entry = float(order_item.get("trigger_price") or 0)
float_pnl = 0.0
float_pct = 0.0
if ticker_price and entry > 0:
float_pnl = float(
calc_pnl_fn(direction, entry, ticker_price, margin, leverage)
)
float_pct = round((float_pnl / margin * 100), 4) if margin > 0 else 0.0
px_for_fmt = ticker_price
mark_raw = None
if ex_metrics and ex_metrics.get("mark_price") is not None:
mark_raw = ex_metrics["mark_price"]
try:
px_for_fmt = float(mark_raw)
except (TypeError, ValueError):
pass
if ex_metrics and ex_metrics.get("unrealized_pnl") is not None:
float_pnl = round(float(ex_metrics["unrealized_pnl"]), 2)
denom = ex_metrics.get("initial_margin") or margin
float_pct = (
round((float_pnl / float(denom)) * 100, 4)
if denom and float(denom) > 0
else float_pct
)
payload: dict[str, Any] = {
"id": order_item["id"],
"symbol": sym,
"direction": direction,
"trigger_price": order_item.get("trigger_price"),
"stop_loss": order_item.get("stop_loss"),
"take_profit": order_item.get("take_profit"),
"trigger_price_display": format_price_fn(sym, order_item.get("trigger_price")),
"stop_loss_display": format_price_fn(sym, order_item.get("stop_loss")),
"take_profit_display": format_price_fn(sym, order_item.get("take_profit")),
"margin_capital": order_item.get("margin_capital"),
"leverage": order_item.get("leverage"),
"position_ratio": order_item.get("position_ratio"),
"breakeven_enabled": bool(int(order_item.get("breakeven_enabled") or 0)),
"current_price": round(float(px_for_fmt), 8) if px_for_fmt is not None else None,
"float_pnl": round(float(float_pnl), 2),
"float_pct": float_pct,
}
apply_order_price_display_fields(
payload,
direction=direction,
entry_price=order_item.get("trigger_price"),
initial_stop_loss=order_item.get("initial_stop_loss"),
stop_loss=order_item.get("stop_loss"),
take_profit=order_item.get("take_profit"),
calc_rr_ratio_fn=calc_rr_ratio_fn,
)
apply_order_live_price_display(
payload,
sym,
ticker_price,
mark_raw,
format_price_fn,
)
payload["current_price_display"] = payload.get("price_display") or (
format_price_fn(sym, px_for_fmt) if px_for_fmt is not None else None
)
return payload
def enrich_key_kline_response(
*,
symbol: str,
current_price: Any,
key_info: Optional[dict[str, Any]],
format_price_fn: Callable[[Any, Any], str],
) -> tuple[Any, Optional[dict[str, Any]]]:
price_display = format_price_fn(symbol, current_price) if current_price is not None else None
if key_info is None:
return price_display, None
enriched = dict(key_info)
enriched["upper_display"] = format_price_fn(symbol, key_info.get("upper"))
enriched["lower_display"] = format_price_fn(symbol, key_info.get("lower"))
return price_display, enriched
+175
View File
@@ -0,0 +1,175 @@
"""实例数据看板:后台定时聚合,内存快照,SSE 版本通知(对齐中控 dashboard_store)."""
from __future__ import annotations
import json
import os
import queue
import threading
from collections.abc import Callable, Iterator
from typing import Any
INSTANCE_DASHBOARD_POLL_SEC = float(os.getenv("INSTANCE_DASHBOARD_POLL_SEC", "5"))
INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
BuildFn = Callable[[], dict[str, Any]]
class InstanceDashboardStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self.version = 0
self.payload: dict[str, Any] | None = None
self.aggregating = False
self.last_error: str | None = None
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._refresh = threading.Event()
self._thread: threading.Thread | None = None
self._build_fn: BuildFn | None = None
def start(self, build_fn: BuildFn) -> None:
self._build_fn = build_fn
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._loop,
daemon=True,
name="instance-dashboard-poll",
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._refresh.set()
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def snapshot_dict(self) -> dict[str, Any]:
with self._lock:
p = dict(self.payload or {})
ver = self.version
aggregating = self.aggregating
err = self.last_error
if not p:
return {
"ok": False,
"dashboard_version": ver,
"aggregating": aggregating,
"error": err,
"msg": err or "看板快照尚未就绪",
"poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC,
}
return {
**p,
"dashboard_version": ver,
"aggregating": aggregating,
"error": err or p.get("error"),
"poll_interval_sec": INSTANCE_DASHBOARD_POLL_SEC,
}
def event_dict(self) -> dict[str, Any]:
with self._lock:
p = self.payload or {}
return {
"dashboard_version": self.version,
"updated_at": p.get("updated_at"),
"aggregating": self.aggregating,
"ok": p.get("ok", True) if self.payload else False,
"error": self.last_error or p.get("error"),
}
def _loop(self) -> None:
assert self._build_fn is not None
while not self._stop.is_set():
self._aggregate_once(self._build_fn)
if self._stop.is_set():
break
self._refresh.clear()
# 周期等待,可被 request_refresh 提前唤醒
self._refresh.wait(timeout=INSTANCE_DASHBOARD_POLL_SEC)
def _aggregate_once(self, build_fn: BuildFn) -> None:
with self._lock:
self.aggregating = True
self._broadcast()
try:
result = build_fn()
if not isinstance(result, dict):
result = {"ok": False, "msg": "聚合返回无效"}
except Exception as e:
result = {"ok": False, "msg": str(e), "error": "aggregate_failed"}
with self._lock:
self.version += 1
prev = self.payload if isinstance(self.payload, dict) else None
if result.get("ok") is False and prev and prev.get("ok"):
self.payload = prev
self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed")
else:
self.payload = result
self.last_error = (
None
if result.get("ok") is not False
else str(result.get("msg") or result.get("error") or "aggregate_failed")
)
self.aggregating = False
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(event)
except queue.Full:
dead.append(q)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
with self._lock:
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = q.get(timeout=INSTANCE_DASHBOARD_SSE_HEARTBEAT_SEC)
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: dashboard\ndata: {body}\n\n"
instance_dashboard_store = InstanceDashboardStore()
+571
View File
@@ -0,0 +1,571 @@
"""实例数据看板:本户活跃监控 / 持仓只读聚合."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Callable, Optional
def _row_dict(row: Any) -> dict[str, Any]:
if row is None:
return {}
if isinstance(row, dict):
return dict(row)
try:
return dict(row)
except Exception:
return {}
def _safe_float(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _dir_label(direction: Any) -> str:
d = str(direction or "").strip().lower()
if d == "short":
return "做空"
if d == "long":
return "做多"
return str(direction or "-")
def _format_order_item(od: dict[str, Any]) -> dict[str, Any]:
try:
from lib.trade.trade_labels_lib import apply_order_monitor_source_labels
od = apply_order_monitor_source_labels(od)
except Exception:
pass
try:
from lib.trade.entry_model_lib import enrich_entry_model_display
enrich_entry_model_display(od)
except Exception:
pass
sym = od.get("exchange_symbol") or od.get("symbol") or "-"
direction = str(od.get("direction") or "long").lower()
mt = od.get("monitor_type_display") or od.get("monitor_type") or ""
kst = od.get("key_signal_type") or ""
title = f"{sym} {_dir_label(direction)}"
bits = [x for x in (mt, kst) if x]
subtitle = " · ".join(bits) if bits else ""
entry = _safe_float(od.get("trigger_price"))
sl = _safe_float(od.get("stop_loss"))
tp = _safe_float(od.get("take_profit"))
return {
"id": od.get("id"),
"kind": "order",
"tab": "trade",
"title": title,
"subtitle": subtitle,
"symbol": sym,
"price_symbol": od.get("symbol") or sym,
"direction": direction,
"direction_label": _dir_label(direction),
"entry": entry,
"mark_price": None,
"contracts": _safe_float(od.get("order_amount")),
"tp_profit": None,
"float_pnl": None,
"stop_loss": sl,
"take_profit": tp,
"status": od.get("status") or "active",
}
OPTIONS_SOURCE_LABELS = {
"option": "纯期权",
"perp_options": "永期对冲",
"options_options": "期期对冲",
}
HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"})
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
"""根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id)."""
default = ("option", OPTIONS_SOURCE_LABELS["option"], None)
if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"):
return default
try:
row = conn.execute(
"""
SELECT p.plan_type, p.id
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.status IN ('opening', 'active', 'partial')
AND l.status = 'open'
AND l.inst_id = ?
ORDER BY p.id DESC
LIMIT 1
""",
(inst_id,),
).fetchone()
except Exception:
return default
if not row:
return default
d = _row_dict(row)
pt = str(d.get("plan_type") or "").strip()
try:
plan_id = int(d["id"]) if d.get("id") is not None else None
except (TypeError, ValueError):
plan_id = None
if pt in OPTIONS_SOURCE_LABELS and pt != "option":
return pt, OPTIONS_SOURCE_LABELS[pt], plan_id
return default
def _format_profit_exit_mult(mult: Any) -> str:
try:
n = float(mult)
except (TypeError, ValueError):
return "1倍"
if n <= 0:
return "1倍"
if abs(n - round(n)) < 1e-9:
return f"{int(round(n))}"
return f"{n:g}"
def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge:
rr = _safe_float(hedge.get("profit_rr"))
pid = hedge.get("plan_id")
if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
ot = str(hedge.get("opt_type") or opt_type).upper()
side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index"))
if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
parts: list[str] = []
tgt = _safe_float(p.get("target_index"))
if tgt is not None and tgt > 0:
side = "Put ≤" if opt_type == "P" else "Call ≥"
parts.append(f"{side} {tgt:g}")
if p.get("profit_exit_enabled"):
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
if parts:
return " · ".join(parts)
return ""
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl
pnl = None
try:
from lib.options.options_positions_lib import display_pnl_from_option_row
pnl = display_pnl_from_option_row(p)
except Exception:
pnl = None
pos = _safe_float(p.get("pos"))
exp_ms = p.get("exp_time_ms")
if exp_ms is None:
exp_ms = p.get("exp_time")
try:
exp_ms = int(float(exp_ms)) if exp_ms not in (None, "") else None
except (TypeError, ValueError):
exp_ms = None
if conn is not None:
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
else:
source_key, source_label, source_plan_id = "option", OPTIONS_SOURCE_LABELS["option"], None
return {
"id": inst,
"kind": "options",
"tab": "options",
"title": f"{inst} {label}",
"subtitle": f"张数 {pos if pos is not None else '-'}",
"inst_id": inst,
"opt_type": opt_type,
"opt_type_label": label,
"source": source_key,
"source_label": source_label,
"source_plan_id": source_plan_id,
"pos": pos,
"exp_time_ms": exp_ms,
"target_monitor": _format_options_target(p),
"pnl": round(pnl, 4) if pnl is not None else None,
}
def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]:
pid = plan.get("id")
underlying = plan.get("underlying") or "-"
plan_type = plan.get("plan_type") or ""
status = str(plan.get("status") or "")
summary = plan.get("contracts_summary") or ""
plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type)
active = status in HEDGE_ACTIVE_STATUSES
status_label = "进行中" if active else (status or "")
return {
"id": pid,
"kind": "hedge_plan",
"tab": "hedge_plan",
"title": f"对冲 #{pid} {underlying}",
"subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x),
"underlying": underlying,
"plan_type": plan_type,
"plan_type_label": plan_type_label,
"status": status,
"status_label": status_label,
"status_active": active,
"contracts_summary": summary,
}
def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]:
sym = kd.get("exchange_symbol") or kd.get("symbol") or "-"
direction = str(kd.get("direction") or "long").lower()
signal = kd.get("signal_type") or kd.get("key_signal_type") or kd.get("monitor_type") or ""
upper = _safe_float(kd.get("upper"))
lower = _safe_float(kd.get("lower"))
subtitle_parts = []
if signal:
subtitle_parts.append(str(signal))
if upper is not None or lower is not None:
subtitle_parts.append(
f"{upper if upper is not None else '-'} / 下{lower if lower is not None else '-'}"
)
return {
"id": kd.get("id"),
"kind": "key",
"tab": "key_monitor",
"title": f"{sym} {_dir_label(direction)}",
"subtitle": " · ".join(subtitle_parts),
"symbol": sym,
"direction": direction,
"direction_label": _dir_label(direction),
"upper": upper,
"lower": lower,
"status": kd.get("status") or "active",
}
def _format_trend_item(td: dict[str, Any]) -> dict[str, Any]:
sym = td.get("exchange_symbol") or td.get("symbol") or "-"
direction = str(td.get("direction") or "long").lower()
status = td.get("status") or "active"
entry = _safe_float(td.get("entry_price") or td.get("trigger_price"))
return {
"id": td.get("id"),
"kind": "trend",
"tab": "strategy",
"title": f"趋势回调 {sym} {_dir_label(direction)}",
"subtitle": f"状态 {status}",
"symbol": sym,
"direction": direction,
"direction_label": _dir_label(direction),
"entry": entry,
"status": status,
}
def _format_roll_item(rd: dict[str, Any]) -> dict[str, Any]:
sym = rd.get("exchange_symbol") or rd.get("symbol") or "-"
direction = str(rd.get("direction") or "long").lower()
status = rd.get("status") or "active"
return {
"id": rd.get("id"),
"kind": "roll",
"tab": "strategy",
"title": f"顺势加仓 {sym} {_dir_label(direction)}",
"subtitle": f"状态 {status}",
"symbol": sym,
"direction": direction,
"direction_label": _dir_label(direction),
"status": status,
}
def _table_exists(conn, name: str) -> bool:
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
(name,),
).fetchone()
return bool(row)
except Exception:
return False
def collect_orders(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "order_monitors"):
return []
rows = conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
return [_format_order_item(_row_dict(r)) for r in rows]
def collect_keys(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "key_monitors"):
return []
rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall()
return [_format_key_item(_row_dict(r)) for r in rows]
def collect_trends(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "trend_pullback_plans"):
return []
try:
rows = conn.execute(
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
).fetchall()
except Exception:
return []
return [_format_trend_item(_row_dict(r)) for r in rows]
def collect_rolls(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"):
return []
try:
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active' ORDER BY g.id DESC"""
).fetchall()
except Exception:
return []
return [_format_roll_item(_row_dict(r)) for r in rows]
def collect_hedge_plans(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "hedge_plans"):
return []
try:
from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans
rows: list[dict[str, Any]] = []
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
return [_format_hedge_item(p) for p in plans]
except Exception:
return []
def collect_options_items(
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
*,
conn=None,
) -> list[dict[str, Any]]:
if not callable(fetch_options_positions):
return []
try:
raw = fetch_options_positions() or []
except Exception:
return []
pe_map: dict[str, dict[str, Any]] = {}
tgt_map: dict[str, dict[str, Any]] = {}
hedge_map: dict[str, dict[str, Any]] = {}
if conn is not None:
try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {}
out: list[dict[str, Any]] = []
for p in raw:
if not isinstance(p, dict):
continue
row = dict(p)
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
pe = pe_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
hedge = hedge_map.get(inst)
if hedge:
row["hedge_plan_target"] = hedge
if not mon:
row["target_index"] = hedge.get("target_index")
out.append(_format_options_item(row, conn=conn))
return out
def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]:
"""优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1."""
raw: list[str] = []
for key in ("symbol", "exchange_symbol", "price_symbol"):
s = str(row.get(key) or "").strip()
if s and s not in raw:
raw.append(s)
swapish: list[str] = []
others: list[str] = []
for s in raw:
if ":" in s:
swapish.append(s)
continue
others.append(s)
if "/" in s:
base, quote = s.split("/", 1)
q = quote.split(":")[0].strip()
if base and q:
swapish.append(f"{base}/{q}:{q}")
out: list[str] = []
for s in swapish + others:
if s and s not in out:
out.append(s)
return out
def _resolve_contract_size(
row_or_sym: Any,
*,
get_contract_size: Optional[Callable[[str], Any]] = None,
) -> float:
if not callable(get_contract_size):
return 1.0
if isinstance(row_or_sym, dict):
candidates = _swap_symbol_candidates(row_or_sym)
else:
sym = str(row_or_sym or "").strip()
candidates = _swap_symbol_candidates({"symbol": sym}) if sym else []
for sym in candidates:
try:
cs = float(get_contract_size(sym) or 0)
if cs > 0:
return cs
except Exception:
continue
return 1.0
def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None:
"""按线性 U 本位补看板「盈利金额 / 浮盈」."""
direction = str(row.get("direction") or "long").lower()
entry = _safe_float(row.get("entry"))
contracts = _safe_float(row.get("contracts"))
tp = _safe_float(row.get("take_profit"))
if entry is None or contracts is None or contracts <= 0:
return
cs = float(contract_size) if contract_size and contract_size > 0 else 1.0
if mark is not None:
try:
from lib.market.position_metrics_lib import estimate_linear_swap_upnl_usdt
upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs)
if upnl is not None:
row["float_pnl"] = upnl
except Exception:
pass
if tp is not None and tp > 0:
try:
try:
d = (direction or "long").lower()
e, t, c, cs_f = float(entry), float(tp), float(contracts), float(cs)
profit = (t - e) * c * cs_f if d == "long" else (e - t) * c * cs_f
except Exception:
profit = None
if profit is not None:
row["tp_profit"] = round(float(profit), 2)
except Exception:
pass
def enrich_order_items_with_marks(
items: list[dict[str, Any]],
*,
get_price: Optional[Callable[[str], Any]] = None,
get_contract_size: Optional[Callable[[str], Any]] = None,
) -> list[dict[str, Any]]:
"""后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈."""
if not items:
return items
if not callable(get_price) and not callable(get_contract_size):
return items
out: list[dict[str, Any]] = []
for it in items:
row = dict(it)
# 标记价:先试 price_symbol,再试永续候选
mark = _safe_float(row.get("mark_price"))
if callable(get_price):
ordered: list[str] = []
for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row):
if s and s not in ordered:
ordered.append(s)
for sym in ordered:
try:
px = get_price(sym)
except Exception:
px = None
mark = _safe_float(px)
if mark is not None:
row["mark_price"] = mark
break
cs = _resolve_contract_size(row, get_contract_size=get_contract_size)
_fill_order_pnl_fields(row, mark=mark, contract_size=cs)
out.append(row)
return out
def build_instance_dashboard_payload(
conn,
*,
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
hedge_enabled: bool = False,
) -> dict[str, Any]:
orders = collect_orders(conn)
keys = collect_keys(conn)
options_items = collect_options_items(fetch_options_positions, conn=conn)
hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关
# hedge_enabled 仅影响「新建」入口,不隐藏已有仓
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return {
"ok": True,
"updated_at": now,
"orders": {
"title": "实盘下单",
"count": 0,
"items": [],
"tab": "options",
"removed": True,
},
"keys": {"title": "关键位监控", "count": len(keys), "items": keys, "tab": "key_monitor"},
"strategy": {
"title": "策略交易",
"count": 0,
"items": [],
"trends": [],
"rolls": [],
"tab": "strategy",
"removed": True,
},
"options": {
"title": "期权持仓",
"count": len(options_items),
"items": options_items,
"visible": len(options_items) > 0,
"tab": "options",
},
"hedge_plan": {
"title": "对冲计划",
"count": len(hedge_items),
"items": hedge_items,
"visible": len(hedge_items) > 0,
"tab": "hedge_plan",
},
}
@@ -0,0 +1,78 @@
"""注册实例数据看板:快照 GET + SSE + 手动刷新(对齐中控)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from flask import Flask, Response, jsonify, stream_with_context
def register_instance_dashboard_routes(
app: Flask,
*,
login_required: Callable,
get_db: Callable,
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
hedge_enabled: bool | Callable[[], bool] = False,
enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None,
) -> None:
from lib.instance.instance_dashboard_cache import instance_dashboard_store
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
def _hedge_on() -> bool:
if callable(hedge_enabled):
try:
return bool(hedge_enabled())
except Exception:
return False
return bool(hedge_enabled)
def _build() -> dict[str, Any]:
conn = get_db()
try:
payload = build_instance_dashboard_payload(
conn,
fetch_options_positions=fetch_options_positions,
hedge_enabled=_hedge_on(),
)
if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict):
items = list(payload["orders"].get("items") or [])
try:
enriched = enrich_orders(items) or items
except Exception:
enriched = items
payload["orders"]["items"] = enriched
payload["orders"]["count"] = len(enriched)
return payload
finally:
conn.close()
instance_dashboard_store.start(_build)
@app.route("/api/instance/dashboard")
@login_required
def api_instance_dashboard():
return jsonify(instance_dashboard_store.snapshot_dict())
@app.route("/api/instance/dashboard/stream")
@login_required
def api_instance_dashboard_stream():
return Response(
stream_with_context(instance_dashboard_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/instance/dashboard/refresh", methods=["POST"])
@login_required
def api_instance_dashboard_refresh():
instance_dashboard_store.request_refresh()
return jsonify(
{
"ok": True,
"dashboard_version": instance_dashboard_store.version,
}
)
+144
View File
@@ -0,0 +1,144 @@
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False,
"show_nav_account_ledger": False,
"show_nav_key_monitor": True,
"show_nav_trade": False, # 实盘下单界面已移除
"show_nav_strategy": False,
"show_nav_strategy_records": False,
"show_nav_records": False, # 永续交易记录与复盘已移除
"show_nav_stats": False,
"show_nav_risk_policy": True,
"show_nav_system_guide": False,
"show_nav_env_config": True,
"show_nav_options": True,
"show_nav_options_review": True,
"show_nav_hedge_plan": True,
"show_settings_transfer": True,
"show_settings_export": True,
"show_settings_password": True,
"show_settings_options_swap": True,
"show_settings_options_transfer": True,
}
DISPLAY_LABELS: dict[str, str] = {
"show_nav_dashboard": "数据看板",
"show_nav_account_ledger": "账户流水",
"show_nav_key_monitor": "关键位监控",
"show_nav_trade": "实盘下单",
"show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析",
"show_nav_risk_policy": "风控说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
"show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "资金划转",
"show_settings_export": "数据导出",
"show_settings_password": "账户密码修改",
"show_settings_options_swap": "期权币种兑换",
"show_settings_options_transfer": "期权资金划转",
}
NAV_TAB_ALLOWED: dict[str, str] = {
"dashboard": "show_nav_dashboard",
"account_ledger": "show_nav_account_ledger",
"key_monitor": "show_nav_key_monitor",
"trade": "show_nav_trade",
"strategy": "show_nav_strategy",
"strategy_records": "show_nav_strategy_records",
"records": "show_nav_records",
"stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
"options_review": "show_nav_options_review",
"hedge_plan": "show_nav_hedge_plan",
}
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
out = dict(DEFAULT_INSTANCE_DISPLAY)
if isinstance(raw, dict):
for key in DEFAULT_INSTANCE_DISPLAY:
if key in raw:
out[key] = bool(raw[key])
return out
def _load_from_conn(conn) -> dict[str, bool]:
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
merged: dict[str, Any] = {}
for key in DEFAULT_INSTANCE_DISPLAY:
sk = key
if sk in stored:
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
return normalize_display_prefs(merged)
def get_display_prefs(get_db: Callable) -> dict[str, bool]:
return with_db(get_db, _load_from_conn)
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
normalized = normalize_display_prefs(prefs)
def _save(conn):
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
runtime_set_many(conn, mapping)
return normalized
return with_db(get_db, _save)
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
prefs = get_display_prefs(get_db)
return {"display": prefs, "display_meta": display_meta_for_ui()}
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
prefs = normalize_display_prefs(display or {})
t = (tab or "").strip()
if t in ("trade", "records", "stats"):
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
key = NAV_TAB_ALLOWED.get(t)
if not key:
return True
return bool(prefs.get(key, True))
def display_meta_for_ui() -> list[dict[str, Any]]:
nav_keys = [
"show_nav_dashboard",
"show_nav_account_ledger",
"show_nav_key_monitor",
"show_nav_risk_policy",
"show_nav_system_guide",
"show_nav_env_config",
"show_nav_options",
"show_nav_options_review",
"show_nav_hedge_plan",
]
settings_keys = [
"show_settings_transfer",
"show_settings_export",
"show_settings_password",
"show_settings_options_swap",
"show_settings_options_transfer",
]
return [
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
]
+194
View File
@@ -0,0 +1,194 @@
"""embed 壳/片段:按 tab 裁剪 render_main_page 的数据加载,降内存与 API 压力."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
EMBED_STRATEGY_PAGES = frozenset()
_WIN_EPS = 1e-9
def env_truthy(raw: str | None, default: bool = False) -> bool:
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
ex = (exchange_key or "").strip().lower()
if ex and ex != "okx":
return True
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
@dataclass(frozen=True)
class EmbedRenderPlan:
exchange_capitals: bool
records_rows: bool
records_summary: bool
key_history: bool
key_list: bool
orders: bool
stats_bundle: bool
strategy: bool
orphan_live: bool
def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
if embed_mode not in ("fragment", "shell"):
return EmbedRenderPlan(
exchange_capitals=True,
records_rows=True,
records_summary=False,
key_history=True,
key_list=True,
orders=True,
stats_bundle=True,
strategy=True,
orphan_live=True,
)
is_shell = embed_mode == "shell"
is_strategy = page in EMBED_STRATEGY_PAGES
return EmbedRenderPlan(
exchange_capitals=is_shell,
records_rows=False, # 永续交易记录页已移除
# 顶栏常驻:设置/风控/env 也要统计,否则首屏 SSR 为 0 后软切 tab 不会重绘顶栏
records_summary=False,
key_history=page == "key_monitor",
key_list=page == "key_monitor" or is_strategy,
orders=False, # 实盘下单界面已移除;对冲永续下单不依赖本页数据
stats_bundle=False,
strategy=is_strategy,
orphan_live=False,
)
def profit_loss_ratio_from_averages(avg_win: float | None, avg_loss: float | None) -> float | None:
"""盈亏比 = 平均盈利 / |平均亏损|."""
if avg_win is None or avg_loss is None:
return None
try:
aw = float(avg_win)
al = float(avg_loss)
except (TypeError, ValueError):
return None
if al == 0:
return None
return round(aw / abs(al), 2)
def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float | None:
wins: list[float] = []
losses: list[float] = []
for row in trades or []:
if not isinstance(row, dict):
continue
try:
pnl = float(row.get("effective_pnl_amount") or row.get("pnl_amount") or 0)
except (TypeError, ValueError):
continue
if pnl > _WIN_EPS:
wins.append(pnl)
elif pnl < -_WIN_EPS:
losses.append(pnl)
avg_win = sum(wins) / len(wins) if wins else None
avg_loss = sum(losses) / len(losses) if losses else None
return profit_loss_ratio_from_averages(avg_win, avg_loss)
def options_funding_label(
funding_usdc: float | None,
funding_usdt: float | None = None,
) -> str:
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略."""
_ = funding_usdt
if funding_usdc is None:
return ""
try:
return f"{float(funding_usdc):.2f} USDC"
except (TypeError, ValueError):
return ""
def total_funds_usdt(
funding_usdt: float | None,
trading_usdt: float | None,
options_trading_usdc: float | None = None,
options_funding_usdc: float | None = None,
options_funding_usdt: float | None = None,
options_trading_usdt: float | None = None,
) -> float | None:
parts = [
funding_usdt,
trading_usdt,
options_funding_usdc,
options_funding_usdt,
options_trading_usdc,
options_trading_usdt,
]
if all(v is None for v in parts):
return None
try:
total = 0.0
for v in parts:
if v is not None:
total += float(v)
return round(total, 2)
except (TypeError, ValueError):
return None
def trade_records_summary(conn, start_bj: str, end_bj: str, tr_ts: str) -> dict[str, Any]:
"""顶栏统计用 COUNT,避免 embed 壳拉 1000 行交易记录."""
from lib.trade.trade_result_lib import sql_effective_pnl_expr
pnl_sql = sql_effective_pnl_expr()
row = conn.execute(
f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN {pnl_sql} > 0 THEN 1 ELSE 0 END) AS wins,
AVG(CASE WHEN {pnl_sql} > 0 THEN {pnl_sql} END) AS avg_win,
AVG(CASE WHEN {pnl_sql} < 0 THEN {pnl_sql} END) AS avg_loss
FROM trade_records
WHERE {tr_ts} >= ? AND {tr_ts} <= ?
AND COALESCE(result, '') != '错过'
AND COALESCE(reviewed_result, '') != '错过'
""",
(start_bj, end_bj),
).fetchone()
total = int(row["total"] or 0) if row else 0
wins = int(row["wins"] or 0) if row else 0
rate = round(wins / total * 100, 2) if total else 0
avg_win = float(row["avg_win"]) if row and row["avg_win"] is not None else None
avg_loss = float(row["avg_loss"]) if row and row["avg_loss"] is not None else None
return {
"records": [],
"total": total,
"rate": rate,
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
}
def header_trade_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]:
"""account_snapshot / 顶栏刷新:按当前列表窗返回总交易/胜率/盈亏比."""
from lib.common.history_window_lib import sql_list_time_field, utc_window_to_bj_sql_strings
start_bj, end_bj = utc_window_to_bj_sql_strings(
list_window["start_utc"], list_window["end_utc"], app_tz
)
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
summary = trade_records_summary(conn, start_bj, end_bj, tr_ts)
return {
"total": summary["total"],
"rate": summary["rate"],
"profit_loss_ratio": summary.get("profit_loss_ratio"),
}
def minimal_stats_bundle(reset_hour: int) -> dict[str, Any]:
return {"stats_reset_hour": reset_hour, "segments": []}
+221
View File
@@ -0,0 +1,221 @@
"""Embed shell: persistent chrome + tab content API (/embed, /api/embed/page/<tab>)."""
from __future__ import annotations
from lib.paths import embed_templates_dir
import os
from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, make_response, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = (
"dashboard",
"account_ledger",
"key_monitor",
"options",
"options_review",
"hedge_plan",
"risk_policy",
"system_guide",
"env_config",
"settings",
)
PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "options",
"/trade": "options", # 实盘下单界面已移除
"/records": "options_review", # 永续交易记录与复盘已移除
"/stats": "options",
"/dashboard": "dashboard",
"/account_ledger": "account_ledger",
"/key_monitor": "key_monitor",
"/options": "options",
"/options/review": "options_review",
"/hedge-plan": "hedge_plan",
"/risk_policy": "risk_policy",
"/system_guide": "system_guide",
"/env_config": "env_config",
"/settings": "settings",
}
ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = {
"gate": "order_monitor_rule_tips_gate.html",
"binance": "order_monitor_rule_tips_binance.html",
"okx": "order_monitor_rule_tips_okx.html",
}
def order_rule_tips_template(exchange_key: str) -> str:
ex = (exchange_key or "").strip().lower()
return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html")
def include_transfer_block(exchange_key: str) -> bool:
"""三所 standalone / embed 壳均在顶栏展示划转区块."""
return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE
def ui_open_guard_enabled(exchange_key: str) -> bool:
return (exchange_key or "").strip().lower() == "okx"
def ui_orphan_recovery_enabled(exchange_key: str) -> bool:
return (exchange_key or "").strip().lower() == "binance"
def path_to_embed_tab(path: str) -> str | None:
p = (path or "/").strip()
if not p.startswith("/"):
p = "/" + p
base = urlsplit(p).path.rstrip("/") or "/"
return PATH_TO_EMBED_TAB.get(base)
def embed_shell_enabled() -> bool:
raw = (os.getenv("EMBED_SHELL") or os.getenv("HUB_EMBED_SHELL") or "1").strip().lower()
return raw in ("1", "true", "yes", "on")
_SETTINGS_SUB_TABS = frozenset(
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
)
def redirect_to_embed_shell_if_enabled(page: str):
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
if not embed_shell_enabled():
return None
if (request.args.get("embed") or "").strip() == "1":
return None
if (request.path or "").rstrip("/") == "/embed":
return None
q = {k: v for k, v in request.args.items()}
# embed 的 tab=页面名;系统设置内页签用 settings_tab,避免 /settings?tab=transfer 被覆盖成 tab=settings
if (page or "").strip() == "settings":
sub = (q.get("settings_tab") or "").strip()
legacy = (q.get("tab") or "").strip()
if not sub and legacy in _SETTINGS_SUB_TABS:
q["settings_tab"] = legacy
q["tab"] = page
q["embed"] = "1"
return redirect("/embed?" + urlencode(q))
def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str:
"""embed=1 打开时:/trade → /embed?tab=trade&embed=1"""
if not embed_shell_enabled():
split = urlsplit(path or "/")
q = dict(parse_qsl(split.query, keep_blank_values=True))
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if q:
return f"{dest}?{urlencode(q)}"
return dest + "?embed=1"
split = urlsplit(path or "/")
tab = path_to_embed_tab(split.path)
q = dict(parse_qsl(split.query, keep_blank_values=True))
if tab:
if tab == "settings":
sub = (q.get("settings_tab") or "").strip()
legacy = (q.get("tab") or "").strip()
if not sub and legacy in _SETTINGS_SUB_TABS:
q["settings_tab"] = legacy
q["tab"] = tab
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
return f"/embed?{urlencode(q)}"
q["embed"] = "1"
ht = (hub_theme or q.get("hub_theme") or "").strip().lower()
if ht in ("light", "dark"):
q["hub_theme"] = ht
dest = split.path or "/"
if split.query:
dest += "?" + split.query
if "embed=1" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}embed=1"
if ht in ("light", "dark") and "hub_theme=" not in dest:
sep = "&" if "?" in dest else "?"
dest += f"{sep}hub_theme={ht}"
return dest
def attach_embed_templates(app: Flask, repo_root: str) -> None:
embed_dir = embed_templates_dir(repo_root)
if not os.path.isdir(embed_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(embed_dir)]
if existing is not None:
if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders
else:
loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders)
def register_embed_routes(
app: Flask,
login_required: Callable,
render_main_page_fn: Callable,
) -> None:
from lib.instance.instance_live_push_lib import register_instance_live_routes
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
register_instance_live_routes(app, login_required)
@login_required
@app.route("/embed")
def embed_shell_page():
tab = (request.args.get("tab") or "options").strip()
if tab not in EMBED_TABS:
tab = "options"
session["hub_embed_shell"] = True
resp = make_response(render_main_page_fn(tab, embed_mode="shell"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@login_required
@app.route("/api/embed/page/<tab>")
def api_embed_page(tab: str):
tab = (tab or "").strip()
if tab not in EMBED_TABS:
return jsonify({"ok": False, "msg": "unknown tab"}), 404
allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN")
if callable(allowed_fn) and not allowed_fn(tab):
return jsonify({"ok": False, "msg": "tab disabled"}), 403
html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response):
html = html.get_data(as_text=True)
resp = jsonify({"ok": True, "page": tab, "html": html})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
def pwa_app_name(exchange_key: str) -> str:
"""安装 App / 主屏幕显示名(各所独立标识)."""
ex = (exchange_key or "").strip().lower()
return {
"binance": "Binance 交易系统",
"okx": "OKX 交易系统",
"gate": "Gate 交易系统",
}.get(ex, "交易系统")
def embed_context_extras(exchange_key: str) -> dict:
return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key),
"ui_open_guard_enabled": ui_open_guard_enabled(exchange_key),
"ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key),
"pwa_app_name": pwa_app_name(exchange_key),
}
+127
View File
@@ -0,0 +1,127 @@
"""实例页:持仓未实现盈亏(实时盈亏)汇总."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from lib.market.position_metrics_lib import parse_position_unrealized_pnl
def position_row_contracts(pos: dict[str, Any]) -> float:
"""持仓张数:与三所 app 内 _position_row_effective_contracts 规则一致."""
if not isinstance(pos, dict):
return 0.0
info = pos.get("info") or {}
if not isinstance(info, dict):
info = {}
for val in (
pos.get("contracts"),
info.get("positionAmt"),
info.get("size"),
info.get("pos"),
info.get("availPos"),
):
if val is None or val == "":
continue
try:
x = abs(float(val))
if x > 0:
return x
except (TypeError, ValueError):
continue
return 0.0
def sum_unrealized_pnl_from_positions(positions: list[dict[str, Any]] | None) -> float | None:
total = 0.0
found = False
for p in positions or []:
if not isinstance(p, dict):
continue
if position_row_contracts(p) <= 1e-12:
continue
upnl = parse_position_unrealized_pnl(p)
if upnl is None:
continue
found = True
total += float(upnl)
return round(total, 2) if found else None
def _row_field(row: Any, key: str, default: str = "") -> str:
if row is None:
return default
try:
if hasattr(row, "keys") and key in row.keys():
val = row[key]
elif isinstance(row, dict):
val = row.get(key)
else:
val = None
except Exception:
val = None
return str(val or default).strip()
def sum_unrealized_pnl_from_metrics(
rows: list[dict[str, Any]] | list[Any],
get_metrics_fn: Callable[[str, str], dict[str, Any] | None],
) -> float | None:
"""按活跃监控单逐笔拉交易所 metrics 汇总(与持仓卡浮盈亏一致)."""
total = 0.0
found = False
for row in rows or []:
ex_sym = _row_field(row, "exchange_symbol")
sym = _row_field(row, "symbol")
direction = _row_field(row, "direction", "long").lower() or "long"
target = ex_sym or sym
if not target:
continue
metrics = get_metrics_fn(target, direction)
if not isinstance(metrics, dict):
continue
upnl = metrics.get("unrealized_pnl")
if upnl is None:
continue
try:
total += float(upnl)
found = True
except (TypeError, ValueError):
continue
return round(total, 2) if found else None
def fetch_unrealized_pnl(fetch_positions_fn: Callable[[], list[dict[str, Any]] | None]) -> float | None:
try:
return sum_unrealized_pnl_from_positions(fetch_positions_fn() or [])
except Exception:
return None
def resolve_instance_unrealized_pnl(
fetch_positions_fn: Callable[[], list[dict[str, Any]] | None],
active_rows: list[Any] | None,
get_metrics_fn: Callable[[str, str], dict[str, Any] | None] | None,
) -> float | None:
"""先全量持仓汇总,失败或无数据时回退到活跃监控单 metrics."""
total = fetch_unrealized_pnl(fetch_positions_fn)
if total is not None:
return total
if active_rows and get_metrics_fn:
return sum_unrealized_pnl_from_metrics(active_rows, get_metrics_fn)
return None
def merge_unrealized_pnl_components(*parts: float | None) -> float | None:
"""合并永续与期权等多路未实现盈亏(任一路有值即参与合计)."""
total = 0.0
found = False
for part in parts:
if part is None:
continue
try:
total += float(part)
found = True
except (TypeError, ValueError):
continue
return round(total, 2) if found else None
+122
View File
@@ -0,0 +1,122 @@
"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)."""
from __future__ import annotations
import json
import os
import queue
import threading
from collections.abc import Iterator
from typing import Any, Callable
from flask import Flask, Response, stream_with_context
INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5"))
INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25"))
class InstanceLivePush:
def __init__(self) -> None:
self._lock = threading.Lock()
self.version = 0
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push")
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._broadcast(close=True)
def tick(self, reason: str = "poll") -> int:
with self._lock:
self.version += 1
ver = self.version
payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False)
self._broadcast(payload)
return ver
def event_dict(self) -> dict[str, Any]:
return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC}
def _loop(self) -> None:
while not self._stop.is_set():
self.tick("poll")
if self._stop.wait(INSTANCE_LIVE_TICK_SEC):
break
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def _subscribe(self) -> queue.Queue[str | None]:
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
with self._lock:
self._subscribers.append(q)
return q
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q = self._subscribe()
try:
yield self._format_event(self.event_dict() | {"reason": "connect"})
while True:
try:
raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC)
except queue.Empty:
yield ": heartbeat\n\n"
continue
if raw is None:
break
yield f"event: live\ndata: {raw}\n\n"
finally:
self._unsubscribe(q)
@staticmethod
def _format_event(data: dict[str, Any]) -> str:
return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
instance_live_push = InstanceLivePush()
def notify_instance_balance_changed() -> int:
"""划转/兑换后通知 embed 壳拉最新资金快照."""
return instance_live_push.tick("balance")
def register_instance_live_routes(app: Flask, login_required: Callable) -> None:
instance_live_push.start()
@login_required
@app.route("/api/instance/live/stream")
def api_instance_live_stream():
return Response(
stream_with_context(instance_live_push.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+9
View File
@@ -0,0 +1,9 @@
"""Soft-nav helper (standalone: always False)."""
from __future__ import annotations
from flask import Request
def request_is_hub_soft_nav(req: Request | None = None) -> bool:
return False
+74
View File
@@ -0,0 +1,74 @@
"""PM2 重启当前实例(仅 Linux 部署环境)."""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
from typing import Any
def default_pm2_app_name(exchange_key: str) -> str:
mapping = {
"okx": "crypto_okx",
"binance": "crypto_binance",
"gate": "crypto_gate",
}
return mapping.get((exchange_key or "").strip().lower(), "crypto_okx")
def resolve_pm2_app_name(exchange_key: str) -> str:
explicit = (os.getenv("PM2_APP_NAME") or "").strip()
if explicit:
return explicit
return default_pm2_app_name(exchange_key)
def schedule_pm2_restart(app_name: str, *, delay_seconds: float = 1.0) -> dict[str, Any]:
"""延迟触发 PM2 重启,便于 HTTP 响应先返回(避免重启当前进程导致请求中断)."""
if not sys.platform.startswith("linux"):
return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": app_name}
if not (app_name or "").strip():
return {"ok": False, "msg": "未指定 PM2 应用名", "app": app_name}
app_name = app_name.strip()
try:
cmd = f"sleep {delay_seconds} && exec pm2 restart {shlex.quote(app_name)} --update-env"
subprocess.Popen(
["bash", "-c", cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return {"ok": True, "app": app_name, "msg": "重启已触发", "deferred": True}
except FileNotFoundError:
return {"ok": False, "msg": "未找到 bash 或 pm2 命令", "app": app_name}
except Exception as e:
return {"ok": False, "msg": str(e), "app": app_name}
def restart_instance_pm2(exchange_key: str, *, defer: bool = False) -> dict[str, Any]:
if not sys.platform.startswith("linux"):
return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None}
app_name = resolve_pm2_app_name(exchange_key)
if defer:
return schedule_pm2_restart(app_name)
try:
proc = subprocess.run(
["pm2", "restart", app_name, "--update-env"],
capture_output=True,
text=True,
timeout=120,
)
ok = proc.returncode == 0
return {
"ok": ok,
"app": app_name,
"msg": (proc.stdout or proc.stderr or "").strip()[:500],
"returncode": proc.returncode,
}
except FileNotFoundError:
return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name}
except subprocess.TimeoutExpired:
return {"ok": False, "msg": "pm2 restart 超时", "app": app_name}
except Exception as e:
return {"ok": False, "msg": str(e), "app": app_name}
+236
View File
@@ -0,0 +1,236 @@
"""实例「系统设置」页:从 .env 汇总风控说明(三所共用)."""
from __future__ import annotations
import os
from typing import Any, Optional
from lib.trade.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit,
max_active_positions_from_env,
mood_issues_daily_freeze_enabled,
risk_control_enabled,
)
from lib.trade.position_sizing_lib import is_full_margin_mode, load_position_sizing_mode, mode_label_zh
from lib.trade.trade_policy_lib import TradePolicy
def _env_bool(key: str, default: bool = False) -> bool:
raw = (os.getenv(key) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def _env_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except (TypeError, ValueError):
return default
def _env_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except (TypeError, ValueError):
return default
def _row(label: str, value: str, note: str = "") -> dict[str, str]:
return {"label": label, "value": value, "note": note}
def _on_off(enabled: bool) -> str:
return "开启" if enabled else "关闭"
def build_instance_settings_view(
*,
exchange_key: str,
exchange_display: str,
risk_status: Optional[dict[str, Any]] = None,
trade_policy: Optional[TradePolicy] = None,
data_export_version: int = 3,
open_guard_enabled: Optional[bool] = None,
) -> dict[str, Any]:
rs = risk_status or {}
sizing_mode = load_position_sizing_mode()
reset_hour = _env_int("TRADING_DAY_RESET_HOUR", 8)
hard_limit = _env_int("DAILY_OPEN_HARD_LIMIT", 0)
alert_threshold = _env_int("DAILY_OPEN_ALERT_THRESHOLD", 5)
force_close_on = _env_bool("FORCE_CLOSE_ENABLED", False)
force_close_hour = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
auto_transfer_on = _env_bool("AUTO_TRANSFER_ENABLED", False)
guard_on = (
bool(open_guard_enabled)
if open_guard_enabled is not None
else _env_bool("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", True)
)
sections: list[dict[str, Any]] = []
sections.append(
{
"title": "交易执行",
"rows": [
_row("最大同时持仓", str(max_active_positions_from_env())),
_row("计仓模式", mode_label_zh(sizing_mode)),
_row("以损定仓风险%", f"{_env_float('RISK_PERCENT', 2):g}%"),
_row("人工最低盈亏比", f">= {_env_float('MANUAL_MIN_PLANNED_RR', 1.4):g}:1"),
_row(
"交易日切点",
f"北京时间 {reset_hour}:00",
"新交易日统计与部分开仓限制以此为准",
),
_row(
"允许北京时间切点前开仓",
"已放开(允许开仓)" if not guard_on else "已限制(禁止开仓)",
f"关闭限制后,{reset_hour}:00 前也可斐波成交登记与人工下单;"
"环境配置「切点前禁止新开仓」(TRADING_DAY_RESET_OPEN_GUARD_ENABLED)",
),
_row(
"单日开仓提醒",
f"{alert_threshold}",
"达次数推送企业微信,不拦单",
),
_row(
"单日开仓硬上限",
str(hard_limit) if hard_limit > 0 else "未启用",
"达上限后禁止一切新开仓直至下一交易日" if hard_limit > 0 else "",
),
],
}
)
sections.append(
{
"title": "账户冷静期",
"rows": [
_row("风控总开关", _on_off(risk_control_enabled())),
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"),
_row(
"日亏损次数上限",
(
f"{daily_loss_limit()}"
if daily_loss_limit() > 0
else "未启用"
),
"平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0",
),
_row(
"复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()),
"复盘勾选心态标签可触发当日冻结",
),
],
}
)
sections.append(
{
"title": "关键位监控",
"rows": [
_row("模式", "仅关键支撑阻力提醒", "箱体/斐波/触价等程序自动单已移除"),
],
}
)
if force_close_on or (exchange_key or "").strip().lower() == "gate":
sections.append(
{
"title": "整点强制清仓",
"rows": [
_row("强制清仓", _on_off(force_close_on)),
_row(
"执行时刻",
f"北京时间 {force_close_hour}:00 起 {_env_int('FORCE_CLOSE_GRACE_MINUTES', 5)} 分钟内",
),
],
}
)
if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False):
api_key = (os.getenv("OKX_API_KEY") or "").strip()
sections.append(
{
"title": "期权设置",
"rows": [
_row("期权模块", "已启用"),
_row(
"账户 API",
f"已配置(…{api_key[-4:]})" if len(api_key) >= 4 else "未配置 OKX_API_*",
"永续与期权共用 OKX_API_*",
),
_row(
"说明",
"币种兑换与账户内划转到右侧「期权设置」卡片操作",
),
],
}
)
policy_note = ""
if trade_policy and getattr(trade_policy, "badge_text", ""):
policy_note = str(trade_policy.badge_text)
return {
"exchange_display": exchange_display,
"risk_status_label": str(rs.get("status_label") or "正常"),
"risk_status_reason": str(rs.get("reason") or "").strip(),
"can_trade": bool(rs.get("can_trade", True)),
"trade_policy_note": policy_note,
"sections": sections,
"data_export_version": int(data_export_version),
"show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"),
"options_settings_enabled": (exchange_key or "").strip().lower() == "okx"
and _env_bool("OKX_OPTIONS_ENABLED", False),
"auto_transfer_enabled": auto_transfer_on,
"auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8),
"auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30),
"auto_transfer_from": (os.getenv("AUTO_TRANSFER_FROM") or "funding").strip(),
"auto_transfer_to": (os.getenv("AUTO_TRANSFER_TO") or "swap").strip(),
}
def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[str, Any]) -> list[dict[str, str]]:
disp = display or {}
inst = instance_settings or {}
tabs: list[dict[str, str]] = [{"key": "nav", "title": "导航显示"}]
tabs.append({"key": "sim_funds", "title": "模拟资金"})
if disp.get("show_settings_password", True):
tabs.append({"key": "password", "title": "账户密码"})
if inst.get("show_transfer") and disp.get("show_settings_transfer", True):
tabs.append({"key": "transfer", "title": "永续划转"})
if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
tabs.append({"key": "options_swap", "title": "币种兑换"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
tabs.append({"key": "options_transfer", "title": "期权划转"})
return tabs
def settings_page_context(page: str, *, instance_base_dir: str | None = None, **kwargs: Any) -> dict[str, Any]:
p = (page or "").strip()
if p == "system_guide":
from lib.instance.instance_system_guide_lib import system_guide_template_context
return system_guide_template_context()
if p not in ("settings", "risk_policy", "env_config"):
return {}
display = kwargs.pop("display", None)
ctx: dict[str, Any] = {"instance_settings": build_instance_settings_view(**kwargs)}
if p == "settings":
ctx["settings_tabs"] = build_settings_tabs(display, ctx["instance_settings"])
if p == "env_config" and instance_base_dir:
from lib.env.env_ui_manifest import build_env_ui_payload
exchange_key = str(kwargs.get("exchange_key") or "")
env_path = os.path.join(instance_base_dir, ".env")
example_path = os.path.join(instance_base_dir, ".env.example")
ctx["env_config_groups"] = build_env_ui_payload(exchange_key, example_path, env_path)
return ctx
+170
View File
@@ -0,0 +1,170 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
from __future__ import annotations
import os
from functools import wraps
from typing import Any, Callable
from flask import jsonify, request, session
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_ui_manifest import (
build_env_ui_payload,
filter_updates_for_ui,
coerce_hedge_partial_close_with_manual,
validate_env_ui_updates,
)
from lib.env.env_schema import parse_env_example_schema
from lib.instance.instance_display_prefs_lib import (
display_meta_for_ui,
get_display_prefs,
normalize_display_prefs,
save_display_prefs,
tab_allowed,
)
from lib.instance.instance_pm2_lib import restart_instance_pm2
from lib.instance.runtime_config_lib import apply_env_reload
def _api_login_required():
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
logged_in = bool(session.get("logged_in"))
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
if auth_disabled or logged_in:
return f(*args, **kwargs)
return jsonify({"ok": False, "msg": "未登录"}), 401
return wrapped
return decorator
def register_instance_settings_routes(
app,
*,
get_db: Callable,
login_required_fn: Callable,
base_dir: str,
exchange_key: str,
username: str,
password: str,
) -> None:
env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required()
@app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth
def api_settings_display():
if request.method == "GET":
prefs = get_display_prefs(get_db)
return jsonify(
{
"ok": True,
"display": prefs,
"meta": display_meta_for_ui(),
}
)
body = request.get_json(silent=True) or {}
raw = body.get("display") if isinstance(body.get("display"), dict) else body
saved = save_display_prefs(get_db, raw)
return jsonify({"ok": True, "display": saved})
@app.route("/api/settings/env/meta", methods=["GET"])
@api_auth
def api_env_meta():
groups = build_env_ui_payload(exchange_key, example_path, env_path)
return jsonify({"ok": True, "groups": groups})
@app.route("/api/settings/env", methods=["GET", "POST"])
@api_auth
def api_settings_env():
if request.method == "GET":
groups = build_env_ui_payload(exchange_key, example_path, env_path)
return jsonify({"ok": True, "groups": groups})
body = request.get_json(silent=True) or {}
updates = body.get("values") if isinstance(body.get("values"), dict) else body
if not isinstance(updates, dict):
return jsonify({"ok": False, "msg": "无效请求体"}), 400
updates = filter_updates_for_ui(exchange_key, updates)
clean, errors = validate_env_ui_updates(exchange_key, example_path, updates)
if errors:
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups)
return jsonify(
{
"ok": True,
"changed_keys": changed,
"restart_required": reload_info.get("restart_required", False),
}
)
@app.route("/api/settings/password", methods=["POST"])
@api_auth
def api_change_password():
body = request.get_json(silent=True) or {}
old_password = str(body.get("old_password") or "")
new_username = str(body.get("new_username") or "").strip()
new_password = str(body.get("new_password") or "")
confirm = str(body.get("confirm_password") or "")
if not old_password or old_password != password:
return jsonify({"ok": False, "msg": "当前密码错误"}), 400
if len(new_password) < 6:
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
if new_password != confirm:
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
updates: dict[str, str] = {"APP_PASSWORD": new_password}
if new_username:
updates["APP_USERNAME"] = new_username
changed = apply_env_updates(env_path, updates)
groups = parse_env_example_schema(example_path)
apply_env_reload(env_path, get_db, changed, groups)
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
@app.route("/api/admin/restart", methods=["POST"])
@api_auth
def api_admin_restart():
result = restart_instance_pm2(exchange_key, defer=True)
code = 200 if result.get("ok") else 500
return jsonify({"ok": bool(result.get("ok")), **result}), code
@app.route("/api/admin/health", methods=["GET"])
def api_admin_health():
return jsonify({"ok": True, "status": "up"})
def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs)
app.config["INSTANCE_GET_DB"] = get_db
app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn
@app.route("/api/embed/tab_allowed/<tab>", methods=["GET"])
@api_auth
def api_tab_allowed(tab: str):
prefs = get_display_prefs(get_db)
return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)})
def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]:
from lib.instance.instance_settings_lib import settings_page_context
prefs = get_display_prefs(get_db)
ctx = {
"display": prefs,
"display_meta": display_meta_for_ui(),
**settings_page_context(page, display=prefs, **settings_kwargs),
}
return ctx
+69
View File
@@ -0,0 +1,69 @@
"""实例「系统说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
from __future__ import annotations
import re
from functools import lru_cache
from html import escape
from pathlib import Path
from typing import Any
from lib.common.markdown_html_lib import render_markdown_html
from lib.paths import REPO_ROOT
def system_guide_md_path() -> Path:
return REPO_ROOT / "docs" / "系统说明.md"
def _slugify(text: str) -> str:
raw = re.sub(r"<[^>]+>", "", text or "")
raw = re.sub(r"\s+", "-", raw.strip())
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
return raw[:80] or "section"
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
"""为 h2 注入 id,并收集目录(仅 h2)."""
toc: list[dict[str, str]] = []
used: dict[str, int] = {}
def repl(m: re.Match[str]) -> str:
inner = m.group(1)
base = _slugify(inner)
n = used.get(base, 0) + 1
used[base] = n
hid = base if n == 1 else f"{base}-{n}"
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
return f'<h2 id="{escape(hid)}">{inner}</h2>'
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
return out, toc
@lru_cache(maxsize=4)
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
path = Path(path_str)
try:
md_text = path.read_text(encoding="utf-8")
except OSError:
md_text = "# 系统说明缺失\n\n未找到 `docs/系统说明.md`。"
body = render_markdown_html(md_text)
body, toc = _inject_h2_ids(body)
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
def load_system_guide_payload() -> dict[str, Any]:
path = system_guide_md_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
return dict(_load_payload_cached(mtime_ns, str(path)))
def system_guide_template_context() -> dict[str, Any]:
payload = load_system_guide_payload()
return {
"system_guide_html": payload.get("html") or "",
"system_guide_toc": payload.get("toc") or [],
}
+93
View File
@@ -0,0 +1,93 @@
"""复盘自动 K 线:后台线程生成,避免 /add_journal 同步卡住."""
from __future__ import annotations
import logging
import threading
from typing import Any, Callable
logger = logging.getLogger(__name__)
def schedule_journal_exchange_chart(
*,
entry_id: str,
exchange_symbol: str,
title_prefix: str,
journal_tfs: list[str],
journal_limit: int,
marker_payload: dict[str, Any],
upload_folder: str,
generate_chart_fn: Callable[..., str | None],
get_db_fn: Callable[[], Any],
) -> None:
"""提交后立即返回;线程内画图并写回 journal_entries.image."""
entry_id = str(entry_id or "").strip()
if not entry_id or not callable(generate_chart_fn) or not callable(get_db_fn):
return
tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()]
if not tfs:
return
def _run() -> None:
try:
chart_fname = f"journal_{entry_id}.png"
saved = generate_chart_fn(
exchange_symbol,
title_prefix,
timeframes=tfs,
limit=journal_limit,
out_dir=upload_folder,
filename=chart_fname,
filename_prefix="journal",
marker_payload=marker_payload,
marker_timeframes={x.lower() for x in tfs},
layout="vertical",
)
if not saved:
logger.warning("journal chart async empty entry_id=%s", entry_id)
return
conn = get_db_fn()
try:
conn.execute(
"UPDATE journal_entries SET image=? WHERE id=?",
(saved, entry_id),
)
conn.commit()
finally:
conn.close()
except Exception:
logger.exception("journal chart async failed entry_id=%s", entry_id)
threading.Thread(
target=_run,
name=f"journal-chart-{entry_id[:8]}",
daemon=True,
).start()
def request_wants_journal_ajax(request: Any) -> bool:
"""XHR / Accept:json / form ajax=1 → 返回 JSON,避免整页刷新."""
xrw = str(getattr(request, "headers", {}).get("X-Requested-With") or "").lower()
if xrw == "xmlhttprequest":
return True
form = getattr(request, "form", None)
if form is not None:
raw = str(form.get("ajax") or "").strip().lower()
if raw in ("1", "true", "yes", "on"):
return True
accept = str(getattr(request, "headers", {}).get("Accept") or "").lower()
if "application/json" in accept and accept.strip().startswith("application/json"):
return True
return False
def journal_ajax_or_flash_error(request: Any, msg: str, *, redirect_fn: Callable[[], Any]):
"""校验失败:AJAX 返回 JSON,否则 flash + 跳转."""
from flask import flash, jsonify
if request_wants_journal_ajax(request):
return jsonify({"ok": False, "msg": msg}), 400
flash(msg)
return redirect_fn()
+452
View File
@@ -0,0 +1,452 @@
"""交易复盘 / 订单 K 线拼图(Binance / Gate / OKX 共用)."""
import math
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
Image = None # type: ignore
ImageDraw = None # type: ignore
ImageFont = None # type: ignore
JOURNAL_CHART_TF_CHOICES = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1d")
JOURNAL_CHART_DEFAULT_TF1 = "15m"
JOURNAL_CHART_DEFAULT_TF2 = "1h"
JOURNAL_CHART_DEFAULT_LIMIT = 300
JOURNAL_CHART_LIMIT_MIN = 50
JOURNAL_CHART_LIMIT_MAX = 500
JOURNAL_CHART_ANCHOR_CLOSE = "close"
JOURNAL_CHART_ANCHOR_NOW = "now"
JOURNAL_CHART_DEFAULT_ANCHOR = JOURNAL_CHART_ANCHOR_CLOSE
def _load_font(size):
if not ImageFont:
return None
for name in ("msyh.ttc", "Microsoft YaHei.ttf", "arial.ttf", "Arial.ttf"):
try:
return ImageFont.truetype(name, size)
except Exception:
continue
try:
return ImageFont.load_default()
except Exception:
return None
def ohlcv_to_rows(ohlcv):
rows = []
for bar in ohlcv or []:
if not bar or len(bar) < 6:
continue
try:
rows.append(
{
"ts": int(bar[0]),
"o": float(bar[1]),
"h": float(bar[2]),
"l": float(bar[3]),
"c": float(bar[4]),
"v": float(bar[5]),
}
)
except Exception:
continue
return rows
def marker_tag_label(tag):
t = str(tag or "").strip().upper()
if t == "ENTRY":
return "开仓"
if t == "EXIT":
return "平仓"
if t == "STOP":
return "止损"
return str(tag or "")
def pick_marker_point(rows, target_ts_ms, target_price=None):
if not rows or target_ts_ms is None:
return None, None
idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
if target_price is not None:
try:
p = float(target_price)
if p > 0:
return idx, p
except Exception:
pass
return idx, float(rows[idx]["c"])
def parse_positive_price(raw):
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
p = float(s)
return p if p > 0 else None
except (TypeError, ValueError):
return None
def parse_journal_chart_anchor(raw):
s = str(raw or "").strip().lower()
if s in (JOURNAL_CHART_ANCHOR_NOW, "current", "当前", "当前时间"):
return JOURNAL_CHART_ANCHOR_NOW
return JOURNAL_CHART_ANCHOR_CLOSE
def parse_journal_chart_limit(raw, fallback=None):
fb = int(fallback if fallback is not None else JOURNAL_CHART_DEFAULT_LIMIT)
try:
n = int(str(raw or "").strip() or fb)
except (TypeError, ValueError):
n = fb
return max(JOURNAL_CHART_LIMIT_MIN, min(JOURNAL_CHART_LIMIT_MAX, n))
def normalize_chart_timeframe(raw):
tf = str(raw or "").strip().lower()
if tf in JOURNAL_CHART_TF_CHOICES:
return tf
return ""
def timeframe_period_ms(tf):
s = (tf or "").strip().lower()
if s.endswith("m"):
try:
return int(s[:-1]) * 60 * 1000
except ValueError:
pass
if s.endswith("h"):
try:
return int(s[:-1]) * 3600 * 1000
except ValueError:
pass
if s.endswith("d"):
try:
return int(s[:-1]) * 86400 * 1000
except ValueError:
pass
return 300000
def _to_int_ms(value):
if value is None:
return None
try:
v = int(value)
return v if v > 0 else None
except (TypeError, ValueError):
return None
def trade_review_fetch_window(entry_ts_ms, exit_ts_ms, timeframe, limit, anchor=None, now_ms=None):
"""
复盘 K 线窗口(anchor=close):
- 有开/平仓:从开仓前若干根起,到平仓 K 线止(覆盖整笔交易 + 入场前背景)
- 仅开仓:以开仓时间为终点向前 limit
- 仅平仓:以平仓时间为终点向前 limit
anchor=now:以当前时间为终点向前 limit (可看平仓后走势)
"""
period = timeframe_period_ms(timeframe)
lim = max(2, int(limit))
entry_ms = _to_int_ms(entry_ts_ms)
exit_ms = _to_int_ms(exit_ts_ms)
anch = (anchor or JOURNAL_CHART_DEFAULT_ANCHOR).strip().lower()
if anch == JOURNAL_CHART_ANCHOR_NOW:
end_ms = _to_int_ms(now_ms)
if not end_ms:
return None
since_ms = end_ms - period * (lim + 10)
return {
"since_ms": since_ms,
"end_ms": end_ms,
"window_start_ms": since_ms,
"fetch_limit": lim + 20,
"display_limit": lim,
}
if entry_ms and exit_ms:
if exit_ms < entry_ms:
entry_ms, exit_ms = exit_ms, entry_ms
span_bars = max(1, (exit_ms - entry_ms) // period + 1)
pre_bars = max(40, min(120, lim // 3))
need = span_bars + pre_bars
fetch_limit = min(JOURNAL_CHART_LIMIT_MAX, max(lim, need + 15))
since_ms = entry_ms - period * pre_bars
return {
"since_ms": since_ms,
"end_ms": exit_ms,
"window_start_ms": since_ms,
"fetch_limit": fetch_limit,
"display_limit": lim,
}
if entry_ms:
end_ms = entry_ms
since_ms = end_ms - period * (lim + 10)
return {
"since_ms": since_ms,
"end_ms": end_ms,
"window_start_ms": since_ms,
"fetch_limit": lim + 20,
"display_limit": lim,
}
if exit_ms:
end_ms = exit_ms
since_ms = end_ms - period * (lim + 10)
return {
"since_ms": since_ms,
"end_ms": end_ms,
"window_start_ms": since_ms,
"fetch_limit": lim + 20,
"display_limit": lim,
}
return None
def trim_rows_for_trade_review(rows, window):
if not window:
return list(rows or [])
start_ms = int(window["window_start_ms"])
end_ms = int(window["end_ms"])
lim = int(window["display_limit"])
filt = [r for r in (rows or []) if start_ms <= int(r["ts"]) <= end_ms]
if len(filt) > lim:
filt = filt[-lim:]
return filt
def parse_journal_chart_timeframes(tf1, tf2, fallback_tfs=None):
"""复盘表单:最多两个周期,去重保序."""
out = []
for raw in (tf1, tf2):
tf = normalize_chart_timeframe(raw)
if tf and tf not in out:
out.append(tf)
if out:
return out[:2]
fb = [normalize_chart_timeframe(x) for x in (fallback_tfs or (JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2))]
fb = [x for x in fb if x]
return fb[:2] if fb else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
def marker_points_for_timeframe(rows, marker_payload):
points = []
if not marker_payload or not rows:
return points
entry_idx, entry_price = pick_marker_point(
rows, marker_payload.get("entry_ts_ms"), marker_payload.get("entry_price")
)
exit_idx, exit_price = pick_marker_point(
rows, marker_payload.get("exit_ts_ms"), marker_payload.get("exit_price")
)
if entry_idx is not None and entry_price is not None:
points.append({"idx": entry_idx, "price": entry_price, "tag": "ENTRY"})
if exit_idx is not None and exit_price is not None:
points.append({"idx": exit_idx, "price": exit_price, "tag": "EXIT"})
return points
def price_levels_from_marker_payload(marker_payload):
levels = []
if not marker_payload:
return levels
sl = parse_positive_price(marker_payload.get("stop_loss_price"))
if sl is not None:
levels.append({"price": sl, "label": "止损", "color": (255, 152, 0)})
return levels
def render_candles_subplot(
rows,
title,
width,
height,
bg_rgb=(255, 255, 255),
marker_points=None,
price_levels=None,
):
if not Image or not ImageDraw:
raise RuntimeError("缺少依赖:Pillow(pip install Pillow)")
img = Image.new("RGB", (width, height), bg_rgb)
draw = ImageDraw.Draw(img)
font = _load_font(14)
small = _load_font(12)
pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
plot_w = max(10, width - pad_l - pad_r)
plot_h = max(10, height - pad_t - pad_b)
header_bg = (245, 247, 250)
draw.rectangle((0, 0, width, pad_t), fill=header_bg)
if font:
draw.text((10, 6), title, fill=(25, 35, 60), font=font)
else:
draw.text((10, 6), title, fill=(25, 35, 60))
if not rows:
if small:
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
else:
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
return img
lo = min(r["l"] for r in rows)
hi = max(r["h"] for r in rows)
for pl in price_levels or []:
try:
p = float(pl.get("price"))
if p > 0:
lo = min(lo, p)
hi = max(hi, p)
except (TypeError, ValueError):
pass
if hi <= lo:
hi = lo + 1e-12
n = len(rows)
marker_by_idx = {}
for mp in marker_points or []:
try:
idx = int(mp.get("idx"))
except Exception:
continue
if idx < 0 or idx >= n:
continue
marker_by_idx.setdefault(idx, []).append(mp)
x0 = pad_l
for i, r in enumerate(rows):
x1 = pad_l + int((i + 1) * plot_w / n)
x_mid = (x0 + x1) // 2
wick_x = x_mid
y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
top = min(y_open, y_close)
bot = max(y_open, y_close)
up = r["c"] >= r["o"]
wick_color = (120, 120, 120)
edge_color = (20, 20, 20)
draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
body_w = max(1, (x1 - x0) - 2)
left = x0 + 1
if bot - top < 2:
mid = (top + bot) // 2
draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
else:
if up:
draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
else:
draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
for j, mp in enumerate(marker_by_idx.get(i, [])):
tag = str(mp.get("tag") or "")
label = marker_tag_label(tag)
m_price = float(mp.get("price") or r["c"])
y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
x_draw = int(x_mid + x_off)
if tag == "ENTRY":
m_color = (0, 195, 95)
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
text_y = y_m - 36
else:
m_color = (235, 65, 65)
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
text_y = y_m + 12
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
draw.polygon(tri, fill=m_color)
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
if font:
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
else:
draw.text((x_draw + 8, text_y), label, fill=m_color)
x0 = x1
x_right = pad_l + plot_w
for pl in price_levels or []:
try:
p = float(pl.get("price"))
except (TypeError, ValueError):
continue
if p <= 0:
continue
y_sl = pad_t + int((hi - p) / (hi - lo) * plot_h)
color = tuple(pl.get("color") or (255, 152, 0))
label = str(pl.get("label") or "止损")
for xx in range(pad_l, x_right, 10):
draw.line((xx, y_sl, min(xx + 6, x_right), y_sl), fill=color, width=2)
if font:
draw.text((x_right - 72, y_sl - 18), label, fill=color, font=small or font)
else:
draw.text((x_right - 72, y_sl - 18), label, fill=color)
if len(marker_points or []) >= 2:
try:
entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
if entry is not None and exitp is not None:
ex_i, ex_p = int(entry["idx"]), float(entry["price"])
xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
except Exception:
pass
if small:
draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
return img
def compose_chart_panels(panels, layout="grid", cell_w=980, cell_h=520, gap=10):
if not panels or not Image:
return None
if layout == "vertical":
cols = 1
rows_n = len(panels)
else:
cols = 2
rows_n = int(math.ceil(len(panels) / cols))
w = cols * cell_w + (cols - 1) * gap
h = rows_n * cell_h + (rows_n - 1) * gap
out = Image.new("RGB", (w, h), (255, 255, 255))
idx = 0
for r in range(rows_n):
for c in range(cols):
if idx >= len(panels):
break
x = c * (cell_w + gap)
y = r * (cell_h + gap)
out.paste(panels[idx], (x, y))
idx += 1
if ImageDraw and layout != "vertical" and rows_n >= 1:
draw_out = ImageDraw.Draw(out)
line_col = (220, 225, 232)
x_mid = cell_w + gap // 2
if w > x_mid >= 0:
draw_out.line((x_mid, 0, x_mid, h), fill=line_col, width=2)
for rr in range(1, rows_n):
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
if 0 <= y_mid <= h:
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
elif ImageDraw and layout == "vertical" and rows_n >= 2:
draw_out = ImageDraw.Draw(out)
line_col = (220, 225, 232)
for rr in range(1, rows_n):
y_mid = rr * cell_h + (rr - 1) * gap + gap // 2
if 0 <= y_mid <= h:
draw_out.line((0, y_mid, w, y_mid), fill=line_col, width=2)
return out
+53
View File
@@ -0,0 +1,53 @@
"""复盘表单:下单类型与开仓类型校验(三所共用)."""
from __future__ import annotations
from typing import Optional, Sequence, Tuple
from lib.trade.trade_labels_lib import (
JOURNAL_ORDER_TYPE_OPTIONS,
STRATEGY_ENTRY_REASON_OPTIONS,
normalize_journal_order_type,
)
from lib.trade.entry_model_lib import (
TRADE_STYLE_FALLBACK_ENTRY_REASONS,
normalize_review_entry_reason,
)
_LEGACY_JOURNAL_ENTRY_REASONS: Tuple[str, ...] = (
*TRADE_STYLE_FALLBACK_ENTRY_REASONS,
*STRATEGY_ENTRY_REASON_OPTIONS,
)
def normalize_journal_entry_reason(
raw: Optional[str],
allowed: Sequence[str],
*,
allow_legacy: bool = False,
) -> str:
s = normalize_review_entry_reason(raw, allowed)
if s:
return s
if not allow_legacy:
return ""
legacy = (raw or "").strip()
if legacy in _LEGACY_JOURNAL_ENTRY_REASONS:
return legacy
return ""
def journal_entry_reason_valid(raw: Optional[str], allowed: Sequence[str]) -> bool:
return bool(normalize_journal_entry_reason(raw, allowed, allow_legacy=False))
def journal_order_type_valid(raw: Optional[str]) -> bool:
return bool(normalize_journal_order_type(raw))
def normalize_journal_direction(raw: Optional[str]) -> str:
s = (raw or "").strip().lower()
if s in ("long", "buy", "", "做多"):
return "long"
if s in ("short", "sell", "", "做空"):
return "short"
return ""
+208
View File
@@ -0,0 +1,208 @@
"""复盘记录:多周期截图上传,存储与读取(三所共用)."""
from __future__ import annotations
import json
import os
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
_JOURNAL_SLOT_FILE_RE = re.compile(
r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
re.I,
)
def journal_upload_field_name(tf: str) -> str:
return f"screenshot_{tf}"
def uploaded_screenshot_field_name(tf: str) -> str:
return f"uploaded_screenshot_{tf}"
def normalize_journal_draft_id(raw: Any) -> Optional[str]:
s = str(raw or "").strip().lower()
if _JOURNAL_DRAFT_ID_RE.match(s):
return s
return None
def _safe_ext(filename: str) -> str:
ext = os.path.splitext(str(filename or ""))[1].lower()
return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png"
def build_journal_slot_filename(
entry_id: str,
tf: str,
ext: str,
*,
secure_filename_fn: Callable[[str], str],
) -> str:
ext = ext if ext.startswith(".") else f".{ext}"
ext = _safe_ext(f"x{ext}")
fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}")
return fname or ""
def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool:
fn = os.path.basename(str(filename or "").strip())
if not fn or fn != str(filename or "").strip():
return False
m = _JOURNAL_SLOT_FILE_RE.match(fn)
if not m:
return False
return m.group(1) == entry_id.lower() and m.group(2) == tf
def save_journal_slot_file(
file,
entry_id: str,
tf: str,
upload_folder: str,
*,
secure_filename_fn: Callable[[str], str],
) -> Optional[Dict[str, str]]:
if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder:
return None
if not file or not getattr(file, "filename", None):
return None
ext = _safe_ext(file.filename)
fname = build_journal_slot_filename(
entry_id, tf, ext, secure_filename_fn=secure_filename_fn
)
if not fname:
return None
os.makedirs(upload_folder, exist_ok=True)
path = os.path.join(upload_folder, fname)
file.save(path)
return {"tf": tf, "file": fname}
def collect_journal_slot_images(
form,
files,
entry_id: str,
upload_folder: str,
*,
secure_filename_fn: Callable[[str], str],
) -> List[Dict[str, str]]:
"""优先使用即时上传 hidden 字段;否则回退到表单 multipart."""
saved: List[Dict[str, str]] = []
if not entry_id or not upload_folder:
return saved
for tf in JOURNAL_UPLOAD_TFS:
pre = ""
if form is not None:
pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip()
if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf):
path = os.path.join(upload_folder, os.path.basename(pre))
if os.path.isfile(path):
saved.append({"tf": tf, "file": os.path.basename(pre)})
continue
f = files.get(journal_upload_field_name(tf)) if files else None
item = save_journal_slot_file(
f,
entry_id,
tf,
upload_folder,
secure_filename_fn=secure_filename_fn,
)
if item:
saved.append(item)
return saved
def save_journal_slot_uploads(
files,
entry_id: str,
upload_folder: str,
*,
secure_filename_fn: Callable[[str], str],
) -> List[Dict[str, str]]:
"""保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]."""
return collect_journal_slot_images(
None,
files,
entry_id,
upload_folder,
secure_filename_fn=secure_filename_fn,
)
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
if not items:
return None
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
def parse_images_json(raw: Any) -> List[Dict[str, str]]:
if not raw:
return []
if isinstance(raw, list):
data = raw
else:
try:
data = json.loads(str(raw))
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(data, list):
return []
out: List[Dict[str, str]] = []
for item in data:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if file:
out.append({"tf": tf, "file": file})
return out
def primary_journal_image(
manual_images: Sequence[Mapping[str, str]],
*,
fallback: Optional[str] = None,
) -> Optional[str]:
if manual_images:
return str(manual_images[0].get("file") or "").strip() or None
return fallback
def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]:
"""API 输出:解析 images_json,兼容旧单图 image 字段."""
images = parse_images_json(item.get("images_json"))
if not images and item.get("image"):
images = [{"tf": "", "file": str(item["image"]).strip()}]
item["images"] = images
return item
def journal_image_paths(row: Any, upload_folder: str) -> List[str]:
"""删除 / AI 附图:收集本条复盘所有本地图片路径(去重)."""
upload_folder = os.path.abspath(upload_folder or "")
paths: List[str] = []
seen = set()
def _add(name: Optional[str]) -> None:
if not name:
return
p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
try:
keys = row.keys() if hasattr(row, "keys") else ()
except Exception:
keys = ()
if "images_json" in keys and row["images_json"]:
for img in parse_images_json(row["images_json"]):
_add(img.get("file"))
if "image" in keys:
_add(row["image"])
return paths
+43
View File
@@ -0,0 +1,43 @@
"""复盘截图即时上传 API(三所共用)."""
from __future__ import annotations
from typing import Any, Callable, Dict, Tuple
from lib.instance.journal_images_lib import (
JOURNAL_UPLOAD_TFS,
normalize_journal_draft_id,
save_journal_slot_file,
)
def handle_journal_upload_slot(
request: Any,
*,
upload_folder: str,
secure_filename_fn: Callable[[str], str],
) -> Tuple[Dict[str, Any], int]:
"""POST multipart: journal_draft_id, tf, file → {ok, file}."""
draft_id = normalize_journal_draft_id(
request.form.get("journal_draft_id") if request.form else None
)
tf = str((request.form.get("tf") if request.form else None) or "").strip()
if not draft_id:
return {"ok": False, "error": "invalid draft_id"}, 400
if tf not in JOURNAL_UPLOAD_TFS:
return {"ok": False, "error": "invalid tf"}, 400
f = request.files.get("file") if request.files else None
if not f or not getattr(f, "filename", None):
return {"ok": False, "error": "no file"}, 400
item = save_journal_slot_file(
f,
draft_id,
tf,
upload_folder,
secure_filename_fn=secure_filename_fn,
)
if not item:
return {"ok": False, "error": "save failed"}, 500
return {"ok": True, "tf": tf, "file": item["file"]}, 200
+66
View File
@@ -0,0 +1,66 @@
"""注册 /api/trade_records(三所共用)."""
from __future__ import annotations
from typing import Any, Callable
from flask import Flask, jsonify, request
def register_trade_records_api(
app: Flask,
*,
login_required: Callable,
get_db: Callable,
list_window_from_request: Callable[[], dict[str, Any]],
utc_window_to_bj_sql_strings: Callable[..., tuple[str, str]],
sql_list_time_field: Callable[..., str],
to_effective_trade_dict: Callable[[Any], dict[str, Any]],
filter_trade_records_excluding_miss: Callable[[list], list],
app_tz: Any,
format_price_fn: Callable[[Any, Any], str] | None = None,
sync_exchange_pnl_fn: Callable[[Any], Any] | None = None,
) -> None:
"""
sync_exchange_pnl_fn(conn): 可选,列表前节流回填交易所已实现盈亏.
中控只走本 API,不经实例整页渲染,必须在此触发,否则盈亏U会一直显示.
"""
from lib.instance.records_list_lib import list_trade_records_page
@app.route("/api/trade_records")
@login_required
def api_trade_records():
win = list_window_from_request()
start_bj, end_bj = utc_window_to_bj_sql_strings(
win["start_utc"], win["end_utc"], app_tz
)
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
try:
limit = int(request.args.get("limit") or 5)
except (TypeError, ValueError):
limit = 5
try:
offset = int(request.args.get("offset") or 0)
except (TypeError, ValueError):
offset = 0
conn = get_db()
try:
if sync_exchange_pnl_fn is not None:
try:
sync_exchange_pnl_fn(conn)
except Exception:
pass
payload = list_trade_records_page(
conn,
start_bj,
end_bj,
tr_ts=tr_ts,
to_effective_fn=to_effective_trade_dict,
filter_fn=filter_trade_records_excluding_miss,
limit=limit,
offset=offset,
format_price_fn=format_price_fn,
)
return jsonify(payload)
finally:
conn.close()
+72
View File
@@ -0,0 +1,72 @@
"""交易记录列表分页(三所 /records 共用)."""
from __future__ import annotations
from typing import Any, Callable, Optional
def enrich_trade_price_displays(
item: dict[str, Any],
format_price_fn: Optional[Callable[[Any, Any], str]] = None,
) -> dict[str, Any]:
"""为成交/止损/止盈补交易所精度展示字段(供交易记录表直接渲染)."""
if not format_price_fn or not isinstance(item, dict):
return item
sym = item.get("symbol")
stop_show = item.get("display_open_stop_loss")
if stop_show in (None, ""):
stop_show = item.get("initial_stop_loss")
if stop_show in (None, ""):
stop_show = item.get("stop_loss")
tp_show = item.get("effective_take_profit")
if tp_show in (None, ""):
tp_show = item.get("take_profit")
try:
item["trigger_price_display"] = format_price_fn(sym, item.get("trigger_price"))
item["stop_loss_display"] = format_price_fn(sym, stop_show)
item["take_profit_display"] = format_price_fn(sym, tp_show)
except Exception:
pass
return item
def list_trade_records_page(
conn: Any,
start_bj: str,
end_bj: str,
*,
tr_ts: str,
to_effective_fn: Callable[[Any], dict[str, Any]],
filter_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]],
limit: int = 5,
offset: int = 0,
fetch_cap: int = 1000,
format_price_fn: Optional[Callable[[Any, Any], str]] = None,
) -> dict[str, Any]:
"""按列表窗拉取、enrich、过滤「错过」后分页."""
limit = max(1, min(100, int(limit or 5)))
offset = max(0, int(offset or 0))
raw_records = conn.execute(
f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? "
f"ORDER BY id DESC LIMIT ?",
(start_bj, end_bj, int(fetch_cap)),
).fetchall()
records = filter_fn([to_effective_fn(r) for r in raw_records])
total = len(records)
pages = max(1, (total + limit - 1) // limit) if total else 1
page = (offset // limit) + 1 if limit else 1
if page > pages:
page = pages
offset = (page - 1) * limit
items = records[offset : offset + limit]
if format_price_fn is not None:
items = [enrich_trade_price_displays(dict(it), format_price_fn) for it in items]
return {
"ok": True,
"items": items,
"total": total,
"limit": limit,
"offset": offset,
"page": page,
"pages": pages,
}
+62
View File
@@ -0,0 +1,62 @@
"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ."""
from __future__ import annotations
import os
from typing import Callable, Optional
from lib.env.env_file_lib import load_env_file_into_environ
from lib.instance.runtime_settings_lib import runtime_get, with_db
ENV_OVERRIDE_PREFIX = "env."
def runtime_env_key(name: str) -> str:
return ENV_OVERRIDE_PREFIX + name
def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]:
def _read(conn):
v = runtime_get(conn, runtime_env_key(key))
return v
try:
v = with_db(get_db, _read)
if v is not None:
return v
except Exception:
pass
raw = os.getenv(key)
if raw is None or raw == "":
return default
return raw
def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None:
from lib.instance.runtime_settings_lib import runtime_set_many
def _write(conn):
payload = {runtime_env_key(k): str(v) for k, v in mapping.items()}
runtime_set_many(conn, payload)
with_db(get_db, _write)
def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]:
"""写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖."""
load_env_file_into_environ(env_path)
hot: dict[str, str] = {}
field_map = {}
for group in groups:
for field in group.get("fields") or []:
field_map[field["key"]] = field
for key in changed_keys:
meta = field_map.get(key) or {}
if meta.get("hot_reload") and not meta.get("restart_required"):
val = os.getenv(key)
if val is not None:
hot[key] = val
if hot:
set_config_overrides(get_db, hot)
from lib.env.env_schema import updates_need_restart
return {"restart_required": updates_need_restart(groups, changed_keys)}
+71
View File
@@ -0,0 +1,71 @@
"""实例 SQLite 运行时配置(导航开关,env 热覆盖等)."""
from __future__ import annotations
import sqlite3
from datetime import datetime
from typing import Any, Callable, Optional
RUNTIME_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS app_runtime_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None:
conn.execute(RUNTIME_TABLE_SQL)
conn.commit()
def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]:
row = conn.execute(
"SELECT value FROM app_runtime_settings WHERE key=?",
(key,),
).fetchone()
if not row:
return None
val = row["value"] if isinstance(row, sqlite3.Row) else row[0]
return None if val is None else str(val)
def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None:
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, value, now),
)
conn.commit()
def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]:
rows = conn.execute(
"SELECT key, value FROM app_runtime_settings WHERE key LIKE ?",
(prefix + "%",),
).fetchall()
out: dict[str, str] = {}
for row in rows:
k = row["key"] if isinstance(row, sqlite3.Row) else row[0]
v = row["value"] if isinstance(row, sqlite3.Row) else row[1]
if k.startswith(prefix):
out[k[len(prefix) :]] = v if v is not None else ""
return out
def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None:
for key, value in mapping.items():
runtime_set(conn, key, value)
def with_db(
get_db: Callable[[], sqlite3.Connection],
fn: Callable[[sqlite3.Connection], Any],
) -> Any:
conn = get_db()
try:
ensure_runtime_settings_table(conn)
return fn(conn)
finally:
conn.close()

Some files were not shown because too many files have changed in this diff Show More