This commit is contained in:
qq564020069
2026-07-12 00:22:48 +08:00
parent 149a3f468d
commit 7502cee8ab
9 changed files with 841 additions and 55 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"vocab_version": "v9217a2c0d91a",
"symbol": "BTCUSDT",
"formula": [
39,
89,
4,
87,
21,
115,
87,
115
],
"best_score": 1.3544840812683105,
"formula_decoded": "YANG_ZHANG_VOL → TS_MAX_10 → SLOPE20 → MOMENTUM_5 → BOLL_POS → MIN → MOMENTUM_5 → MIN",
"train_step": 0
}
+61
View File
@@ -108,6 +108,17 @@ class RemoveWatchRequest(BaseModel):
id: str
class FeishuSettingsRequest(BaseModel):
enabled: bool | None = None
webhook_url: str | None = None
secret: str | None = None
class FeishuTestRequest(BaseModel):
webhook_url: str | None = None
secret: str | None = None
@app.middleware("http")
async def log_requests(request: Request, call_next):
started = time.perf_counter()
@@ -923,6 +934,56 @@ def api_realtime_stop() -> dict[str, Any]:
return {"ok": True, "running": False}
@app.get("/api/realtime/feishu")
def api_realtime_feishu_get() -> dict[str, Any]:
s = load_settings()
return {
"enabled": bool(s.get("feishu_enabled")),
"webhook_url": s.get("feishu_webhook_url") or "",
"secret": s.get("feishu_secret") or "",
}
@app.put("/api/realtime/feishu")
def api_realtime_feishu_put(req: FeishuSettingsRequest) -> dict[str, Any]:
payload: dict[str, Any] = {}
if req.enabled is not None:
payload["feishu_enabled"] = bool(req.enabled)
if req.webhook_url is not None:
payload["feishu_webhook_url"] = req.webhook_url
if req.secret is not None:
payload["feishu_secret"] = req.secret
saved = save_settings(payload)
return {
"ok": True,
"enabled": bool(saved.get("feishu_enabled")),
"webhook_url": saved.get("feishu_webhook_url") or "",
"secret": saved.get("feishu_secret") or "",
}
@app.post("/api/realtime/feishu/test")
def api_realtime_feishu_test(req: FeishuTestRequest) -> dict[str, Any]:
from web.feishu_notify import send_text
url = (req.webhook_url or "").strip()
if not url:
url = (load_settings().get("feishu_webhook_url") or "").strip()
if not url:
raise HTTPException(400, "请先填写 Webhook URL")
secret = req.secret
if secret is None:
secret = load_settings().get("feishu_secret") or ""
ok, msg = send_text(
"✅ AlphaMaster 飞书通知测试:配置正常。信号方向转折时会推送提醒。",
webhook_url=url,
secret=secret or "",
)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg}
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
+140
View File
@@ -0,0 +1,140 @@
"""飞书自定义机器人通知(信号方向转折时推送文本)。
参考 PA_Agent 的 webhook + 可选签名校验写法。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import urllib.error
import urllib.request
from typing import Any
from web.settings import load_settings
_DIR_CN = {
"LONG": "看涨",
"SHORT": "看跌",
"FLAT": "不确定",
}
def direction_cn(direction: str | None) -> str:
if not direction:
return "未知"
return _DIR_CN.get(str(direction).upper(), str(direction))
def strength_cn(strength: float | None, direction: str | None) -> str:
if direction == "FLAT" or direction is None:
return "没把握"
s = max(0.0, min(1.0, float(strength or 0.0)))
if s < 0.2:
return "一点把握"
if s < 0.4:
return "把握不大"
if s < 0.6:
return "一半把握"
if s < 0.8:
return "比较有把握"
return "很有把握"
def _gen_sign(secret: str, timestamp: int) -> str:
string_to_sign = f"{timestamp}\n{secret}"
digest = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def send_text(
text: str,
*,
webhook_url: str | None = None,
secret: str | None = None,
timeout_s: float = 10.0,
) -> tuple[bool, str]:
"""向飞书群发送纯文本。返回 (ok, message)。"""
settings = load_settings()
url = (webhook_url if webhook_url is not None else settings.get("feishu_webhook_url") or "").strip()
if not url:
return False, "未配置 Webhook URL"
sec = (secret if secret is not None else settings.get("feishu_secret") or "").strip()
payload: dict[str, Any] = {
"msg_type": "text",
"content": {"text": text},
}
if sec:
ts = int(time.time())
payload["timestamp"] = str(ts)
payload["sign"] = _gen_sign(sec, ts)
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="replace")
data = json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
try:
detail = exc.read().decode("utf-8", errors="replace")
except Exception:
detail = str(exc)
return False, f"HTTP {exc.code}: {detail}"
except Exception as exc: # noqa: BLE001
return False, str(exc)
if data.get("code") == 0 or data.get("StatusCode") == 0:
return True, "ok"
code = data.get("code", data.get("StatusCode", "?"))
msg = data.get("msg", data.get("StatusMessage", ""))
hint = ""
if code == 19021:
hint = "(签名校验失败,请检查密钥或留空禁用签名)"
elif code == 19024:
hint = "(关键词校验失败,请检查机器人自定义关键词)"
elif code == 19022:
hint = "(IP 不在白名单)"
return False, f"飞书返回 code={code} msg={msg}{hint}"
def notify_direction_flip(
*,
symbol: str,
timeframe: str,
strategy_name: str,
prev_direction: str,
new_direction: str,
strength: float | None = None,
factor_value: float | None = None,
) -> tuple[bool, str]:
"""信号方向发生转折时推送提醒。"""
settings = load_settings()
if not settings.get("feishu_enabled"):
return False, "飞书通知未启用"
if not (settings.get("feishu_webhook_url") or "").strip():
return False, "未配置 Webhook URL"
prev_cn = direction_cn(prev_direction)
new_cn = direction_cn(new_direction)
grasp = strength_cn(strength, new_direction)
factor_s = f"{factor_value:+.4f}" if factor_value is not None else "—"
text = (
f"【AlphaMaster 信号转折】\n"
f"{symbol} · {timeframe}\n"
f"上次判断:{prev_cn}\n"
f"本次判断:{new_cn}({grasp})\n"
f"策略:{strategy_name}\n"
f"因子:{factor_s}"
)
return send_text(text)
+95 -2
View File
@@ -29,6 +29,18 @@ _CADENCE = {
"1m": 15, "5m": 30, "15m": 45, "30m": 60,
"1h": 60, "4h": 120, "1d": 300, "1w": 600, "1M": 600,
}
# K 线周期长度(秒);用于推算「下一根已收盘 bar」时间
_TF_SECONDS = {
"1m": 60,
"5m": 300,
"15m": 900,
"30m": 1800,
"1h": 3600,
"4h": 14400,
"1d": 86400,
"1w": 604800,
"1M": 2592000, # 近似 30 天
}
_DEFAULT_CADENCE = 60
_N_BARS = 500 # 每次拉取的历史 bar 数(喂给特征引擎)
_HISTORY_LEN = 60 # 保留的信号强度历史点数(供 sparkline)
@@ -39,6 +51,47 @@ def _cadence_for(tf: str) -> int:
return _CADENCE.get(tf, _DEFAULT_CADENCE)
def _next_bar_close_at(last_bar_open: int | None, timeframe: str, now: float | None = None) -> int | None:
"""根据最后已收盘 bar 的开盘时间,推算下次收盘(即下次信号更新)的 Unix 秒。
若最后一根已收盘 bar 已过时太久(超过约 2 个周期仍无新 bar),视为休市/断档,
返回 None,避免在周末等时段虚构「几分钟后更新」的倒计时。
"""
if last_bar_open is None:
return None
period = _TF_SECONDS.get(timeframe)
if not period:
return None
now_i = int(now if now is not None else time.time())
last_open = int(last_bar_open)
last_close = last_open + period
# 仍未到收盘(常见于 MT5 终端时钟快于本机、或未剔除形成中 bar)
if last_close > now_i:
return last_close
# 正常交易中:上一根收盘距今至多约 1 个周期;再放宽到 2 个周期容错拉取延迟
if now_i - last_close > period * 2:
return None
# last_open 开盘 → last_close 收盘;当前形成中的 bar 在 +2*period 收盘
nxt = last_open + 2 * period
while nxt <= now_i:
nxt += period
if nxt - last_close > period * 2:
return None
return nxt
def _ensure_closed_bars(bars: list, timeframe: str, now: float | None = None) -> list:
"""按本机时钟再剔掉尚未收盘的 K 线(防止 MT5 时钟偏快时把形成中 bar 当已收盘)。"""
period = _TF_SECONDS.get(timeframe)
if not period or not bars:
return bars
now_i = int(now if now is not None else time.time())
out = list(bars)
while out and int(out[-1].ts) + period > now_i:
out.pop()
return out
def _load_strategy_meta(path: str) -> dict[str, Any]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
if isinstance(data, list):
@@ -81,6 +134,9 @@ class WatchTask:
history: deque = field(default_factory=lambda: deque(maxlen=_HISTORY_LEN))
def to_public(self) -> dict[str, Any]:
now = time.time()
next_close = _next_bar_close_at(self.last_bar_ts, self.timeframe, now)
live = next_close is not None
return {
"id": self.id,
"source": self.source,
@@ -97,6 +153,11 @@ class WatchTask:
"factor_value": self.factor_value,
"bars_used": self.bars_used,
"last_bar_ts": self.last_bar_ts,
"session_live": live,
"next_bar_close_at": next_close,
"seconds_to_next": (
max(0, int(next_close - now)) if next_close is not None else None
),
"updated_at": self.updated_at,
"message": self.message,
"warn": self.warn,
@@ -223,7 +284,20 @@ class RealtimeManager:
def status(self) -> dict[str, Any]:
with self._lock:
watches = [t.to_public() for t in self._tasks.values()]
return {"running": self._running, "count": len(watches), "watches": watches}
nearest = None
for w in watches:
s = w.get("seconds_to_next")
if s is None:
continue
if nearest is None or s < nearest:
nearest = s
return {
"running": self._running,
"count": len(watches),
"watches": watches,
"server_time": time.time(),
"nearest_seconds_to_next": nearest,
}
# ── 调度线程 ────────────────────────────────────────────────────────
def start(self) -> None:
@@ -271,6 +345,7 @@ class RealtimeManager:
return cached[1]
src = get_source(source)
bars = src.fetch_bars(symbol, timeframe, _N_BARS, drop_forming=True)
bars = _ensure_closed_bars(bars, timeframe)
self._bar_cache[key] = (now, bars)
return bars
@@ -290,11 +365,29 @@ class RealtimeManager:
task.last_bar_ts = last_ts
task.updated_at = time.time()
if task.state == "ok":
task.direction = result["direction"]
new_dir = result["direction"]
prev_dir = task.direction
task.direction = new_dir
task.strength = result["strength"]
task.position = result["position"]
task.factor_value = result["factor_value"]
task.history.append(round(result["strength"], 4))
# 已有上次方向且发生转折时推飞书(首次算出方向不打扰)
if prev_dir and new_dir and prev_dir != new_dir:
try:
from web.feishu_notify import notify_direction_flip
notify_direction_flip(
symbol=task.symbol,
timeframe=task.timeframe,
strategy_name=task.strategy_name,
prev_direction=prev_dir,
new_direction=new_dir,
strength=task.strength,
factor_value=task.factor_value,
)
except Exception:
pass
except Exception as exc: # noqa: BLE001
self._set_error(task, str(exc))
finally:
+53
View File
@@ -16,6 +16,12 @@ _DEFAULT = {
# 回测单边成本(单位 %):手续费 0.02% + 滑点 0.01% ≈ 常见加密货币轻度成本
"bt_commission_pct": 0.02,
"bt_slippage_pct": 0.01,
# 实时分析监控清单:[{source, symbol, timeframe, strategy_file}, ...]
"realtime_watches": [],
# 飞书机器人(信号转折提醒,仅文本)
"feishu_enabled": False,
"feishu_webhook_url": "",
"feishu_secret": "",
}
@@ -50,6 +56,25 @@ def load_settings() -> dict:
out["bt_slippage_pct"] = _as_pct(
out.get("bt_slippage_pct"), _DEFAULT["bt_slippage_pct"]
)
watches = out.get("realtime_watches")
if not isinstance(watches, list):
watches = []
cleaned = []
for w in watches:
if not isinstance(w, dict):
continue
src = str(w.get("source") or "").strip()
sym = str(w.get("symbol") or "").strip()
tf = str(w.get("timeframe") or "").strip()
sf = str(w.get("strategy_file") or "").strip()
if src and sym and tf and sf:
cleaned.append(
{"source": src, "symbol": sym, "timeframe": tf, "strategy_file": sf}
)
out["realtime_watches"] = cleaned
out["feishu_enabled"] = bool(out.get("feishu_enabled", False))
out["feishu_webhook_url"] = str(out.get("feishu_webhook_url") or "").strip()
out["feishu_secret"] = str(out.get("feishu_secret") or "").strip()
return out
@@ -76,6 +101,34 @@ def save_settings(data: dict) -> dict:
current["bt_slippage_pct"] = _as_pct(
data["bt_slippage_pct"], _DEFAULT["bt_slippage_pct"]
)
if "realtime_watches" in data:
watches = data["realtime_watches"]
if not isinstance(watches, list):
watches = []
cleaned = []
for w in watches:
if not isinstance(w, dict):
continue
src = str(w.get("source") or "").strip()
sym = str(w.get("symbol") or "").strip()
tf = str(w.get("timeframe") or "").strip()
sf = str(w.get("strategy_file") or "").strip()
if src and sym and tf and sf:
cleaned.append(
{
"source": src,
"symbol": sym,
"timeframe": tf,
"strategy_file": sf,
}
)
current["realtime_watches"] = cleaned
if "feishu_enabled" in data:
current["feishu_enabled"] = bool(data["feishu_enabled"])
if "feishu_webhook_url" in data:
current["feishu_webhook_url"] = str(data["feishu_webhook_url"] or "").strip()
if "feishu_secret" in data:
current["feishu_secret"] = str(data["feishu_secret"] or "").strip()
SETTINGS_PATH.write_text(
json.dumps(current, indent=2, ensure_ascii=False),
encoding="utf-8",
+295 -27
View File
@@ -1626,11 +1626,13 @@ let rtSources = [];
let rtSourceById = {};
let rtImportedStrategy = null; // {path, name}
let rtGridSig = "";
let rtServerSkew = 0; // server_time - local_now(秒)
let rtCountdownTimer = null;
const RT_DIR = {
LONG: { label: "↑ 预期上涨", cls: "rt-long", color: "#4ade80" },
SHORT: { label: "↓ 预期下跌", cls: "rt-short", color: "#f87171" },
FLAT: { label: "— 无信号", cls: "rt-flat", color: "#7a8a9e" },
FLAT: { label: "— 先观望", cls: "rt-flat", color: "#7a8a9e" },
};
const RT_STATE_LABEL = {
pending: "等待首次计算",
@@ -1639,6 +1641,21 @@ const RT_STATE_LABEL = {
error: "错误",
};
/** 把 0~1 强度翻成「把握」白话 */
function rtSizePlain(strength, direction) {
if (direction === "FLAT" || direction == null) {
return { size: "没把握" };
}
const s = Math.max(0, Math.min(1, Number(strength) || 0));
let size;
if (s < 0.2) size = "一点把握";
else if (s < 0.4) size = "把握不大";
else if (s < 0.6) size = "一半把握";
else if (s < 0.8) size = "比较有把握";
else size = "很有把握";
return { size };
}
function escHtml(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])
@@ -1650,6 +1667,59 @@ function rtClock(ts) {
return new Date(ts * 1000).toLocaleTimeString();
}
function rtNowSec() {
return Date.now() / 1000 + rtServerSkew;
}
function rtFmtCountdown(sec) {
const s = Math.max(0, Math.floor(sec));
if (s < 60) return `${s}秒`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}分${String(rs).padStart(2, "0")}秒`;
const h = Math.floor(m / 60);
const rm = m % 60;
if (h < 48) return `${h}小时${rm}分`;
const d = Math.floor(h / 24);
return `${d}天${h % 24}小时`;
}
function ensureRtCountdownTimer() {
if (rtCountdownTimer) return;
rtCountdownTimer = setInterval(tickRtCountdowns, 1000);
}
function tickRtCountdowns() {
document.querySelectorAll(".rt-countdown").forEach((el) => {
if (el.dataset.session === "closed") {
el.textContent = "休市中";
return;
}
const nxt = Number(el.dataset.nextClose);
if (!Number.isFinite(nxt) || nxt <= 0) {
el.textContent = "距离下次判断 —";
return;
}
const left = nxt - rtNowSec();
el.textContent = left <= 0 ? "即将重新判断…" : `距离下次判断 ${rtFmtCountdown(left)}`;
});
const hintCd = $("rtNextHint");
if (hintCd) {
if (hintCd.dataset.session === "closed") {
hintCd.textContent = "休市中";
return;
}
if (hintCd.dataset.nextClose) {
const nxt = Number(hintCd.dataset.nextClose);
if (Number.isFinite(nxt) && nxt > 0) {
const left = nxt - rtNowSec();
hintCd.textContent =
left <= 0 ? "即将重新判断" : `距离下次判断 ${rtFmtCountdown(left)}`;
}
}
}
}
async function initRealtimeOnce() {
if (rtInited) return;
rtInited = true;
@@ -1672,6 +1742,101 @@ async function initRealtimeOnce() {
await logClientError("加载数据源失败: " + e.message);
}
await loadRtStrategies();
await loadRtFeishuSettings();
}
async function loadRtFeishuSettings() {
try {
const data = await fetchJSON("/api/realtime/feishu");
const en = $("rtFeishuEnabled");
const wh = $("rtFeishuWebhook");
const sec = $("rtFeishuSecret");
if (en) en.checked = !!data.enabled;
if (wh) wh.value = data.webhook_url || "";
if (sec) sec.value = data.secret || "";
} catch (e) {
const hint = $("rtFeishuHint");
if (hint) {
hint.textContent = "加载飞书设置失败: " + e.message;
hint.classList.add("bad");
}
}
}
async function saveRtFeishuSettings() {
const hint = $("rtFeishuHint");
const btn = $("rtFeishuSaveBtn");
if (btn) btn.disabled = true;
try {
await fetchJSON("/api/realtime/feishu", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: !!$("rtFeishuEnabled")?.checked,
webhook_url: $("rtFeishuWebhook")?.value || "",
secret: $("rtFeishuSecret")?.value || "",
}),
});
if (hint) {
hint.textContent = "✓ 已保存,方向转折时会推送到飞书群。";
hint.classList.remove("bad", "invalid");
hint.classList.add("valid");
}
if (btn) {
const old = btn.textContent;
btn.textContent = "已保存";
setTimeout(() => {
if (btn.textContent === "已保存") btn.textContent = old || "保存";
}, 1600);
}
} catch (e) {
if (hint) {
hint.textContent = "保存失败: " + e.message;
hint.classList.remove("valid");
hint.classList.add("bad", "invalid");
}
} finally {
if (btn) btn.disabled = false;
}
}
async function testRtFeishu() {
const hint = $("rtFeishuHint");
const btn = $("rtFeishuTestBtn");
if (btn) btn.disabled = true;
try {
await fetchJSON("/api/realtime/feishu/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
webhook_url: $("rtFeishuWebhook")?.value || "",
secret: $("rtFeishuSecret")?.value || "",
}),
});
if (hint) {
hint.textContent = "✓ 测试消息已发送,请到飞书群查收。";
hint.classList.remove("bad", "invalid");
hint.classList.add("valid");
}
} catch (e) {
if (hint) {
hint.textContent = "测试失败: " + e.message;
hint.classList.remove("valid");
hint.classList.add("bad", "invalid");
}
} finally {
if (btn) btn.disabled = false;
}
}
function openRtFeishuHelpModal() {
const modal = $("rtFeishuHelpModal");
if (modal) modal.hidden = false;
}
function closeRtFeishuHelpModal() {
const modal = $("rtFeishuHelpModal");
if (modal) modal.hidden = true;
}
async function loadRtStrategies() {
@@ -1684,12 +1849,17 @@ async function loadRtStrategies() {
} catch (_) {}
const opts = ['<option value="">— 选择已保存策略 —</option>'];
if (rtImportedStrategy) {
opts.push(`<option value="${escHtml(rtImportedStrategy.path)}">导入: ${escHtml(rtImportedStrategy.name)}</option>`);
const isym = escHtml(rtImportedStrategy.symbol || "");
opts.push(
`<option value="${escHtml(rtImportedStrategy.path)}" data-symbol="${isym}">导入: ${escHtml(rtImportedStrategy.name)}</option>`
);
}
rows.forEach((r) => {
const score = r.best_score != null ? Number(r.best_score).toFixed(3) : "—";
const tf = r.timeframe ? ` ${r.timeframe}` : "";
opts.push(`<option value="${escHtml(r.strategy_file)}">${escHtml(r.symbol)}${tf} · 分数 ${score}</option>`);
opts.push(
`<option value="${escHtml(r.strategy_file)}" data-symbol="${escHtml(r.symbol || "")}">${escHtml(r.symbol)}${tf} · 分数 ${score}</option>`
);
});
const prev = sel.value;
sel.innerHTML = opts.join("");
@@ -1719,6 +1889,22 @@ function onRtSourceChange() {
}
}
function rtParseSymbolFromFilename(pathOrName) {
const name = String(pathOrName || "").split(/[/\\]/).pop() || "";
let m = name.match(/^best_(.+)\.json$/i);
if (m) return m[1];
m = name.match(/^strategy_(.+)_step\d+/i);
if (m) return m[1];
return "";
}
function rtApplySymbolFromStrategy(sym) {
const s = String(sym || "").trim();
if (!s) return;
const input = $("rtSymbolInput");
if (input) input.value = s;
}
function onRtStrategyChange() {
const sel = $("rtStrategySelect");
const picked = $("rtStrategyPicked");
@@ -1727,14 +1913,28 @@ function onRtStrategyChange() {
picked.textContent = sel.value
? `因子来源:${opt ? opt.textContent : sel.value}。信号取最后已收盘 bar。`
: "因子来源:从已保存策略下拉选择,或「导入策略」选本地 JSON。信号取最后已收盘 bar。";
if (!sel.value) return;
const fromOpt = (opt && opt.dataset.symbol) || "";
const fromImport =
rtImportedStrategy && sel.value === rtImportedStrategy.path
? rtImportedStrategy.symbol || ""
: "";
const sym = fromOpt || fromImport || rtParseSymbolFromFilename(sel.value);
rtApplySymbolFromStrategy(sym);
}
async function rtBrowseStrategy() {
try {
const res = await fetchJSON("/api/strategy-file/browse", { method: "POST" });
if (res.cancelled) return;
rtImportedStrategy = { path: res.strategy_file, name: res.filename || res.strategy_file };
const name = res.filename || res.strategy_file;
rtImportedStrategy = {
path: res.strategy_file,
name,
symbol: (res.symbol || "").trim() || rtParseSymbolFromFilename(name),
};
await loadRtStrategies();
rtApplySymbolFromStrategy(rtImportedStrategy.symbol);
} catch (e) {
await logClientError("导入策略失败: " + e.message);
}
@@ -1764,7 +1964,6 @@ async function rtAddWatch() {
});
if (picked) picked.classList.remove("bad");
rtEngineRunning = true;
if ($("rtEngineToggle")) $("rtEngineToggle").checked = true;
rtGridSig = "";
await refreshRealtime();
} catch (e) {
@@ -1788,16 +1987,6 @@ async function rtRemoveWatch(id) {
}
}
async function rtToggleEngine(on) {
try {
await fetchJSON(`/api/realtime/${on ? "start" : "stop"}`, { method: "POST" });
rtEngineRunning = !!on;
await refreshRealtime();
} catch (e) {
await logClientError("引擎切换失败: " + e.message);
}
}
async function refreshRealtime() {
let st;
try {
@@ -1805,16 +1994,47 @@ async function refreshRealtime() {
} catch (_) {
return;
}
// 有监控项却未在跑时自动拉起(不再提供手动开关)
if (st.count > 0 && !st.running) {
try {
st = await fetchJSON("/api/realtime/start", { method: "POST" });
} catch (_) {}
}
rtEngineRunning = !!st.running;
const toggle = $("rtEngineToggle");
if (toggle) toggle.checked = rtEngineRunning;
if (typeof st.server_time === "number") {
rtServerSkew = st.server_time - Date.now() / 1000;
}
const nearest = st.nearest_seconds_to_next;
let nearestClose = null;
let anyLive = false;
let anyOk = false;
for (const w of st.watches || []) {
if (w.state === "ok") anyOk = true;
if (w.session_live && w.next_bar_close_at != null) {
anyLive = true;
if (nearestClose == null || w.next_bar_close_at < nearestClose) {
nearestClose = w.next_bar_close_at;
}
}
}
const hint = $("rtStatusHint");
if (hint) {
hint.textContent = st.count
? `${rtEngineRunning ? "运行中" : "已暂停"} · ${st.count} 项 · ${new Date().toLocaleTimeString()}`
: (rtEngineRunning ? "运行中 · 无监控项" : "未启动");
const base = st.count
? `${rtEngineRunning ? "运行中" : "已暂停"} · ${st.count} 项`
: "暂无监控项";
if (nearestClose) {
hint.innerHTML = `${base} · <span id="rtNextHint" data-next-close="${nearestClose}"></span>`;
} else if (anyOk && !anyLive) {
hint.innerHTML = `${base} · <span id="rtNextHint" data-session="closed">休市中</span>`;
} else {
hint.textContent = base;
}
}
renderRealtimeGrid(st.watches || []);
ensureRtCountdownTimer();
tickRtCountdowns();
}
// 半环表盘(180° 上半环,值弧按强度填充)
@@ -1841,9 +2061,39 @@ function renderRealtimeGrid(watches) {
// 签名:只在信号相关字段变化时重建(避免每次轮询重播动画)
const sig = watches
.map((w) => [w.id, w.state, w.direction, w.strength, w.warn, w.message].join("~"))
.map((w) =>
[
w.id,
w.state,
w.direction,
w.strength,
w.warn,
w.message,
w.last_bar_ts,
w.updated_at,
w.session_live ? 1 : 0,
w.next_bar_close_at || "",
].join("~")
)
.join("|");
if (sig === rtGridSig) return;
// 签名未变时仍同步休市/倒计时锚点
if (sig === rtGridSig) {
watches.forEach((w) => {
const el = grid.querySelector(`.rt-card[data-id="${CSS.escape(w.id)}"] .rt-countdown`);
if (!el) return;
if (w.session_live && w.next_bar_close_at) {
el.dataset.session = "";
el.dataset.nextClose = String(w.next_bar_close_at);
} else if (w.state === "ok") {
el.dataset.nextClose = "";
el.dataset.session = "closed";
} else {
el.dataset.nextClose = "";
el.dataset.session = "";
}
});
return;
}
rtGridSig = sig;
grid.innerHTML = watches
@@ -1851,6 +2101,8 @@ function renderRealtimeGrid(watches) {
const dir = w.state === "ok" ? RT_DIR[w.direction] || RT_DIR.FLAT : null;
const color = dir ? dir.color : "#7a8a9e";
const strength = w.state === "ok" ? w.strength || 0 : 0;
const dirKey = w.state === "ok" ? w.direction : null;
const plain = w.state === "ok" ? rtSizePlain(strength, dirKey) : null;
const dirLabel = dir ? dir.label : RT_STATE_LABEL[w.state] || w.state;
const dirCls = dir ? dir.cls : "rt-flat";
const srcLabel = (rtSourceById[w.source] || {}).label || w.source;
@@ -1860,9 +2112,7 @@ function renderRealtimeGrid(watches) {
w.state !== "ok" && w.message
? `<div class="rt-msg">${escHtml(w.message)}</div>`
: "";
const strengthAttr =
w.state === "ok" ? ` data-count="${strength}" data-fmt="strength"` : "";
const strengthText = w.state === "ok" ? METRIC_FMT.strength(strength) : "—";
const sizeText = plain ? plain.size : "—";
return `
<div class="rt-card ${dirCls}" data-id="${escHtml(w.id)}">
<button class="rt-remove" data-remove="${escHtml(w.id)}" title="移除监控">×</button>
@@ -1874,7 +2124,7 @@ function renderRealtimeGrid(watches) {
<div class="rt-gauge">
${halfRingGauge(strength, color)}
<div class="rt-gauge-center">
<div class="rt-strength"${strengthAttr}>${strengthText}</div>
<div class="rt-strength">${escHtml(sizeText)}</div>
<div class="rt-dir ${dirCls}">${dirLabel}</div>
</div>
</div>
@@ -1885,6 +2135,19 @@ function renderRealtimeGrid(watches) {
<div class="rt-foot">
<span class="rt-state ${w.state}">${RT_STATE_LABEL[w.state] || w.state}</span>
<span class="rt-time">更新 ${rtClock(w.updated_at)}</span>
<span class="rt-countdown"${
w.session_live && w.next_bar_close_at
? ` data-next-close="${w.next_bar_close_at}"`
: w.state === "ok"
? ` data-session="closed"`
: ""
}>${
w.session_live && w.next_bar_close_at
? "距离下次判断 …"
: w.state === "ok"
? "休市中"
: "距离下次判断 —"
}</span>
</div>
${warn}
${msg}
@@ -1950,7 +2213,12 @@ async function init() {
if ($("rtStrategySelect")) $("rtStrategySelect").addEventListener("change", onRtStrategyChange);
if ($("rtBrowseStrategyBtn")) $("rtBrowseStrategyBtn").addEventListener("click", rtBrowseStrategy);
if ($("rtAddBtn")) $("rtAddBtn").addEventListener("click", rtAddWatch);
if ($("rtEngineToggle")) $("rtEngineToggle").addEventListener("change", (e) => rtToggleEngine(e.target.checked));
if ($("rtFeishuSaveBtn")) $("rtFeishuSaveBtn").addEventListener("click", saveRtFeishuSettings);
if ($("rtFeishuTestBtn")) $("rtFeishuTestBtn").addEventListener("click", testRtFeishu);
if ($("rtFeishuHelpBtn")) $("rtFeishuHelpBtn").addEventListener("click", openRtFeishuHelpModal);
document.querySelectorAll("[data-close-feishu-help]").forEach((el) => {
el.addEventListener("click", closeRtFeishuHelpModal);
});
if ($("rtGrid")) {
$("rtGrid").addEventListener("click", (e) => {
const btn = e.target.closest("[data-remove]");
+84 -6
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css?v=16" />
<link rel="stylesheet" href="/static/style.css?v=23" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
@@ -408,20 +408,98 @@
<h2>信号雷达</h2>
<div class="rt-head-right">
<span class="hint" id="rtStatusHint">未启动</span>
<label class="rt-engine-toggle">
<input type="checkbox" id="rtEngineToggle" />
<span>引擎</span>
</label>
</div>
</div>
<div class="rt-grid" id="rtGrid">
<div class="metric-empty">尚无监控项。添加「数据源 + 品种 + 周期 + 因子」后开始实时分析。</div>
</div>
<details class="chart-metrics-help bt-howto" open>
<summary>怎么看信号?还有哪些要注意</summary>
<ol class="bt-howto-list">
<li>
<strong>这张卡怎么看?(一眼看懂)</strong>
<ul>
<li>先看中间下面那行方向:<strong>预期上涨</strong>(偏多)或<strong>预期下跌</strong>(偏空)。颜色也会跟着变——绿偏多、红偏空。</li>
<li>再看大字把握:策略对这个方向有多坚决——一点把握 → 把握不大 → 一半把握 → 比较有把握 → 很有把握。半环亮得越满,把握越大。</li>
<li>若显示「没把握 / 先观望」,表示信号太弱,先别当买卖依据。</li>
<li>「因子」是内部算出来的分数,负的偏空、正的偏多,一般不用盯着它;看方向 + 把握就够了。</li>
<li>这不是「猜对的概率」,只是策略此刻有多坚决。</li>
</ul>
</li>
<li>
<strong>只在「当前周期」那根 K 线收盘后才重新判断</strong>
<p>例如选的是 1 小时周期,就等这一小时走完、K 线正式收盘,才会用最新已收盘的数据重算方向和把握;盘中正在走的那根(未收盘)不会拿来改信号。</p>
</li>
<li>
<strong>为什么这样设计</strong>
<p>训练和回测用的都是「已经收盘」的完整 K 线。若盘中用还在变动的价格去算,同一根线里信号会来回跳,也和回测看到的历史不一致,容易误判、频繁改仓。</p>
<p>等收盘再用完整 OHLC,信号更稳,也和策略当初学到的数据口径一致。</p>
</li>
<li>
<strong>相同品种、不同周期,能不能套同一个策略?</strong>
<p>不建议。策略是按<strong>训练时的那个周期</strong>学出来的;公式里的「10 根、20 根」指的是该周期的 K 线根数,不是固定的钟表时间。</p>
<p>例如在 1 小时上训练时,「10 根」大约看过去 10 小时;若硬套到日线,「10 根」就变成过去 10 天——价格形态、噪声幅度、趋势节奏全变了,信号含义对不上,回测成绩也推不到新周期上。</p>
<p>同品种换周期应重新训练(或至少用该周期单独回测验证);实时监控时,周期尽量与策略文件里记录的周期一致。</p>
</li>
<li>
<strong>卡片上的「距离下次判断」在倒计什么</strong>
<p>倒计时指向下一次收盘、重新判断信号的时刻。到点后会短暂显示「即将重新判断…」,收到新 bar 后再刷新,并开始下一轮倒计时。</p>
</li>
</ol>
</details>
</section>
<section class="panel rt-feishu-panel">
<span class="hud-corner tl"></span><span class="hud-corner tr"></span>
<span class="hud-corner bl"></span><span class="hud-corner br"></span>
<h2>飞书转折提醒</h2>
<p class="rt-feishu-desc">
仅在信号方向发生变化时推送文字提醒(例如看涨 → 不确定 / 看跌)。
在飞书群添加自定义机器人后,把 Webhook 地址填到下面。
</p>
<div class="rt-feishu-form">
<label class="rt-feishu-enable">
<input type="checkbox" id="rtFeishuEnabled" />
<span>启用飞书提醒</span>
</label>
<label class="rt-field rt-field-wide">
<span>Webhook URL</span>
<input type="text" id="rtFeishuWebhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…" autocomplete="off" />
</label>
<label class="rt-field rt-field-wide">
<span>签名密钥(可选)</span>
<input type="password" id="rtFeishuSecret" placeholder="机器人开启签名校验时填写,否则留空" autocomplete="off" />
</label>
<div class="rt-actions rt-feishu-actions">
<button type="button" class="btn btn-secondary" id="rtFeishuTestBtn">发送测试</button>
<button type="button" class="btn btn-primary" id="rtFeishuSaveBtn">保存</button>
<button type="button" class="btn btn-feishu-help" id="rtFeishuHelpBtn">怎么使用飞书功能</button>
</div>
</div>
<small class="field-hint" id="rtFeishuHint">保存后,监控项方向转折时会自动推送到飞书群。</small>
</section>
</div><!-- /page-realtime -->
<div class="modal" id="rtFeishuHelpModal" hidden>
<div class="modal-backdrop" data-close-feishu-help></div>
<div class="modal-card modal-card-feishu-help" role="dialog" aria-labelledby="rtFeishuHelpTitle">
<h3 id="rtFeishuHelpTitle">怎么使用飞书功能</h3>
<div class="modal-body modal-body-feishu-help">
去问龙虾AlphaMaster这个项目怎么使用飞书功能,一步一步教我<br>
还是搞不定的话<br>
可以联系阿尔法本人QQ:564020069<br><br>
赞助49.9元可以帮你远程协助完成飞书设置<br><br>
如果之前已经支付过49.9元了,这次只需要赞助30元即可
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" data-close-feishu-help>知道了</button>
</div>
</div>
</div>
</div>
<script src="/static/bg.js?v=1"></script>
<script src="/static/app.js?v=40"></script>
<script src="/static/app.js?v=52"></script>
</body>
</html>
+91 -19
View File
@@ -1700,7 +1700,8 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
.rt-field-wide { flex: 1 1 260px; }
.rt-field select,
.rt-field input[type="text"] {
.rt-field input[type="text"],
.rt-field input[type="password"] {
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text);
@@ -1712,9 +1713,11 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
width: 100%;
}
.rt-field input[type="text"] { font-family: var(--mono); font-size: 0.85rem; }
.rt-field input[type="text"],
.rt-field input[type="password"] { font-family: var(--mono); font-size: 0.85rem; }
.rt-field select:focus,
.rt-field input[type="text"]:focus { border-color: var(--accent); }
.rt-field input[type="text"]:focus,
.rt-field input[type="password"]:focus { border-color: var(--accent); }
.rt-actions { display: flex; gap: 10px; align-items: flex-end; }
@@ -1737,17 +1740,6 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
.rt-head-right { display: flex; align-items: center; gap: 14px; }
.rt-engine-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.rt-engine-toggle input { accent-color: var(--accent); width: 15px; height: 15px; }
.rt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
@@ -1842,15 +1834,16 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
position: absolute;
left: 0;
right: 0;
bottom: 4px;
bottom: 2px;
text-align: center;
}
.rt-strength {
font-family: var(--mono);
font-size: 1.5rem;
font-family: var(--font);
font-size: 1.35rem;
font-weight: 700;
line-height: 1;
line-height: 1.15;
color: var(--text);
letter-spacing: 0.04em;
}
.rt-dir {
margin-top: 4px;
@@ -1877,13 +1870,21 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
.rt-foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
align-items: center;
gap: 6px 10px;
font-size: 0.68rem;
font-family: var(--mono);
color: var(--text-muted);
margin-top: 6px;
}
.rt-countdown {
margin-left: auto;
color: var(--accent);
font-weight: 600;
letter-spacing: 0.02em;
}
.rt-state.ok { color: var(--success); }
.rt-state.error { color: var(--danger); }
.rt-state.insufficient { color: var(--warn); }
@@ -1905,3 +1906,74 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
color: #f0c4c4;
line-height: 1.4;
}
.rt-feishu-panel {
margin-top: 18px;
}
.rt-feishu-desc {
margin: 0 0 14px;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.55;
max-width: 52rem;
}
.rt-feishu-form {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: flex-end;
}
.rt-feishu-enable {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.88rem;
color: var(--text);
cursor: pointer;
user-select: none;
padding-bottom: 10px;
min-width: 140px;
}
.rt-feishu-enable input {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
#rtFeishuHint {
display: block;
margin-top: 10px;
font-size: 0.85rem;
font-weight: 600;
}
#rtFeishuHint.bad,
#rtFeishuHint.invalid { color: var(--danger); }
#rtFeishuHint.valid { color: var(--success); }
.rt-feishu-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.btn-feishu-help {
font-weight: 700;
padding: 10px 18px;
border-radius: 10px;
border: none;
color: #fff;
background: #4a90d9;
cursor: pointer;
letter-spacing: 0.02em;
}
.btn-feishu-help:hover {
background: #357abd;
}
.btn-feishu-help:active {
background: #2a5f9e;
}
.modal-body-feishu-help {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75;
}
+5 -1
View File
@@ -27,7 +27,11 @@ def strategy_path_for_symbol(symbol: str) -> Path:
def symbol_from_strategy_path(path: Path) -> str | None:
m = _BEST_NAME_RE.match(path.name)
name = path.name
m = _BEST_NAME_RE.match(name)
if m:
return m.group(1)
m = _STRATEGY_EXPORT_RE.match(name)
if m:
return m.group(1)
return None