首次上传

This commit is contained in:
dekun
2026-05-16 22:25:48 +08:00
commit 2b8f902548
88 changed files with 16386 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
# Gate.io USDT Perpetual Monitor (Exchange-Only)
> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。
Python service for 7x24 monitoring of **Gate.io USDT-settled linear perpetual futures** using **public REST v4** market data (no on-chain dependency).
## Policy(主线)
- Only **live Gate USDT linear perpetual** contracts (`BASE_USDT`, non-delisting) are eligible.
- **`monitor.universe: all_swaps` (default)**:自动扫 Gate 上架中的线性合约,用 `min_24h_quote_volume_usdt` 按 24h **计价货币(USDT)成交额**过滤(默认 **≥ 1 百万 USDT**,可在 `config.yaml` 调整);**不要**混用 `watch_symbols`。成交额优先读 ticker 的 `volume_24h_quote`
- **`monitor.universe: watchlist`**:只监控 `watch_symbols` 里列出的 base 合约(须为 Gate 上架 USDT 线性)。
- `all_swaps` 模式下 **`min_24h_quote_volume_usdt` 必须大于 0**,否则会扫全市场(极低流动性)。
- 监控主周期 **5m**,规则与触发逻辑见 `app/exchange_rules.py`
### 附加(非产品主线说明)
- **`monitor.btc_daily_gate_enabled`(默认关闭)**:可选的 **BTC 日线横盘过滤**——在判定为日线横盘 regime 下叠加 **K 线形态**等附加条件;实验性/非必选风控,**不作为对外产品主线说明**。实现见 `app/btc_regime.py`,可在 `config.yaml` 关闭。
### 自动下单(gate_order_executor
- **下单执行器**:在 Web 面板 **「下单执行器 · 转发链」** 维护列表(`runtime/order_executors.json`),支持运行中增删;首次启动可从 `config.yaml``order_executor` 导入一条。
- 仅在 **企业微信突破推送成功之后**,向列表中已启用的执行器 **广播** 同一 `POST /v1/signal`;价位与企微 **方案 A** 一致。详见 [`docs/多执行器与信号转发归档.md`](docs/多执行器与信号转发归档.md)。
- 该 HTTP 请求 **不走** `proxy.url`,便于同机访问执行器。
全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。
## Web Panel
- Login required for all `/api/*` and dashboard (unless `auth.enabled: false`).
- **MATRIX 主视图**:表格列为 **Gemma 漏斗评分**`/api/funnel`),按 `composite_score` 排序。
- **K 线周期**:监控主周期 **5m**;其它周期沿用内部命名(如 `4H``1D`),在 `app/gate.py` 映射为 Gate 的 `interval`
## Gemma 漏斗(本地 Ollama
1. 安装并启动 [Ollama](https://ollama.com)`ollama pull` 你的 Gemma 模型(如 `gemma2:2b`);多模态需带 vision 的 tag 才能送 K 线图。
2.`config.yaml``gemma.enabled: true`,按需改 `model``ollama_base_url`
3. 每个 **5m 扫描** 产生 `WATCH` / `TRIGGER` 后,按 **24h 成交额**`max_funnel_per_cycle` 上限,取 **日线 OHLCV** + 可选图表;按 `vision_top_n`**matplotlib 生成的 PNG 截图**base64)给 Ollama(纯文本模型设 `send_chart_image: false`)。
4. 模型必须返回 JSON 结构(见 `app/gemma_client.py`)。合并 `composite_score` 后写入 `alerts``details.source === gemma_funnel`);若 `priority >= gemma_push_priority_min` **或** `composite_score >= composite_push_min`,则 **企业微信** 推送一条「MATRIX · 漏斗优先」提醒。
5. 若 Ollama 对 `format: json` 不稳,可将 `gemma.json_mode` 设为 `false`
## Project Layout
```text
onchain_scout_gate/
app/
main.py
web.py
monitor.py
gate.py
exchange_rules.py
btc_regime.py
daily_features.py
chart_candles.py
gemma_client.py
notifier.py
order_executor_forward.py
storage.py
models.py
config.py
templates/
static/
deploy/
ecosystem.config.cjs # PM2: python -m app.main
onchain-scout.service # 可选:systemd + pm2-runtime
config.example.yaml
requirements.txt
```
## 本地 / 服务器部署(PM2,推荐)
1. Python **3.10+**`python -m venv .venv``pip install -r requirements.txt`
2. 复制 `config.example.yaml``config.yaml`,填写 `auth``wecom``monitor` 等。
3. 安装 [Node.js](https://nodejs.org/) 后:`npm install -g pm2`
4. 在项目根:**`pm2 start deploy/ecosystem.config.cjs`**
5. 常用:`pm2 logs onchain-scout``pm2 restart onchain-scout``pm2 save`(配合 `pm2 startup` 开机)。
监听地址与端口以 **`config.yaml`** 的 `app.host` / `app.port`(与 `app.main` 起 uvicorn 一致)。
## SOCKS5 代理
- 代理写在 **`config.yaml``proxy`**`enabled` / `url`),仅用于访问 **Gate** 的 httpx;**当前实现**里企业微信等为直连 `proxy_url=None`**本地 Ollama 不走代理**。
- 详见 **[docs/本地部署-SOCKS5代理.md](docs/本地部署-SOCKS5代理.md)**。
## Linux Quick Startvenv + PM2
```bash
cd /opt/onchain_scout_gate
chmod +x deploy/bootstrap.sh
./deploy/bootstrap.sh /opt/onchain_scout_gate
# 编辑 config.yaml
source .venv/bin/activate
npm install -g pm2
pm2 start deploy/ecosystem.config.cjs
```
也可用当前脚本快速试:**`deploy/start.sh`**uvicorn,非 PM2)。
## systemd(可选)
长期用 systemd 托管 **PM2**(进程用 `pm2-runtime`),可参考 `deploy/onchain-scout.service`(需已安装 `pm2`,并按环境改 `WorkingDirectory` / `ExecStart`)。
## Config Keys
- `gate.api_base`:默认 `https://api.gateio.ws/api/v4`;亦可按官方文档使用 `https://fx-api.gateio.ws/api/v4`
- `gate.settle`USDT 线性填 `usdt`
- `gate.quote_currency`:计价货币,默认 `USDT`
- `monitor.universe`: `all_swaps` | `watchlist`.
- `watch_symbols`: 仅在 `watchlist` 模式下使用。
- `monitor.min_24h_quote_volume_usdt`: 24h 成交额(USDT)下限;`all_swaps` 须填 `>0`。默认 **1 百万**
- `monitor.btc_daily_gate_enabled` / `btc_sideways_*`: **可选** BTC 日线横盘过滤(**非主线说明**);默认关闭,见 `config.yaml`
- `gemma.*``proxy.*``app.poll_interval_seconds`: 见 `config.example.yaml`
+2
View File
@@ -0,0 +1,2 @@
"""On-chain first-mover monitoring system package."""
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass, field
from statistics import mean
@dataclass
class BtcDailyGateResult:
"""BTC 日线辅助门控(非产品主线):下跌不扫山寨,其它仍扫 —— 仅 downtrend 时关闭本轮 alt K 线请求。"""
allow_alt_scan: bool
regime: str # sideways | downtrend | neutral_or_up | unknown
reason: str
metrics: dict = field(default_factory=dict)
def _rows_to_hlc(rows: list[list[str]]) -> tuple[list[float], list[float], list[float]]:
"""与行情 K 线行对齐:h, l, cts,o,h,l,c,...)。"""
h, l_, c = [], [], []
for item in rows:
if len(item) < 6:
continue
h.append(float(item[2]))
l_.append(float(item[3]))
c.append(float(item[4]))
return h, l_, c
def evaluate_btc_daily_gate(
btc_1d_rows: list[list[str]],
*,
sideways_lookback_days: int = 14,
sideways_max_range_pct: float = 10.0,
min_bars: int = 30,
) -> BtcDailyGateResult:
"""
原则:下跌不扫,其它都扫。
- 下跌(唯一不扫):非横盘,且收盘低于近 20 日收盘均线,且该均线相对前一段走低。
- 其余(横盘、上涨、宽幅震荡、数据不足 unknown 等):一律允许扫山寨。
"""
ah, al, ac = _rows_to_hlc(btc_1d_rows)
if len(ac) < min_bars:
return BtcDailyGateResult(
allow_alt_scan=True,
regime="unknown",
reason=f"insufficient_1d_bars have={len(ac)} need>={min_bars}, gate skipped",
metrics={"have": len(ac), "min_bars": min_bars},
)
lb = max(5, min(sideways_lookback_days, len(ah) - 1))
window_h = ah[-lb:]
window_l = al[-lb:]
range_high = max(window_h)
range_low = min(window_l)
mid = (range_high + range_low) / 2 if range_high > range_low else 0.0
range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0
sma_curr = mean(ac[-20:])
sma_prev = mean(ac[-26:-6]) if len(ac) >= 26 else sma_curr
last_close = ac[-1]
is_sideways = range_pct <= sideways_max_range_pct
if is_sideways:
return BtcDailyGateResult(
allow_alt_scan=True,
regime="sideways",
reason="btc_daily_sideways",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"sideways_max_range_pct": sideways_max_range_pct,
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
is_downtrend = last_close < sma_curr and sma_curr < sma_prev
if is_downtrend:
return BtcDailyGateResult(
allow_alt_scan=False,
regime="downtrend",
reason="btc_daily_downtrend_below_falling_sma20",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"sideways_max_range_pct": sideways_max_range_pct,
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
return BtcDailyGateResult(
allow_alt_scan=True,
regime="neutral_or_up",
reason="btc_not_sideways_not_downtrend_gate_open",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import base64
import io
import logging
LOGGER = logging.getLogger("onchain_scout.chart_candles")
def daily_candles_png_base64(rows_1d: list[list[str]], symbol: str, max_bars: int = 48) -> str | None:
"""
生成简易日线蜡烛图 PNGbase64,无 data URL 前缀),供 Ollama 多模态。
若 matplotlib 不可用或失败则返回 None。
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
except ImportError:
LOGGER.warning("matplotlib not installed, skip chart image")
return None
o, h, l, c, _ = [], [], [], [], []
for item in rows_1d:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
n = len(c)
if n < 5:
return None
start = max(0, n - max_bars)
o, h, l, c = o[start:], h[start:], l[start:], c[start:]
x = list(range(len(c)))
fig, ax = plt.subplots(figsize=(7, 3), facecolor="#030308")
ax.set_facecolor("#050510")
for i in x:
up = c[i] >= o[i]
col = "#00f5d4" if up else "#ff006e"
ax.plot([i, i], [l[i], h[i]], color=col, linewidth=0.9, alpha=0.9)
body_low = min(o[i], c[i])
body_h = abs(c[i] - o[i])
if body_h < 1e-12:
body_h = (h[i] - l[i]) * 0.08 or 1e-8
ax.add_patch(
Rectangle(
(i - 0.35, body_low),
0.7,
body_h,
facecolor=col,
edgecolor=col,
linewidth=0.4,
alpha=0.85,
)
)
ax.set_title(f"{symbol} 1D", color="#00fff7", fontsize=11, fontfamily="monospace")
ax.tick_params(colors="#7dffb3", labelsize=7)
for spine in ax.spines.values():
spine.set_color("#1b3d2f")
ax.grid(True, alpha=0.12, color="#00fff7")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=100, facecolor=fig.get_facecolor())
plt.close(fig)
buf.seek(0)
return base64.b64encode(buf.read()).decode("ascii")
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field, ValidationError
class AppConfig(BaseModel):
host: str = "0.0.0.0"
port: int = 8088
poll_interval_seconds: int = 120
log_file: str = "./runtime/system.log"
database_url: str = "sqlite+aiosqlite:///./runtime/alerts.db"
session_secret: str = "please-change-me"
class AuthConfig(BaseModel):
"""
enabled: 为 false 时跳过登录(仅建议纯局域网、无外网暴露时使用)。
"""
enabled: bool = True
username: str
password: str
class WeComConfig(BaseModel):
webhook: str
mentioned_mobile_list: list[str] = Field(default_factory=list)
class GateConfig(BaseModel):
"""Gate.io 公共 REST v4USDT 永续 settle=usdt)。"""
api_base: str = "https://api.gateio.ws/api/v4"
settle: str = "usdt"
quote_currency: str = "USDT"
class ProxyConfig(BaseModel):
"""
出站 HTTP 客户端代理(httpx),用于访问 Gate 等外网。
企业微信与本机/局域网 Ollama(Gemma)默认直连,不使用此配置。
可写 socks5h://…;程序在交给 httpx 时会自动改为 socks5://(避免 Unknown scheme)。
"""
enabled: bool = False
url: str = "socks5h://127.0.0.1:1080"
class OrderExecutorConfig(BaseModel):
"""
与 gate_order_executor 联动:企微突破推送 **成功之后**,向执行器 POST /v1/signal。
请求不走 proxy.url(直连 base_url),便于同机 127.0.0.1。
webhook_secret 须与执行器 config.yaml 的 security.webhook_secret 一致。
"""
enabled: bool = False
base_url: str = "http://127.0.0.1:8090"
webhook_secret: str = ""
timeout_seconds: float = Field(15.0, ge=3.0, le=120.0)
class WatchSymbol(BaseModel):
"""Gate USDT 永续 base 资产符号,如 BTC、ORDI、1000PEPE(与合约名 BTC_USDT 的左侧一致)。"""
symbol: str
class MonitorConfig(BaseModel):
"""
监控侧过滤。
universe:
- all_swaps: 监控 Gate 全部 USDT 本位线性永续中,24h 成交额达标的合约(不依赖 watch_symbols)。
- watchlist: 仅监控 watch_symbols 中列出且满足成交额阈值的标的。
min_24h_quote_volume_usdt: 近 24h 成交额下限(USDT)。优先使用 Gate ticker 的 volume_24h_quote。
all_swaps 模式下若设为 0 或负数,将拒绝整轮扫描(避免无阈值拉全市场)。
watchlist 模式下 0 表示关闭成交额过滤。
btc_daily_gate_enabled: 可选;true 时仍计算 BTC 日线 regime 供面板/日志参考,不再拦截山寨扫描。
btc_sideways_lookback_days / btc_sideways_max_range_pct: 与上述辅助门控配套的横盘区分参数。
"""
universe: Literal["all_swaps", "watchlist"] = "all_swaps"
min_24h_quote_volume_usdt: float = 10_000_000
# 可选:BTC 日线 regime 仅展示/记录;推送门控用「近8h×15m BTC 环境(横盘则多空均可;否则涨→LONG、跌→SHORT)+ 本币4h同向」
btc_daily_gate_enabled: bool = True
btc_sideways_lookback_days: int = 14
btc_sideways_max_range_pct: float = 10.0
# 同一币种在 N 小时内对同一条「链路」只落库一条告警、只推送一次(0 表示关闭去重)
# 链路含:GATE-USDT 5m WATCH / GATE-USDT 5m TRIGGER(分级)与 FUNNEL-GEMMA(漏斗)
symbol_signal_dedupe_hours: float = 4.0
# 企业微信主推送(突破预警):仅对本轮监控池内 24h 成交额排名前 N 的合约推送;0 表示不限制
wecom_push_max_volume_rank: int = 30
class GemmaConfig(BaseModel):
"""
本地 Ollama 跑 Gemma(或其它模型)做漏斗二次分拣。
需在机器上自行启动 ollama 并拉取模型;开启后仅对本轮 5m 扫描命中的 WATCH/TRIGGER 按成交额取前 N 再请求。
"""
enabled: bool = False
ollama_base_url: str = "http://127.0.0.1:11434"
model: str = "gemma2:2b"
timeout_seconds: float = 180.0
temperature: float = 0.15
json_mode: bool = True
send_chart_image: bool = True
max_funnel_per_cycle: int = 12
vision_top_n: int = 4
gemma_push_priority_min: float = 7.0
composite_push_min: float = 72.0
class DailyReportConfig(BaseModel):
"""每日晨报:北京时间定时生成昨天复盘,并可推送企业微信。"""
enabled: bool = True
run_time_cn: str = "08:30"
push_wecom: bool = True
run_on_startup: bool = False
class Settings(BaseModel):
app: AppConfig
auth: AuthConfig
wecom: WeComConfig
gate: GateConfig
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig)
monitor: MonitorConfig = Field(default_factory=MonitorConfig)
gemma: GemmaConfig = Field(default_factory=GemmaConfig)
daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig)
watch_symbols: list[WatchSymbol] = Field(default_factory=list)
def load_settings(config_path: str = "config.yaml") -> Settings:
path = Path(config_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {path}. 请先复制 config.example.yaml 为 config.yaml 并填写密钥。"
)
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
try:
return Settings.model_validate(raw)
except ValidationError as exc:
raise ValueError(f"配置文件校验失败: {exc}") from exc
# 兼容原 OKX 风格 bar 字符串(映射见 app.gate._to_gate_interval
GATE_BAR_CHOICES: tuple[str, ...] = (
"1m",
"3m",
"5m",
"15m",
"30m",
"1H",
"2H",
"4H",
"6H",
"12H",
"1D",
"1W",
"1M",
)
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import math
from statistics import mean
def rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]:
o, h, l, c, v = [], [], [], [], []
for item in rows:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
v.append(float(item[5]))
return o, h, l, c, v
def build_daily_programmatic(rows_1d: list[list[str]], est_quote_vol_24h_usdt: float) -> dict:
"""
日线程序化特征:上方空间(距阶段高)、成交活跃度、简单阻力代理(现价与区间高之间局部高点数量)。
"""
_, high, low, close, vol = rows_to_ohlcv(rows_1d)
if len(close) < 10:
return {"error": "insufficient_daily", "have": len(close)}
last = close[-1]
look = min(60, len(close))
hi = max(high[-look:])
lo = min(low[-look:])
mid = (hi + lo) / 2 if hi > lo else last
range_pct = ((hi - lo) / mid) * 100 if mid > 0 else 0.0
upside_pct = ((hi - last) / last) * 100 if last > 0 else 0.0
# 现价上方到区间高:统计「局部高点」数量作为中间阻力代理(越多越密)
seg_h = high[-look:]
seg_l = low[-look:]
local_peaks = 0
for i in range(1, len(seg_h) - 1):
if seg_h[i] >= seg_h[i - 1] and seg_h[i] >= seg_h[i + 1]:
if seg_h[i] > last * 1.002 and seg_h[i] < hi * 0.998:
local_peaks += 1
vol_tail = vol[-20:] if len(vol) >= 20 else vol
vol_mean = mean(vol_tail[:-1]) if len(vol_tail) > 1 else (vol_tail[0] if vol_tail else 1.0)
vol_ratio = (vol_tail[-1] / vol_mean) if vol_mean > 0 else 0.0
sma20 = mean(close[-20:]) if len(close) >= 20 else mean(close)
structure_hint = "price_above_sma20" if last >= sma20 else "price_below_sma20"
return {
"last_close": round(last, 8),
"range_60d_high": round(hi, 8),
"range_60d_low": round(lo, 8),
"range_pct_lookback": round(range_pct, 4),
"upside_to_range_high_pct": round(max(0.0, upside_pct), 4),
"mid_resistance_proxy_peaks": local_peaks,
"volume_last_vs_20d_mean": round(vol_ratio, 4),
"est_quote_vol_24h_usdt": round(est_quote_vol_24h_usdt, 2),
"structure_hint": structure_hint,
"sma20": round(sma20, 8),
}
def programmatic_scores(prog: dict) -> dict:
"""归一化子分数 0100,供合成 composite。"""
if prog.get("error"):
return {"vol": 0.0, "upside": 0.0, "liquidity": 0.0, "mid_clear": 0.0}
est = float(prog.get("est_quote_vol_24h_usdt") or 0.0)
# 成交额:10M≈35100M≈70
vol_score = min(100.0, max(0.0, math.log10(est / 1e6 + 1) * 32.0))
upside = float(prog.get("upside_to_range_high_pct") or 0.0)
upside_score = min(100.0, upside * 4.0)
vr = float(prog.get("volume_last_vs_20d_mean") or 0.0)
liquidity_score = min(100.0, max(0.0, (vr - 1.0) * 35.0 + 40.0))
peaks = int(prog.get("mid_resistance_proxy_peaks") or 0)
mid_clear_score = max(0.0, 100.0 - peaks * 12.0)
return {
"vol": round(vol_score, 2),
"upside": round(upside_score, 2),
"liquidity": round(liquidity_score, 2),
"mid_clear": round(mid_clear_score, 2),
}
def composite_score(gemma_priority: float, sub: dict) -> float:
"""gemma_priority 110;与程序化子分合成 0–100。"""
g = max(1.0, min(10.0, gemma_priority)) * 10.0
p = 0.35 * g
p += 0.2 * sub.get("vol", 0.0)
p += 0.2 * sub.get("upside", 0.0)
p += 0.15 * sub.get("liquidity", 0.0)
p += 0.1 * sub.get("mid_clear", 0.0)
return round(min(100.0, max(0.0, p)), 2)
def daily_ohlc_text_block(rows_1d: list[list[str]], max_lines: int = 24) -> str:
"""给 LLM 的紧凑 OHLCV 文本(时间正序:旧→新,最后一行为最新)。"""
rows = rows_1d[-max_lines:] if len(rows_1d) > max_lines else rows_1d
lines = ["ts,o,h,l,c,vol"]
for item in rows:
if len(item) < 6:
continue
ts, o, h, l, c, v = item[0], item[1], item[2], item[3], item[4], item[5]
lines.append(f"{ts},{o},{h},{l},{c},{v}")
return "\n".join(lines)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import json
from collections import Counter
from datetime import date, datetime, timedelta, timezone
from statistics import mean
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from .config import Settings
from .notifier import WeComNotifier
from .gate import GateClient
from .storage import Storage
from .time_cn import format_beijing_wall, utc_now
if TYPE_CHECKING:
from .gemma_client import OllamaGemmaClient
CN_TZ = ZoneInfo("Asia/Shanghai")
BTC_INST = "BTC_USDT"
def _rows_to_close(rows: list[list[str]]) -> list[float]:
out: list[float] = []
for r in rows:
if len(r) < 5:
continue
out.append(float(r[4]))
return out
def _sma(values: list[float], n: int) -> float:
if not values:
return 0.0
if len(values) < n:
return mean(values)
return mean(values[-n:])
def _btc_direction(close: float, prev: float, sma20: float, sma60: float) -> tuple[str, str]:
up = close >= prev
if close >= sma20 >= sma60 and up:
return "偏多上行", "收盘位于 SMA20/SMA60 上方,且日内延续上涨。"
if close < sma20 <= sma60 and not up:
return "偏空下行", "收盘位于 SMA20 下方且动能走弱。"
return "震荡中性", "价格位于均线附近,趋势延续性一般。"
def _cn_day_range(target_day: date) -> tuple[datetime, datetime]:
day_start_cn = datetime(target_day.year, target_day.month, target_day.day, tzinfo=CN_TZ)
day_end_cn = day_start_cn + timedelta(days=1)
start_utc = day_start_cn.astimezone(timezone.utc).replace(tzinfo=None)
end_utc = day_end_cn.astimezone(timezone.utc).replace(tzinfo=None)
return start_utc, end_utc
def _default_report_text(snapshot: dict, stats: dict, report_day_cn: str) -> dict:
top_symbols = stats.get("top_trigger_symbols", [])
top_line = "".join(top_symbols[:5]) if top_symbols else ""
risk = "若 BTC 回落并失守日内关键位,山寨延续将明显减弱。"
action = "优先跟踪成交额靠前且 5m 不创新低的标的,确认后再加仓。"
return {
"headline": f"{report_day_cn} 复盘:BTC {snapshot['direction']},触发层共 {stats['trigger_count']}",
"btc_explain": snapshot["direction_reason"],
"summary": (
f"昨日 WATCH {stats['watch_count']} 条、TRIGGER {stats['trigger_count']} 条、"
f"漏斗优先推送 {stats['funnel_push_count']} 条。"
f"触发活跃币种:{top_line}"
),
"risk_points": [risk],
"action_hint": action,
}
class DailyReportService:
def __init__(
self,
settings: Settings,
storage: Storage,
gate_client: GateClient,
notifier: WeComNotifier,
gemma_client: OllamaGemmaClient | None,
) -> None:
self.settings = settings
self.storage = storage
self.gate = gate_client
self.notifier = notifier
self.gemma_client = gemma_client
async def _push_wecom_enabled(self) -> bool:
raw = await self.storage.get_kv("daily_report_push_wecom")
if raw is None:
return self.settings.daily_report.push_wecom
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
async def run_once(self) -> dict:
now_utc = utc_now()
now_cn = now_utc.astimezone(CN_TZ)
report_day = now_cn.date() - timedelta(days=1)
start_utc, end_utc = _cn_day_range(report_day)
report_day_cn = report_day.strftime("%Y-%m-%d")
alerts = await self.storage.get_alerts_between(start_utc, end_utc, limit=3000)
watch_count = 0
trigger_count = 0
funnel_push_count = 0
trigger_symbols: Counter[str] = Counter()
for a in alerts:
d = a.get("details") or {}
lvl = str(d.get("signal_level") or "")
src = str(d.get("source") or "")
if lvl == "WATCH":
watch_count += 1
elif lvl == "TRIGGER":
trigger_count += 1
trigger_symbols[str(a.get("symbol") or "").upper()] += 1
if src == "gemma_funnel" and bool(d.get("priority_push")):
funnel_push_count += 1
btc_rows = await self.gate.get_candles(BTC_INST, "1D", limit=100)
closes = _rows_to_close(btc_rows)
last_close = closes[-1] if closes else 0.0
prev_close = closes[-2] if len(closes) >= 2 else last_close
day_change_pct = ((last_close - prev_close) / prev_close * 100.0) if prev_close else 0.0
sma20 = _sma(closes, 20)
sma60 = _sma(closes, 60)
direction, direction_reason = _btc_direction(last_close, prev_close, sma20, sma60)
snapshot = {
"symbol": "BTC",
"last_close": round(last_close, 4),
"prev_close": round(prev_close, 4),
"day_change_pct": round(day_change_pct, 2),
"sma20": round(sma20, 4),
"sma60": round(sma60, 4),
"direction": direction,
"direction_reason": direction_reason,
}
stats = {
"watch_count": watch_count,
"trigger_count": trigger_count,
"funnel_push_count": funnel_push_count,
"top_trigger_symbols": [s for s, _ in trigger_symbols.most_common(10)],
}
ai_used = False
text_block = _default_report_text(snapshot, stats, report_day_cn)
if self.gemma_client and self.settings.gemma.enabled:
ai = await self.gemma_client.generate_daily_report(report_day_cn, snapshot, stats)
if ai and not ai.get("error"):
ai_used = True
text_block = {
"headline": ai.get("headline") or text_block["headline"],
"btc_explain": ai.get("btc_explain") or text_block["btc_explain"],
"summary": ai.get("summary") or text_block["summary"],
"risk_points": ai.get("risk_points") or text_block["risk_points"],
"action_hint": ai.get("action_hint") or text_block["action_hint"],
}
report = {
"report_day_cn": report_day_cn,
"generated_at_utc": now_utc.isoformat(),
"generated_at_cn": format_beijing_wall(now_utc),
"ai_used": ai_used,
"btc": snapshot,
"stats": stats,
"text": text_block,
}
await self.storage.set_kv("daily_report_latest", json.dumps(report, ensure_ascii=False))
await self.storage.add_log(
"INFO",
f"daily_report_generated day={report_day_cn} ai={'on' if ai_used else 'off'} trigger={trigger_count}",
)
if await self._push_wecom_enabled():
await self.notifier.send_daily_report(report)
return report
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from dataclasses import dataclass, field
from statistics import mean
# 以下换算仅针对 5m K(与是否单独拉 4h 图无关):
# 每小时 60/5 = 12 根;一根「4 小时」大周期对应 4×12 = 48 根 5m。
BARS_5M_PER_HOUR = 12
BARS_5M_PER_4H = BARS_5M_PER_HOUR * 4 # 48
# 箱体回看最短不少于一根 4h 等价的 5m 长度,避免用不足一个 4h 的窗去定义箱体
MIN_BOX_LOOKBACK_BARS_5M = BARS_5M_PER_4H
@dataclass
class IntradayRuleParams:
range_hours: float = 8.0
range_max_pct: float = 1.5
volume_spike_mult: float = 1.6
volume_lookback_bars: int = 20
breakout_buffer_pct: float = 0.05
@dataclass
class ExchangeRuleResult:
signal_level: str = "NONE" # NONE | WATCH | TRIGGER
signal_side: str = "NONE" # NONE | LONG | SHORT
trigger_types: list[str] = field(default_factory=list)
score: float = 0.0
metrics: dict = field(default_factory=dict)
def _rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]:
o, h, l, c, v = [], [], [], [], []
for item in rows:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
v.append(float(item[5]))
return o, h, l, c, v
def evaluate_exchange(
symbol: str,
alt_rows: list[list[str]],
btc_rows: list[list[str]],
params: IntradayRuleParams,
) -> ExchangeRuleResult:
"""
5m 日内结构规则(中文分级):
- range_hours 按「墙钟小时」换成 5m 根数:×12(根/小时);48 根 5m = 4 墙钟小时。
- 观察:箱体回看窗口内(不含突破/确认 K)用最高/最低算振幅,不超过 range_max_pct
- 触发:突破 K 在有效带内,确认 K 收在箱体外,并满足放量等条件
"""
breakout_max_pct = 0.5
result = ExchangeRuleResult()
_, ah, al, ac, av = _rows_to_ohlcv(alt_rows)
bars_for_range = max(
MIN_BOX_LOOKBACK_BARS_5M,
int(params.range_hours * BARS_5M_PER_HOUR),
)
vol_lb = max(5, int(params.volume_lookback_bars))
min_need = bars_for_range + vol_lb + 3
if len(ac) < min_need:
result.metrics = {"error": "insufficient_candles", "need": min_need, "have": len(ac)}
return result
# 区间边界:前 N 根(不含倒数第 1 确认 K、倒数第 2 突破 K),用区间内的 highest/lowest
seg_h = ah[-bars_for_range - 2 : -2]
seg_l = al[-bars_for_range - 2 : -2]
range_high = max(seg_h)
range_low = min(seg_l)
mid = (range_high + range_low) / 2 if range_high > range_low else 0
range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0
breakout_close = ac[-2]
confirm_close = ac[-1]
breakout_high = ah[-2]
breakout_low = al[-2]
confirm_high = ah[-1]
confirm_low = al[-1]
last_volume = av[-1]
vol_base = mean(av[-vol_lb - 1 : -1]) if len(av) > vol_lb else mean(av)
vol_ratio = (last_volume / vol_base) if vol_base > 0 else 0.0
breakout_min_line = range_high * (1 + params.breakout_buffer_pct / 100)
breakout_max_line = range_high * (1 + breakout_max_pct / 100)
breakdown_min_line = range_low * (1 - params.breakout_buffer_pct / 100)
breakdown_max_line = range_low * (1 - breakout_max_pct / 100)
is_sideways = range_pct <= params.range_max_pct
is_volume_spike = vol_ratio >= params.volume_spike_mult
breakout_long_ok = breakout_close > breakout_min_line and breakout_close < breakout_max_line
breakout_short_ok = breakout_close < breakdown_min_line and breakout_close > breakdown_max_line
confirm_long_ok = confirm_close > range_high
confirm_short_ok = confirm_close < range_low
if is_sideways:
result.signal_level = "WATCH"
result.trigger_types = ["横盘结构成立"]
result.score = 1.0
if is_sideways and breakout_long_ok and confirm_long_ok and is_volume_spike:
result.signal_level = "TRIGGER"
result.signal_side = "LONG"
result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量突破"]
result.score = 3.4
elif is_sideways and breakout_short_ok and confirm_short_ok and is_volume_spike:
result.signal_level = "TRIGGER"
result.signal_side = "SHORT"
result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量破位"]
result.score = 3.4
result.metrics = {
"symbol": symbol.upper(),
"bar": "5m",
"range_hours": params.range_hours,
"range_bars": bars_for_range,
"range_max_pct": params.range_max_pct,
"range_pct": round(range_pct, 4),
"range_high": range_high,
"range_low": range_low,
"breakout_min_pct": params.breakout_buffer_pct,
"breakout_max_pct": breakout_max_pct,
"breakout_min_line": breakout_min_line,
"breakout_max_line": breakout_max_line,
"breakdown_min_line": breakdown_min_line,
"breakdown_max_line": breakdown_max_line,
"breakout_close": breakout_close,
"confirm_close": confirm_close,
"breakout_high": breakout_high,
"breakout_low": breakout_low,
"confirm_high": confirm_high,
"confirm_low": confirm_low,
"volume_lookback_bars": vol_lb,
"volume_spike_mult": params.volume_spike_mult,
"last_volume": last_volume,
"volume_base": round(vol_base, 8),
"volume_ratio": round(vol_ratio, 4),
"signal_side": result.signal_side,
}
return result
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from .config import GateConfig
from .proxy_util import httpx_proxy_url
def _to_gate_interval(bar: str) -> str:
b = (bar or "").strip()
mapping = {
"1m": "1m",
"3m": "3m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1H": "1h",
"2H": "2h",
"4H": "4h",
"6H": "6h",
"8H": "8h",
"12H": "12h",
"1D": "1d",
"1W": "7d",
"1M": "1M",
}
if b in mapping:
return mapping[b]
if len(b) >= 2 and b.endswith("H") and b[:-1].isdigit():
return f"{b[:-1]}h"
if len(b) >= 2 and b.endswith("D"):
return b[:-1] + "d"
return b.lower()
def _candle_row(obj: dict[str, Any]) -> list[str]:
ts_ms = str(int(float(obj["t"])) * 1000)
o = str(obj.get("o") or "")
h = str(obj.get("h") or "")
l = str(obj.get("l") or "")
c = str(obj.get("c") or "")
v = str(obj.get("v") or "")
sum_q = str(obj.get("sum") or "")
return [ts_ms, o, h, l, c, v, v, sum_q, "1"]
def _is_linear_usdt_perp_contract(item: dict[str, Any]) -> bool:
name = str(item.get("name") or "")
parts = name.split("_")
if len(parts) != 2 or parts[1].upper() != "USDT":
return False
if item.get("in_delisting") is True:
return False
return True
class GateClient:
"""Gate.io USDT 结算永续合约公共行情(REST v4)。"""
def __init__(self, conf: GateConfig, proxy_url: str | None = None) -> None:
self.conf = conf
self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None)
self.timeout = httpx.Timeout(10.0, read=16.0)
self._candle_sem = asyncio.Semaphore(3)
def _base_url(self) -> str:
return str(self.conf.api_base).rstrip("/")
def _futures_prefix(self) -> str:
return f"{self._base_url()}/futures/{self.conf.settle.strip().lower()}"
def _client_kwargs(self, timeout: httpx.Timeout) -> dict:
if self._proxy:
return {"timeout": timeout, "proxy": self._proxy, "trust_env": False}
return {"timeout": timeout, "trust_env": True}
def symbol_to_swap_inst_id(self, symbol: str) -> str:
base = symbol.strip().upper()
return f"{base}_{self.conf.quote_currency.upper()}"
def inst_id_to_base_symbol(self, inst_id: str) -> str:
inst = inst_id.strip().upper()
suf = f"_{self.conf.quote_currency.upper()}"
if inst.endswith(suf):
return inst[: -len(suf)]
return inst.split("_")[0].upper() if "_" in inst else inst
async def _fetch_contracts(self) -> list[dict[str, Any]]:
url = f"{self._futures_prefix()}/contracts"
async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
raise RuntimeError(f"Gate contracts unexpected payload: {type(data)}")
return data
async def list_live_usdt_swap_inst_ids(self) -> list[str]:
"""全部 USDT 本位线性永续合约名(如 BTC_USDT),剔除交割/下架中的条目。"""
data = await self._fetch_contracts()
out: list[str] = []
for item in data:
if not isinstance(item, dict):
continue
if not _is_linear_usdt_perp_contract(item):
continue
name = str(item.get("name") or "").strip()
if name:
out.append(name)
return sorted(set(out))
async def get_perpetual_symbols(self) -> set[str]:
ids = await self.list_live_usdt_swap_inst_ids()
return {self.inst_id_to_base_symbol(i) for i in ids}
async def get_candles(self, inst_id: str, bar: str, limit: int = 120) -> list[list[str]]:
"""
返回按时间正序排列的 K 线列表(与旧 OKX 行格式对齐便于下游逻辑):
[ts_ms, o, h, l, c, vol, vol_dup, sum_quote, confirm]
"""
interval = _to_gate_interval(bar)
lim = max(1, min(int(limit), 2000))
url = f"{self._futures_prefix()}/candlesticks"
params = {"contract": inst_id, "interval": interval, "limit": str(lim)}
async with self._candle_sem:
await asyncio.sleep(0.12)
async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
raise RuntimeError(f"Gate candlesticks error: {payload}")
rows: list[list[str]] = []
for item in payload:
if isinstance(item, dict) and "t" in item:
rows.append(_candle_row(item))
rows.sort(key=lambda r: int(r[0]) if r and r[0].isdigit() else 0)
return rows
async def get_usdt_swap_est_quote_volume_map(self) -> dict[str, float]:
"""
合约名 -> 近 24h 计价币种成交额(USDT)。
优先使用 ticker 的 volume_24h_quote;缺失时再尝试简单估算。
"""
url = f"{self._futures_prefix()}/tickers"
tick_timeout = httpx.Timeout(15.0, read=90.0)
async with httpx.AsyncClient(**self._client_kwargs(tick_timeout)) as client:
resp = await client.get(url)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
raise RuntimeError(f"Gate tickers error: {type(payload)}")
out: dict[str, float] = {}
for item in payload:
if not isinstance(item, dict):
continue
contract = str(item.get("contract") or "").strip()
if not contract.endswith("_USDT"):
continue
vol_quote = item.get("volume_24h_quote") or item.get("volume_24h_usd")
try:
if vol_quote is not None and str(vol_quote).strip():
out[contract] = max(0.0, float(vol_quote))
continue
last = float(item.get("last") or 0)
vol_base = float(item.get("volume_24h_base") or item.get("volume_24h") or 0)
out[contract] = max(0.0, vol_base * last)
except (TypeError, ValueError):
continue
return out
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any
import httpx
from .config import GemmaConfig
LOGGER = logging.getLogger("onchain_scout.gemma_client")
def _extract_json_object(text: str) -> dict[str, Any] | None:
text = text.strip()
m = re.search(r"\{[\s\S]*\}", text)
if not m:
return None
raw = m.group(0)
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
class OllamaGemmaClient:
def __init__(self, conf: GemmaConfig) -> None:
self.conf = conf
self.timeout = httpx.Timeout(conf.timeout_seconds, read=conf.timeout_seconds + 30.0)
async def rank_funnel(
self,
symbol: str,
programmatic_text: str,
ohlc_csv_block: str,
image_base64: str | None,
) -> dict[str, Any]:
"""
调用本地 Ollama,让 Gemma 按漏斗标准 JSON 回复。
"""
system = (
"你是加密货币永续合约的日线结构分析师。只输出一个 JSON 对象,不要 Markdown,不要代码围栏。"
"字段必须全部存在且为英文枚举/数字:"
'{"daily_structure":"strong|ok|weak",'
'"volume_view":"high|mid|low",'
'"upside_space":"high|mid|low",'
'"mid_resistance":"low|mid|high",'
'"priority":1-10整数,'
'"one_liner":"中文一句"}。'
"priority 越高越值得优先关注:成交大、日线结构好、上方空间大、中间阻力小则给高分。"
)
user_body = (
f"标的 {symbol} USDT 永续。\n"
f"程序化摘要:\n{programmatic_text}\n\n"
f"最近日线 OHLCV(时间正序最后一行为最新):\n{ohlc_csv_block}\n"
)
url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat"
message: dict[str, Any] = {"role": "user", "content": user_body}
if image_base64 and self.conf.send_chart_image:
message["images"] = [image_base64]
payload: dict[str, Any] = {
"model": self.conf.model,
"messages": [{"role": "system", "content": system}, message],
"stream": False,
"options": {"temperature": self.conf.temperature},
}
if self.conf.json_mode:
payload["format"] = "json"
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
parsed = _extract_json_object(msg) if msg else None
if parsed is None and isinstance(data.get("message"), dict):
parsed = _extract_json_object(str(data["message"]))
if parsed is None:
LOGGER.warning("gemma_parse_failed symbol=%s raw_len=%s", symbol, len(msg))
return {
"error": "parse_failed",
"raw": msg[:2000],
"daily_structure": "weak",
"volume_view": "low",
"upside_space": "low",
"mid_resistance": "high",
"priority": 1,
"one_liner": "模型输出无法解析为 JSON",
}
return _normalize_gemma_dict(parsed)
async def generate_daily_report(self, report_day_cn: str, btc_snapshot: dict, stats: dict) -> dict[str, Any]:
system = (
"你是加密交易复盘助手。输出严格 JSON 对象,不要 Markdown。字段必须存在:"
'{"headline":"...","btc_explain":"...","summary":"...","risk_points":["..."],"action_hint":"..."}。'
"用中文,简洁专业,不写投资建议免责声明。"
)
user_body = (
f"请生成 {report_day_cn} 的晨报。\n"
f"BTC 快照: {json.dumps(btc_snapshot, ensure_ascii=False)}\n"
f"昨日统计: {json.dumps(stats, ensure_ascii=False)}\n"
"要求:1) headline 一句话;2) btc_explain 解释方向;"
"3) summary 覆盖 WATCH/TRIGGER/漏斗;4) risk_points 给1-3条;5) action_hint 给执行提示。"
)
url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat"
payload: dict[str, Any] = {
"model": self.conf.model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user_body}],
"stream": False,
"options": {"temperature": 0.1},
"format": "json",
}
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
parsed = _extract_json_object(msg) if msg else None
if parsed is None:
return {"error": "parse_failed", "raw": msg[:1200]}
risk = parsed.get("risk_points")
if not isinstance(risk, list):
risk = [str(risk or "")]
risk = [str(x)[:120] for x in risk if str(x or "").strip()][:3] or ["注意高波动时的回撤风险。"]
return {
"headline": str(parsed.get("headline") or "")[:120],
"btc_explain": str(parsed.get("btc_explain") or "")[:220],
"summary": str(parsed.get("summary") or "")[:360],
"risk_points": risk,
"action_hint": str(parsed.get("action_hint") or "")[:220],
}
def _normalize_gemma_dict(d: dict[str, Any]) -> dict[str, Any]:
def _enum(v: Any, choices: set[str], default: str) -> str:
s = str(v or "").strip().lower()
return s if s in choices else default
try:
pr = int(float(d.get("priority", 1)))
except (TypeError, ValueError):
pr = 1
pr = max(1, min(10, pr))
return {
"daily_structure": _enum(d.get("daily_structure"), {"strong", "ok", "weak"}, "weak"),
"volume_view": _enum(d.get("volume_view"), {"high", "mid", "low"}, "low"),
"upside_space": _enum(d.get("upside_space"), {"high", "mid", "low"}, "low"),
"mid_resistance": _enum(d.get("mid_resistance"), {"low", "mid", "high"}, "high"),
"priority": pr,
"one_liner": str(d.get("one_liner") or "")[:280],
}
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import uvicorn
from .config import load_settings
from .web import create_app
def setup_logging(log_file: str) -> None:
path = Path(log_file).resolve()
path.parent.mkdir(parents=True, exist_ok=True)
fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers.clear()
fh = RotatingFileHandler(path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
fh.setFormatter(fmt)
sh = logging.StreamHandler()
sh.setFormatter(fmt)
root.addHandler(fh)
root.addHandler(sh)
def build_app(config_path: str = "config.yaml"):
settings = load_settings(config_path)
setup_logging(settings.app.log_file)
return create_app(settings)
app = build_app()
if __name__ == "__main__":
settings = load_settings("config.yaml")
setup_logging(settings.app.log_file)
uvicorn.run(
"app.main:app",
host=settings.app.host,
port=settings.app.port,
workers=1,
log_level="info",
)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class AlertRecord(Base):
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(32), index=True)
chain: Mapped[str] = mapped_column(String(32), index=True)
trigger_types: Mapped[str] = mapped_column(String(255))
score: Mapped[float] = mapped_column(Float)
details_json: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class RuntimeLog(Base):
__tablename__ = "runtime_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
level: Mapped[str] = mapped_column(String(12), index=True)
message: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class KvStore(Base):
"""Simple key-value settings persisted in SQLite (e.g. chart bar from web UI)."""
__tablename__ = "kv_store"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+771
View File
@@ -0,0 +1,771 @@
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from .btc_regime import evaluate_btc_daily_gate
from .chart_candles import daily_candles_png_base64
from .config import Settings, WatchSymbol
from .daily_features import (
build_daily_programmatic,
composite_score,
daily_ohlc_text_block,
programmatic_scores,
)
from .exchange_rules import IntradayRuleParams, evaluate_exchange
from .notifier import WeComNotifier
from .order_executor_forward import build_order_executor_payload, forward_signal_to_executors
from .order_executors_store import read_forward_config, record_last_forward
from .gate import GateClient
from .storage import Storage
if TYPE_CHECKING:
from .gemma_client import OllamaGemmaClient
LOGGER = logging.getLogger("onchain_scout.monitor")
FIXED_BAR = "5m"
# 最近 8 墙钟小时 ≈ 32 根 15m K
BTC_15M_BARS_PER_8H = 32
@dataclass
class RuntimeState:
started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
last_cycle_at: str = ""
last_cycle_status: str = "INIT"
last_cycle_msg: str = ""
chart_bar: str = FIXED_BAR
universe: str = "all_swaps"
intraday_params: dict = field(default_factory=dict)
monitoring_pool: list[dict] = field(default_factory=list)
perpetual_symbols_count: int = 0
monitored_inst_count: int = 0
pushed_alerts_count: int = 0
btc_gate_allow: bool = True
btc_gate_regime: str = ""
btc_gate_reason: str = ""
btc_gate_metrics: dict = field(default_factory=dict)
btc_env_8h_15m: str = ""
symbol_blocklist_count: int = 0
symbol_blocklist_removed: int = 0
last_funnel: list[dict] = field(default_factory=list)
last_funnel_at: str = ""
gemma_cycle_msg: str = ""
class MonitorService:
def __init__(
self,
settings: Settings,
storage: Storage,
gate_client: GateClient,
notifier: WeComNotifier,
gemma_client: OllamaGemmaClient | None = None,
) -> None:
self.settings = settings
self.storage = storage
self.gate = gate_client
self.notifier = notifier
self.gemma_client = gemma_client
self.state = RuntimeState()
self._lock = asyncio.Lock()
self._funnel_bg_task: asyncio.Task[None] | None = None
@staticmethod
def _symbol_blocklist_from_kv(raw: str | None) -> frozenset[str]:
if not raw or not str(raw).strip():
return frozenset()
try:
data = json.loads(raw)
except json.JSONDecodeError:
return frozenset()
if not isinstance(data, list):
return frozenset()
out: set[str] = set()
for x in data:
s = str(x).strip().upper()
if s:
out.add(s)
return frozenset(out)
async def _maybe_forward_order_executor(self, sym: str, inst: str, push_metrics: dict) -> None:
fwd = read_forward_config(self.settings)
if not fwd.get("enabled"):
return
secret = str(fwd.get("webhook_secret") or "").strip()
if not secret:
await self.storage.add_log(
"WARN",
"order_executor.enabled=true but webhook_secret is empty; skip POST /v1/signal",
)
return
targets = list(fwd.get("executors") or [])
if not targets:
await self.storage.add_log(
"WARN",
"order_executor.enabled=true but no enabled executor in panel list; skip POST /v1/signal",
)
return
payload = build_order_executor_payload(inst_id=inst, metrics=push_metrics)
if not payload:
await self.storage.add_log(
"WARN",
f"order_executor_build_payload_failed sym={sym} inst={inst}",
)
return
try:
results = await forward_signal_to_executors(
self.settings,
executors=targets,
webhook_secret=secret,
timeout_seconds=float(fwd.get("timeout_seconds") or 15.0),
payload=payload,
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log(
"ERROR",
f"order_executor_forward_exception sym={sym} inst={inst}: {exc}",
)
return
for out in results:
name = out.get("name") or "?"
eid = str(out.get("executor_id") or "")
status = out.get("http_status")
ok = out.get("ok")
body = out.get("body") or {}
st = body.get("status") if isinstance(body, dict) else None
detail = out.get("error")
if not detail and isinstance(body, dict):
detail = body.get("reason") or body.get("detail")
try:
record_last_forward(
self.settings,
eid,
http_status=int(status or 0),
ok=bool(ok),
exec_status=str(st) if st is not None else None,
detail=str(detail) if detail is not None else None,
)
except Exception: # noqa: BLE001
pass
if ok:
await self.storage.add_log(
"INFO",
f"order_executor_ok name={name} sym={sym} inst={inst} http={status} exec_status={st}",
)
else:
await self.storage.add_log(
"ERROR",
f"order_executor_failed name={name} sym={sym} inst={inst} http={status} body={body!r} err={detail}",
)
@staticmethod
def _btc_intraday_bias(btc_rows: list[list[str]]) -> str:
closes: list[float] = []
for row in btc_rows:
if len(row) < 5:
continue
try:
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
if len(closes) < 2:
return "NEUTRAL"
if closes[-1] > closes[-2]:
return "BULL"
if closes[-1] < closes[-2]:
return "BEAR"
return "NEUTRAL"
@staticmethod
def _ema(values: list[float], period: int) -> float:
if not values:
return 0.0
p = max(1, period)
alpha = 2.0 / (p + 1.0)
ema = values[0]
for v in values[1:]:
ema = alpha * v + (1 - alpha) * ema
return ema
@staticmethod
def _status_by_ema55(rows: list[list[str]]) -> str:
closes: list[float] = []
highs: list[float] = []
lows: list[float] = []
for row in rows:
if len(row) < 5:
continue
try:
highs.append(float(row[2]))
lows.append(float(row[3]))
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
if len(closes) < 55:
return "横盘"
ema55 = MonitorService._ema(closes[-120:], 55)
last = closes[-1]
lb = min(21, len(closes))
h = max(highs[-lb:])
l = min(lows[-lb:])
mid = (h + l) / 2 if h > l else 0.0
range_pct = ((h - l) / mid * 100.0) if mid > 0 else 999.0
if range_pct <= 2.0:
return "横盘"
if last >= ema55:
return "多头"
return "空头"
@staticmethod
def _btc_env_15m_last_8h(rows_15m: list[list[str]]) -> str:
"""最近 8 小时内 BTC 15m 走势:多头 / 空头 / 横盘(窄幅视为横盘)。"""
closes: list[float] = []
highs: list[float] = []
lows: list[float] = []
for row in rows_15m:
if len(row) < 5:
continue
try:
highs.append(float(row[2]))
lows.append(float(row[3]))
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
need = BTC_15M_BARS_PER_8H
if len(closes) < need:
return "横盘"
h_win = max(highs[-need:])
l_win = min(lows[-need:])
mid = (h_win + l_win) / 2 if h_win > l_win else 0.0
range_pct = ((h_win - l_win) / mid * 100.0) if mid > 0 else 999.0
if range_pct <= 1.8:
return "横盘"
warmup = min(len(closes), 96)
seq = closes[-warmup:]
if len(seq) < 21:
return "横盘"
ema21 = MonitorService._ema(seq, 21)
last = closes[-1]
if last >= ema21:
return "多头"
return "空头"
@staticmethod
def _push_matches_btc_env(btc_env: str, signal_side: str) -> bool:
if signal_side not in {"LONG", "SHORT"}:
return False
if btc_env == "横盘":
return True
if btc_env == "多头":
return signal_side == "LONG"
if btc_env == "空头":
return signal_side == "SHORT"
return False
@staticmethod
def _within_push_window_utc8(enabled: bool) -> bool:
if not enabled:
return True
now_utc = datetime.now(timezone.utc)
bj_hour = (now_utc.hour + 8) % 24
return 9 <= bj_hour < 23
async def run_cycle(self) -> None:
funnel_candidates: list[dict] = []
async with self._lock:
try:
funnel_candidates = await self._run_cycle_inner()
self.state.last_cycle_status = "OK"
self.state.last_cycle_msg = "cycle_completed"
except Exception as exc: # noqa: BLE001
msg = f"cycle_failed: {exc}"
self.state.last_cycle_status = "ERROR"
self.state.last_cycle_msg = msg
LOGGER.exception(msg)
await self.storage.add_log("ERROR", msg)
funnel_candidates = []
finally:
# 本轮扫描结束后刷新,避免 HUD「LAST」与墙钟脱节(原先在周期开始时写入)
self.state.last_cycle_at = datetime.now(timezone.utc).isoformat()
if self.gemma_client and self.settings.gemma.enabled and funnel_candidates:
if self._funnel_bg_task is not None and not self._funnel_bg_task.done():
await self.storage.add_log(
"WARN",
f"funnel_skipped_previous_still_running candidates={len(funnel_candidates)}",
)
else:
self._funnel_bg_task = asyncio.create_task(
self._run_gemma_funnel_safe(funnel_candidates),
name="gemma_funnel",
)
async def _run_gemma_funnel_safe(self, candidates: list[dict]) -> None:
try:
await self._run_gemma_funnel(candidates)
except Exception as exc: # noqa: BLE001
msg = f"funnel_failed: {exc}"
LOGGER.exception(msg)
await self.storage.add_log("ERROR", msg)
async with self._lock:
self.state.gemma_cycle_msg = f"funnel_failed: {exc!s}"[:500]
async def _run_cycle_inner(self) -> list[dict]:
bar = FIXED_BAR
self.state.chart_bar = bar
universe = self.settings.monitor.universe
self.state.universe = universe
rule_params = await self._load_intraday_params()
stop_buffer_pct = _as_float(await self.storage.get_kv("intraday_stop_buffer_pct"), 0.2)
stop_buffer_pct = max(0.0, min(stop_buffer_pct, 10.0))
self.state.intraday_params = {
"range_hours": rule_params.range_hours,
"range_max_pct": rule_params.range_max_pct,
"volume_spike_mult": rule_params.volume_spike_mult,
"volume_lookback_bars": rule_params.volume_lookback_bars,
"breakout_buffer_pct": rule_params.breakout_buffer_pct,
"stop_buffer_pct": stop_buffer_pct,
}
blocklist = self._symbol_blocklist_from_kv(await self.storage.get_kv("monitor_symbol_blocklist"))
self.state.symbol_blocklist_count = len(blocklist)
self.state.symbol_blocklist_removed = 0
listed_bases = await self.gate.get_perpetual_symbols()
self.state.perpetual_symbols_count = len(listed_bases)
min_vol = float(self.settings.monitor.min_24h_quote_volume_usdt)
vol_map: dict[str, float] = {}
watch_insts: list[str] = []
if universe == "all_swaps":
if min_vol <= 0:
await self.storage.add_log(
"ERROR",
"all_swaps requires monitor.min_24h_quote_volume_usdt > 0; skipping cycle",
)
self.state.monitoring_pool = []
self.state.monitored_inst_count = 0
return []
vol_map = await self.gate.get_usdt_swap_est_quote_volume_map()
all_ids = await self.gate.list_live_usdt_swap_inst_ids()
watch_insts = [i for i in all_ids if vol_map.get(i, 0.0) >= min_vol]
await self.storage.add_log(
"INFO",
f"universe=all_swaps bar={bar} min_usdt={min_vol:.0f} pool={len(watch_insts)}/{len(all_ids)}",
)
else:
watchlist = [w for w in self.settings.watch_symbols if w.symbol.upper() in listed_bases]
if min_vol > 0:
vol_map = await self.gate.get_usdt_swap_est_quote_volume_map()
before = len(watchlist)
kept: list[WatchSymbol] = []
for w in watchlist:
inst = self.gate.symbol_to_swap_inst_id(w.symbol)
est = vol_map.get(inst, 0.0)
if est >= min_vol:
kept.append(w)
else:
await self.storage.add_log(
"INFO",
f"{w.symbol.upper()} skipped_24h_vol est_usdt={est:.0f} < min={min_vol:.0f}",
)
watchlist = kept
await self.storage.add_log(
"INFO",
f"universe=watchlist volume_filter min_usdt={min_vol:.0f} kept={len(watchlist)}/{before}",
)
watch_insts = [self.gate.symbol_to_swap_inst_id(w.symbol) for w in watchlist]
await self.storage.add_log(
"INFO",
f"universe=watchlist bar={bar} pool={len(watch_insts)} gate_bases={len(listed_bases)}",
)
if blocklist:
before_bl = len(watch_insts)
watch_insts = [
i for i in watch_insts if self.gate.inst_id_to_base_symbol(i) not in blocklist
]
removed = before_bl - len(watch_insts)
self.state.symbol_blocklist_removed = removed
if removed:
await self.storage.add_log(
"INFO",
f"symbol_blocklist removed={removed} pool_now={len(watch_insts)} rules={len(blocklist)}",
)
self.state.monitored_inst_count = len(watch_insts)
push_window_enabled = _as_bool(await self.storage.get_kv("intraday_push_time_window_enabled"), True)
vol_rank_map: dict[str, int] = {}
vol_rank_total = len(watch_insts)
if vol_map and watch_insts:
sorted_insts = sorted(watch_insts, key=lambda x: float(vol_map.get(x, 0.0)), reverse=True)
vol_rank_map = {inst_id: idx + 1 for idx, inst_id in enumerate(sorted_insts)}
self.state.monitoring_pool = []
for inst in watch_insts:
sym = self.gate.inst_id_to_base_symbol(inst)
entry: dict = {"symbol": sym, "instId": inst}
if vol_map:
entry["est_quote_vol_24h_usdt"] = round(vol_map.get(inst, 0.0), 2)
self.state.monitoring_pool.append(entry)
btc_inst = self.gate.symbol_to_swap_inst_id("BTC")
if self.settings.monitor.btc_daily_gate_enabled:
btc_1d = await self.gate.get_candles(btc_inst, "1D", limit=60)
gate = evaluate_btc_daily_gate(
btc_1d,
sideways_lookback_days=self.settings.monitor.btc_sideways_lookback_days,
sideways_max_range_pct=self.settings.monitor.btc_sideways_max_range_pct,
)
self.state.btc_gate_allow = gate.allow_alt_scan
self.state.btc_gate_regime = gate.regime
self.state.btc_gate_reason = gate.reason
self.state.btc_gate_metrics = dict(gate.metrics)
if not gate.allow_alt_scan:
await self.storage.add_log(
"INFO",
(
f"btc_daily_gate regime={gate.regime} reason={gate.reason} "
f"(informational only; scan continues) metrics={gate.metrics}"
),
)
else:
self.state.btc_gate_allow = True
self.state.btc_gate_regime = "disabled"
self.state.btc_gate_reason = "btc_daily_gate_enabled=false"
self.state.btc_gate_metrics = {}
btc_rows = await self.gate.get_candles(btc_inst, bar, limit=120)
btc_bias_5m = self._btc_intraday_bias(btc_rows)
btc_15m_rows = await self.gate.get_candles(btc_inst, "15m", limit=120)
btc_env_8h_15m = self._btc_env_15m_last_8h(btc_15m_rows)
self.state.btc_env_8h_15m = btc_env_8h_15m
await self.storage.add_log(
"INFO",
f"btc_intraday_bias_5m={btc_bias_5m} btc_env_8h_15m={btc_env_8h_15m}",
)
funnel_candidates: list[dict] = []
for inst in watch_insts:
sym = self.gate.inst_id_to_base_symbol(inst)
try:
alt_rows = await self.gate.get_candles(inst, bar, limit=120)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"{sym} candles_failed: {exc}")
continue
result = evaluate_exchange(sym, alt_rows, btc_rows, rule_params)
if result.signal_level in {"WATCH", "TRIGGER"}:
est_vol = float(vol_map.get(inst, 0.0)) if vol_map else 0.0
signal_side = str((result.metrics or {}).get("signal_side") or result.signal_side or "NONE")
push_allowed = result.signal_level == "TRIGGER"
funnel_candidates.append(
{
"symbol": sym,
"inst": inst,
"est_vol": est_vol,
"est_vol_rank": int(vol_rank_map.get(inst, 0)) if vol_rank_map else 0,
"est_vol_rank_total": int(vol_rank_total),
"signal_level": result.signal_level,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"push_allowed": push_allowed,
"btc_env_8h_15m": btc_env_8h_15m,
"intraday_metrics": dict(result.metrics),
}
)
dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours)
chain_suffix = signal_side if signal_side in {"LONG", "SHORT"} else "NONE"
surface_chain = f"GATE-USDT {bar} {result.signal_level} {chain_suffix}"
skip_surface_alert = dedupe_h > 0 and await self.storage.has_recent_alert(
sym, chain=surface_chain, within_hours=dedupe_h
)
if not skip_surface_alert:
symbol_4h_status = "横盘"
symbol_side_ok = False
push_time_ok = True
vol_rank_ok = True
rank_max = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0)
if result.signal_level == "TRIGGER":
try:
sym_4h_rows = await self.gate.get_candles(inst, "4H", limit=120)
symbol_4h_status = self._status_by_ema55(sym_4h_rows)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"{sym} 4h_status_failed: {exc}")
symbol_side_ok = (signal_side == "LONG" and symbol_4h_status == "多头") or (
signal_side == "SHORT" and symbol_4h_status == "空头"
)
push_time_ok = self._within_push_window_utc8(push_window_enabled)
if rank_max > 0:
if vol_rank_map:
rnk = int(vol_rank_map.get(inst, 999))
vol_rank_ok = 1 <= rnk <= rank_max
else:
vol_rank_ok = False
btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side)
strict_push_ok = bool(
push_allowed and symbol_side_ok and push_time_ok and vol_rank_ok and btc_env_ok
)
push_reason = "trigger_pushed"
if result.signal_level == "TRIGGER" and not strict_push_ok:
reasons: list[str] = []
if not btc_env_ok:
reasons.append("btc_env_8h_15m_direction_mismatch")
if not symbol_side_ok:
reasons.append("symbol_4h_not_aligned")
if not push_time_ok:
reasons.append("outside_push_time_window")
if not vol_rank_ok:
reasons.append("volume_rank_outside_top_n")
push_reason = ",".join(reasons) if reasons else "filtered_by_rules"
await self.storage.add_alert(
symbol=sym,
venue=surface_chain,
trigger_types=result.trigger_types,
score=result.score,
details={
"metrics": result.metrics,
"instId": inst,
"signal_level": result.signal_level,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"push_allowed": push_allowed,
"btc_env_8h_15m": btc_env_8h_15m,
"btc_env_ok": btc_env_ok,
"symbol_4h_status": symbol_4h_status,
"push_time_ok": push_time_ok,
"vol_rank_ok": vol_rank_ok,
"strict_push_ok": strict_push_ok,
"push_block_reason": push_reason,
},
)
if result.signal_level == "TRIGGER":
if strict_push_ok:
push_metrics = dict(result.metrics)
push_metrics["signal_side"] = signal_side
push_metrics["btc_bias"] = btc_bias_5m
push_metrics["btc_env_8h_15m"] = btc_env_8h_15m
push_metrics["symbol_4h_status"] = symbol_4h_status
push_metrics["est_quote_vol_24h_usdt"] = est_vol
push_metrics["est_quote_vol_rank"] = int(vol_rank_map.get(inst, 0))
push_metrics["est_quote_vol_rank_total"] = int(vol_rank_total)
push_metrics["stop_buffer_pct"] = stop_buffer_pct
try:
await self.notifier.send_breakout_alert(
symbol=sym,
bar=bar,
inst_id=inst,
trigger_types=result.trigger_types,
metrics=push_metrics,
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"wecom_push_failed {sym}: {exc}")
else:
self.state.pushed_alerts_count += 1
await self._maybe_forward_order_executor(sym, inst, push_metrics)
else:
await self.storage.add_log(
"INFO",
(
f"signal_blocked sym={sym} side={signal_side} btc_bias={btc_bias_5m} "
f"btc_env_8h_15m={btc_env_8h_15m} btc_env_ok={btc_env_ok} "
f"sym_4h={symbol_4h_status} symbol_side_ok={symbol_side_ok} "
f"push_time_ok={push_time_ok} vol_rank_ok={vol_rank_ok} "
f"rank={vol_rank_map.get(inst, 0)}/{rank_max}"
),
)
await self.storage.add_log(
"WARN",
(
f"signal={result.signal_level} side={signal_side} {sym} bar={bar} "
f"push_allowed={push_allowed} triggers={','.join(result.trigger_types)}"
),
)
else:
await self.storage.add_log(
"INFO",
f"signal_dedupe_skip sym={sym} chain={surface_chain} within_h={dedupe_h}",
)
await asyncio.sleep(0.08)
if not self.settings.gemma.enabled:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "gemma_disabled"
elif not self.gemma_client:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "gemma_client_none"
elif not funnel_candidates:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "no_funnel_candidates"
else:
self.state.gemma_cycle_msg = "funnel_pending"
await self.storage.add_log(
"INFO",
f"cycle_scan_done monitored={len(watch_insts)} funnel_candidates={len(funnel_candidates)}",
)
return funnel_candidates
async def _run_gemma_funnel(self, candidates: list[dict]) -> None:
assert self.gemma_client is not None
cfg = self.settings.gemma
candidates.sort(key=lambda x: float(x.get("est_vol") or 0.0), reverse=True)
take = candidates[: max(1, cfg.max_funnel_per_cycle)]
out: list[dict] = []
dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours)
for i, c in enumerate(take):
sym = str(c["symbol"])
inst = str(c["inst"])
est = float(c.get("est_vol") or 0.0)
if dedupe_h > 0 and await self.storage.has_recent_alert(
sym, chain="FUNNEL-GEMMA", within_hours=dedupe_h
):
await self.storage.add_log("INFO", f"funnel_dedupe_skip sym={sym} within_h={dedupe_h}")
continue
try:
rows_1d = await self.gate.get_candles(inst, "1D", limit=80)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"funnel {sym} 1d_failed: {exc}")
continue
prog = build_daily_programmatic(rows_1d, est)
subs = programmatic_scores(prog)
prog_text = json.dumps({**prog, **subs}, ensure_ascii=False)
ohlc_block = daily_ohlc_text_block(rows_1d)
img_b64: str | None = None
if cfg.send_chart_image and i < max(0, cfg.vision_top_n):
img_b64 = daily_candles_png_base64(rows_1d, sym)
try:
gemma_out = await self.gemma_client.rank_funnel(sym, prog_text, ohlc_block, img_b64)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"gemma_ollama_failed {sym}: {exc}")
gemma_out = {
"daily_structure": "weak",
"volume_view": "low",
"upside_space": "low",
"mid_resistance": "high",
"priority": 1,
"one_liner": f"Ollama 调用失败: {exc}",
"error": str(exc),
}
pri = float(gemma_out.get("priority", 1))
comp = composite_score(pri, subs)
signal_side = str(c.get("signal_side") or "NONE")
btc_bias_5m = str(c.get("btc_bias_5m") or "NEUTRAL")
btc_env_8h_15m = str(c.get("btc_env_8h_15m") or "横盘")
btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side)
threshold_ok = pri >= cfg.gemma_push_priority_min or comp >= cfg.composite_push_min
rank_max_f = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0)
vol_rank_ok_f = True
if rank_max_f > 0:
rnk_f = int(c.get("est_vol_rank") or 999)
vol_rank_ok_f = 1 <= rnk_f <= rank_max_f
should_push = btc_env_ok and threshold_ok and vol_rank_ok_f
gemma_clean = {k: v for k, v in gemma_out.items() if k not in {"raw", "error"}}
details: dict = {
"source": "gemma_funnel",
"underlying_signal": c.get("signal_level"),
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"btc_env_8h_15m": btc_env_8h_15m,
"gemma": gemma_clean,
"programmatic": prog,
"programmatic_subscores": subs,
"composite_score": comp,
"priority_push": should_push,
"priority_threshold_ok": threshold_ok,
"btc_env_ok": btc_env_ok,
"volume_rank_ok": vol_rank_ok_f,
"instId": inst,
"image_sent": bool(img_b64),
"intraday_signal_metrics": c.get("intraday_metrics"),
}
if gemma_out.get("error"):
details["gemma_error"] = str(gemma_out.get("error"))[:500]
raw_snip = gemma_out.get("raw")
if isinstance(raw_snip, str) and raw_snip:
details["gemma_raw_snip"] = raw_snip[:800]
await self.storage.add_alert(
symbol=sym,
venue="FUNNEL-GEMMA",
trigger_types=["漏斗", f"P{int(pri)}", str(gemma_out.get("daily_structure", "?"))],
score=comp,
details=details,
)
if should_push:
try:
await self.notifier.send_funnel_priority(
symbol=sym,
inst_id=inst,
composite_score=comp,
gemma=gemma_clean,
programmatic=prog,
)
self.state.pushed_alerts_count += 1
await self.storage.add_log(
"WARN",
f"funnel_priority_push {sym} composite={comp} priority={pri}",
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"funnel_wecom_push_failed {sym}: {exc}")
out.append(
{
"symbol": sym,
"composite_score": comp,
"gemma_priority": pri,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"pushed": should_push,
"one_liner": gemma_clean.get("one_liner", ""),
}
)
await asyncio.sleep(0.35)
out.sort(key=lambda x: float(x.get("composite_score") or 0.0), reverse=True)
msg = f"funnel_ranked={len(out)}"
async with self._lock:
self.state.last_funnel = out[:40]
self.state.last_funnel_at = datetime.now(timezone.utc).isoformat()
self.state.gemma_cycle_msg = msg
await self.storage.add_log("INFO", msg)
async def _load_intraday_params(self) -> IntradayRuleParams:
range_hours = _as_float(await self.storage.get_kv("intraday_range_hours"), 24.0)
range_max_pct = _as_float(await self.storage.get_kv("intraday_range_max_pct"), 1.5)
volume_spike_mult = _as_float(await self.storage.get_kv("intraday_volume_spike_mult"), 1.6)
volume_lookback_bars = int(_as_float(await self.storage.get_kv("intraday_volume_lookback_bars"), 20))
breakout_buffer_pct = _as_float(await self.storage.get_kv("intraday_breakout_buffer_pct"), 0.05)
return IntradayRuleParams(
range_hours=max(1.0, range_hours),
range_max_pct=max(0.1, range_max_pct),
volume_spike_mult=max(1.0, volume_spike_mult),
volume_lookback_bars=max(5, volume_lookback_bars),
breakout_buffer_pct=max(0.0, breakout_buffer_pct),
)
def _as_float(raw: str | None, default: float) -> float:
try:
return float(raw) if raw is not None else default
except (TypeError, ValueError):
return default
def _as_bool(raw: str | None, default: bool) -> bool:
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import httpx
from .config import WeComConfig
from .proxy_util import httpx_proxy_url
from .time_cn import format_beijing_wall, utc_now
class WeComNotifier:
def __init__(self, conf: WeComConfig, proxy_url: str | None = None) -> None:
self.conf = conf
self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None)
self.timeout = httpx.Timeout(8.0, read=10.0)
def _client_kwargs(self) -> dict:
if self._proxy:
return {"timeout": self.timeout, "proxy": self._proxy, "trust_env": False}
return {"timeout": self.timeout, "trust_env": True}
async def send_breakout_alert(
self,
symbol: str,
bar: str,
inst_id: str,
trigger_types: list[str],
metrics: dict,
) -> None:
sym_u = symbol.strip().upper()
pair_line = f"{sym_u}-USDT 永续"
bar_cn = "5分钟" if bar == "5m" else f"{bar}"
range_h = float(metrics.get("range_hours") or 8)
range_pct = float(metrics.get("range_pct") or 0)
vol_ratio = float(metrics.get("volume_ratio") or 0)
range_high = float(metrics.get("range_high") or 0.0)
range_low = float(metrics.get("range_low") or 0.0)
confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0)
breakout_high = float(metrics.get("breakout_high") or 0.0)
breakout_low = float(metrics.get("breakout_low") or 0.0)
est_vol = float(metrics.get("est_quote_vol_24h_usdt") or 0.0)
est_vol_rank = int(metrics.get("est_quote_vol_rank") or 0)
est_vol_rank_total = int(metrics.get("est_quote_vol_rank_total") or 0)
btc_env_8h_15m = str(metrics.get("btc_env_8h_15m") or metrics.get("btc_8h_status") or "横盘")
symbol_4h_status = str(metrics.get("symbol_4h_status") or "横盘")
def _px(x: float) -> str:
s = f"{x:.8f}".rstrip("0").rstrip(".")
return s or "0"
signal_side = str(metrics.get("signal_side") or "NONE")
signal_cn = "多头突破" if signal_side == "LONG" else ("空头破位" if signal_side == "SHORT" else "方向未定")
dir_line = "做多突破" if signal_side == "LONG" else ("做空破位" if signal_side == "SHORT" else "方向未定")
move_line = "放量上破" if signal_side == "LONG" else ("放量下破" if signal_side == "SHORT" else "等待确认")
state_line = f"{range_h:g}小时横盘箱体 {move_line}"
vol24_line = f"{est_vol:,.0f} USDT" if est_vol > 0 else "未知"
rank_line = (
f"#{est_vol_rank} / {est_vol_rank_total}"
if est_vol_rank > 0 and est_vol_rank_total > 0
else "未知"
)
key_ref = _px(range_low if signal_side == "LONG" else range_high)
stop_pct = float(metrics.get("stop_buffer_pct") or 0.2)
stop_pct = max(0.0, min(stop_pct, 10.0))
long_m = 1.0 - stop_pct / 100.0
short_m = 1.0 + stop_pct / 100.0
stop_a = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m)
stop_b = _px(range_low * long_m if signal_side == "LONG" else range_high * short_m)
if abs(stop_pct - round(stop_pct)) < 1e-9:
stop_pct_label = str(int(round(stop_pct)))
else:
stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0"
box_size = (range_high - range_low)
tp_a = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size)
tp_b = _px(confirm_close + box_size * 1.5 if signal_side == "LONG" else confirm_close - box_size * 1.5)
t_cn = format_beijing_wall(utc_now())
content = (
"🚨 Gate 突破预警信号\n"
"━━━━━━━━━━━━━━\n"
f"🔹 交易对:{pair_line}\n"
f"⏱️ K线周期:{bar_cn}\n"
f"📊 行情状态:{state_line}\n"
f"🧭 信号方向:{dir_line}\n"
"✅ 确认条件:\n"
f" 1. 震荡幅度:{range_pct:.2f}%\n"
f" 2. 成交量放大:{vol_ratio:.2f}\n"
f" 3. BTC 近8小时(15m){btc_env_8h_15m}(横盘多空均可推送;涨→仅LONG;跌→仅SHORT)\n"
f" 4. 日成交量:{vol24_line}\n"
f" 5. 当日成交量排名:{rank_line}\n"
f" 6. 本币种4h状态:{symbol_4h_status}(仅同向推送)\n"
"📌 关键价位:\n"
f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}{key_ref}\n"
f" 确认K收盘价:{_px(confirm_close)}\n"
"💡 操作提示:\n"
f" 1. 入场区间A:止盈 {signal_cn} 箱体1.0倍距离({tp_a}),止损 突破K高低点±{stop_pct_label}%{stop_a}\n"
f" 2. 入场区间B:止盈 {signal_cn} 箱体1.5倍距离({tp_b}),止损 箱体边沿±{stop_pct_label}%{stop_b}\n"
f"⏰ 触发时间:{t_cn}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_mobile_list": self.conf.mentioned_mobile_list,
},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
async def send_funnel_priority(
self,
symbol: str,
inst_id: str,
composite_score: float,
gemma: dict,
programmatic: dict,
) -> None:
sym_u = symbol.strip().upper()
pair_line = f"{sym_u}-USDT 永续"
pri = gemma.get("priority", "?")
one = str(gemma.get("one_liner", "") or "").strip()
t_cn = format_beijing_wall(utc_now())
def _pg(key: str, default: str = "") -> str:
v = programmatic.get(key)
if v is None:
return default
if isinstance(v, bool):
return "" if v else ""
if isinstance(v, (int, float)):
return f"{float(v):.6f}".rstrip("0").rstrip(".") or "0"
return str(v)
vol24 = programmatic.get("est_quote_vol_24h_usdt")
vol24_s = f"{float(vol24):,.0f}" if isinstance(vol24, (int, float)) else str(vol24 or "")
prog_lines = [
f" · 现价:{_pg('last_close')}",
f" · 24h 估算成交额 USDT{vol24_s}",
f" · 60日区间高 / 低:{_pg('range_60d_high')} / {_pg('range_60d_low')}",
f" · 区间振幅%(回看):{_pg('range_pct_lookback')}",
f" · 距区间上沿空间%{_pg('upside_to_range_high_pct')}",
f" · 结构提示:{_pg('structure_hint')}",
f" · SMA20{_pg('sma20')}",
]
content = (
"🎯 MATRIX · 漏斗优先推送\n"
"━━━━━━━━━━━━━━\n"
f"🔹 交易对:{pair_line}\n"
f"🔗 合约 ID{inst_id}\n"
f"📈 合成评分:{composite_score:.2f}\n"
"🧩 Gemma 分项:\n"
f" 优先级 P{pri}|结构 {gemma.get('daily_structure', '?')}|量 {gemma.get('volume_view', '?')}"
f"上方 {gemma.get('upside_space', '?')}|中间阻力 {gemma.get('mid_resistance', '?')}\n"
"💬 一句话:\n"
f" {one}\n"
"📌 程序化摘录:\n"
+ "\n".join(prog_lines)
+ "\n"
"💡 操作提示:\n"
"仅结构信号,严格执行交易纪律+仓位管理\n"
f"⏰ 触发时间:{t_cn}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_mobile_list": self.conf.mentioned_mobile_list,
},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
async def send_daily_report(self, report: dict) -> None:
text = report.get("text") or {}
btc = report.get("btc") or {}
stats = report.get("stats") or {}
risk_lines = text.get("risk_points") or []
risk_block = "\n".join([f" - {str(x)}" for x in risk_lines[:3]]) if risk_lines else " - 暂无"
content = (
"🗞️ MATRIX 每日晨报\n"
"━━━━━━━━━━━━━━\n"
f"📅 复盘日期:{report.get('report_day_cn', '')}\n"
f"🤖 AI 生成:{'' if report.get('ai_used') else '否(规则回退)'}\n"
f"📈 BTC 方向:{btc.get('direction', '')} | 日涨跌 {btc.get('day_change_pct', '')}%\n"
f"🧭 方向说明:{text.get('btc_explain', '')}\n"
f"📊 昨日统计:WATCH {stats.get('watch_count', 0)} / TRIGGER {stats.get('trigger_count', 0)} / 漏斗优先 {stats.get('funnel_push_count', 0)}\n"
f"📝 总结:{text.get('summary', '')}\n"
f"⚠️ 风险点:\n{risk_block}\n"
f"🎯 执行提示:{text.get('action_hint', '')}\n"
f"⏰ 生成时间:{report.get('generated_at_cn', format_beijing_wall(utc_now()))}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {"content": content, "mentioned_mobile_list": self.conf.mentioned_mobile_list},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
@@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
import httpx
from .config import Settings
logger = logging.getLogger(__name__)
def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, Any] | None:
"""
与企微突破文案方案 A一致止盈 = 确认收盘 ± 1 倍箱宽止损 = 突破 K 高低点外侧 stop_buffer_pct默认 0.2%与面板一致
返回 gate_order_executor POST /v1/signal JSON无法构造则 None
"""
signal_side = str(metrics.get("signal_side") or "NONE")
if signal_side not in ("LONG", "SHORT"):
return None
range_high = float(metrics.get("range_high") or 0.0)
range_low = float(metrics.get("range_low") or 0.0)
confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0)
breakout_high = float(metrics.get("breakout_high") or 0.0)
breakout_low = float(metrics.get("breakout_low") or 0.0)
if confirm_close <= 0 or range_high <= range_low:
return None
box_size = range_high - range_low
stop_pct = float(metrics.get("stop_buffer_pct") or 0.2)
stop_pct = max(0.0, min(stop_pct, 10.0))
long_m = 1.0 - stop_pct / 100.0
short_m = 1.0 + stop_pct / 100.0
if signal_side == "LONG":
stop_loss = breakout_low * long_m
take_profit = confirm_close + box_size
else:
stop_loss = breakout_high * short_m
take_profit = confirm_close - box_size
if take_profit <= 0 or stop_loss <= 0:
return None
side = "long" if signal_side == "LONG" else "short"
ct = inst_id.strip().upper()
signal_id = f"scout-{ct}-{uuid.uuid4().hex[:12]}"
return {
"signal_id": signal_id,
"contract": ct,
"side": side,
"take_profit": float(take_profit),
"stop_loss": float(stop_loss),
"reference_price": float(confirm_close),
}
async def _post_one_executor(
*,
name: str,
executor_id: str,
base_url: str,
webhook_secret: str,
timeout_seconds: float,
payload: dict[str, Any],
) -> dict[str, Any]:
url = base_url.rstrip("/") + "/v1/signal"
t = float(timeout_seconds)
timeout = httpx.Timeout(t, connect=min(10.0, t), read=t + 5.0)
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=None) as client:
resp = await client.post(
url,
json=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Secret": webhook_secret,
},
)
try:
body: Any = resp.json()
except Exception: # noqa: BLE001
body = {"_raw": (resp.text or "")[:800]}
ok = resp.is_success
if not ok:
logger.warning(
"order_executor_http_error name=%s status=%s body=%s",
name,
resp.status_code,
body,
)
exec_status = body.get("status") if isinstance(body, dict) else None
return {
"executor_id": executor_id,
"name": name,
"base_url": base_url,
"http_status": resp.status_code,
"body": body,
"ok": ok,
"exec_status": exec_status,
"error": None,
}
except Exception as exc: # noqa: BLE001
logger.warning("order_executor_forward_exception name=%s: %s", name, exc)
return {
"executor_id": executor_id,
"name": name,
"base_url": base_url,
"http_status": 0,
"body": None,
"ok": False,
"exec_status": None,
"error": str(exc),
}
async def forward_signal_to_executors(
settings: Settings,
*,
executors: list[dict[str, Any]],
webhook_secret: str,
timeout_seconds: float,
payload: dict[str, Any],
) -> list[dict[str, Any]]:
"""
向多个执行器广播同一 signal直连 base_url不走 proxy
executors 每项需含 idnamebase_url
"""
secret = (webhook_secret or "").strip()
if not secret:
return []
if not executors:
return []
tasks = [
_post_one_executor(
name=str(ex.get("name") or "executor"),
executor_id=str(ex.get("id") or ""),
base_url=str(ex.get("base_url") or ""),
webhook_secret=secret,
timeout_seconds=timeout_seconds,
payload=payload,
)
for ex in executors
if (ex.get("base_url") or "").strip()
]
if not tasks:
return []
results = await asyncio.gather(*tasks)
return list(results)
async def forward_signal_after_wecom(settings: Settings, payload: dict[str, Any]) -> dict[str, Any]:
"""
兼容旧调用单执行器转发读取 runtime 中第一个 enabled 目标
新代码请使用 forward_signal_to_executors + order_executors_store.read_forward_config
"""
from .order_executors_store import read_forward_config
cfg = read_forward_config(settings)
rows = cfg.get("executors") or []
if not cfg.get("enabled") or not rows:
return {"ok": False, "error": "no_active_executor", "results": []}
results = await forward_signal_to_executors(
settings,
executors=rows[:1],
webhook_secret=str(cfg.get("webhook_secret") or ""),
timeout_seconds=float(cfg.get("timeout_seconds") or 15.0),
payload=payload,
)
one = results[0] if results else {}
return {
"http_status": one.get("http_status"),
"body": one.get("body"),
"ok": one.get("ok"),
"results": results,
}
@@ -0,0 +1,272 @@
"""执行器列表与转发全局设置:runtime/order_executors.json(仅扫描端维护,不支持执行器反向注册)。"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_STORE_PATH = _ROOT / "runtime" / "order_executors.json"
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def _read_unlocked() -> dict[str, Any] | None:
if not _STORE_PATH.is_file():
return None
try:
raw = _STORE_PATH.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("order_executors_read_failed: %s", exc)
return None
def _write_unlocked(data: dict[str, Any]) -> None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
tmp = _STORE_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_STORE_PATH)
def _default_from_settings(settings: Settings) -> dict[str, Any]:
oe = settings.order_executor
executors: list[dict[str, Any]] = []
base = (oe.base_url or "").strip()
if base:
executors.append(
{
"id": str(uuid.uuid4()),
"name": "default",
"base_url": base.rstrip("/"),
"enabled": True,
"created_at": _now_iso(),
"updated_at": _now_iso(),
"last_forward": None,
}
)
return {
"enabled": bool(oe.enabled),
"webhook_secret": str(oe.webhook_secret or ""),
"timeout_seconds": float(oe.timeout_seconds),
"executors": executors,
}
def ensure_store_initialized(settings: Settings) -> None:
"""首次启动:从 config.yaml 的 order_executor 段导入;已有文件则不覆盖。"""
with _lock:
if _read_unlocked() is not None:
return
_write_unlocked(_default_from_settings(settings))
logger.info("order_executors_store_initialized path=%s", _STORE_PATH)
def read_snapshot(settings: Settings) -> dict[str, Any]:
with _lock:
data = _read_unlocked()
if data is None:
ensure_store_initialized(settings)
with _lock:
data = _read_unlocked()
if data is None:
data = _default_from_settings(settings)
return _normalize_snapshot(data, settings)
def _normalize_snapshot(data: dict[str, Any], settings: Settings) -> dict[str, Any]:
oe = settings.order_executor
out: dict[str, Any] = {
"enabled": bool(data.get("enabled", oe.enabled)),
"webhook_secret": str(data.get("webhook_secret") if data.get("webhook_secret") is not None else oe.webhook_secret),
"timeout_seconds": float(data.get("timeout_seconds") or oe.timeout_seconds),
"executors": [],
}
raw_list = data.get("executors")
if isinstance(raw_list, list):
for row in raw_list:
if not isinstance(row, dict):
continue
eid = str(row.get("id") or "").strip() or str(uuid.uuid4())
name = str(row.get("name") or "executor").strip() or "executor"
url = str(row.get("base_url") or "").strip().rstrip("/")
if not url:
continue
out["executors"].append(
{
"id": eid,
"name": name,
"base_url": url,
"enabled": bool(row.get("enabled", True)),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"last_forward": row.get("last_forward") if isinstance(row.get("last_forward"), dict) else None,
}
)
return out
def active_executors(settings: Settings) -> list[dict[str, Any]]:
snap = read_snapshot(settings)
if not snap.get("enabled"):
return []
return [e for e in snap.get("executors") or [] if e.get("enabled")]
def read_forward_config(settings: Settings) -> dict[str, Any]:
snap = read_snapshot(settings)
return {
"enabled": bool(snap.get("enabled")),
"webhook_secret": str(snap.get("webhook_secret") or "").strip(),
"timeout_seconds": float(snap.get("timeout_seconds") or settings.order_executor.timeout_seconds),
"executors": active_executors(settings),
}
def write_global_settings(
settings: Settings,
*,
enabled: bool | None = None,
webhook_secret: str | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
if enabled is not None:
snap["enabled"] = bool(enabled)
if webhook_secret is not None:
snap["webhook_secret"] = str(webhook_secret)
if timeout_seconds is not None:
lo, hi = 3.0, 120.0
v = float(timeout_seconds)
if not (lo <= v <= hi):
raise ValueError(f"timeout_seconds must be in [{lo}, {hi}]")
snap["timeout_seconds"] = v
_write_unlocked(snap)
return read_snapshot(settings)
def _validate_base_url(base_url: str) -> str:
u = (base_url or "").strip().rstrip("/")
if not u:
raise ValueError("base_url_required")
p = urlparse(u)
if p.scheme not in ("http", "https") or not p.netloc:
raise ValueError("base_url_must_be_http_or_https")
return u
def add_executor(
settings: Settings,
*,
name: str,
base_url: str,
enabled: bool = True,
) -> dict[str, Any]:
nm = (name or "").strip() or "executor"
url = _validate_base_url(base_url)
row = {
"id": str(uuid.uuid4()),
"name": nm,
"base_url": url,
"enabled": bool(enabled),
"created_at": _now_iso(),
"updated_at": _now_iso(),
"last_forward": None,
}
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
for ex in snap["executors"]:
if str(ex.get("base_url") or "").rstrip("/") == url:
raise ValueError("base_url_already_exists")
snap["executors"].append(row)
_write_unlocked(snap)
return row
def update_executor(
settings: Settings,
executor_id: str,
*,
name: str | None = None,
base_url: str | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
eid = (executor_id or "").strip()
if not eid:
raise ValueError("executor_id_required")
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
found: dict[str, Any] | None = None
for ex in snap["executors"]:
if str(ex.get("id")) == eid:
found = ex
break
if not found:
raise ValueError("executor_not_found")
if name is not None:
found["name"] = (name or "").strip() or found.get("name") or "executor"
if base_url is not None:
url = _validate_base_url(base_url)
for ex in snap["executors"]:
if str(ex.get("id")) != eid and str(ex.get("base_url") or "").rstrip("/") == url:
raise ValueError("base_url_already_exists")
found["base_url"] = url
if enabled is not None:
found["enabled"] = bool(enabled)
found["updated_at"] = _now_iso()
_write_unlocked(snap)
return dict(found)
def delete_executor(settings: Settings, executor_id: str) -> None:
eid = (executor_id or "").strip()
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
before = len(snap["executors"])
snap["executors"] = [e for e in snap["executors"] if str(e.get("id")) != eid]
if len(snap["executors"]) == before:
raise ValueError("executor_not_found")
_write_unlocked(snap)
def record_last_forward(
settings: Settings,
executor_id: str,
*,
http_status: int,
ok: bool,
exec_status: str | None,
detail: str | None = None,
) -> None:
eid = (executor_id or "").strip()
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
for ex in snap["executors"]:
if str(ex.get("id")) == eid:
ex["last_forward"] = {
"at": _now_iso(),
"http_status": int(http_status),
"ok": bool(ok),
"exec_status": exec_status,
"detail": (detail or "")[:500] or None,
}
ex["updated_at"] = _now_iso()
break
_write_unlocked(snap)
+16
View File
@@ -0,0 +1,16 @@
"""代理 URL 与 httpx 的兼容处理。"""
def httpx_proxy_url(proxy_url: str | None) -> str | None:
"""
将配置中的代理地址转为 httpx 可用的形式
部分环境socksio / httpx不支持 ``socks5h://`` scheme会报
``Unknown scheme for proxy URL``此时退化为 ``socks5://``域名在本机解析后再走 SOCKS
"""
if not proxy_url or not str(proxy_url).strip():
return None
u = str(proxy_url).strip()
if u.startswith("socks5h://"):
return "socks5://" + u[len("socks5h://") :]
return u
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from sqlalchemy import desc, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from .models import AlertRecord, Base, KvStore, RuntimeLog
DEFAULT_CHART_BAR = "1D"
class Storage:
def __init__(self, database_url: str) -> None:
self.engine = create_async_engine(database_url, pool_pre_ping=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async def init_db(self) -> None:
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await self._ensure_default_kv()
async def _ensure_default_kv(self) -> None:
current = await self.get_kv("chart_bar")
if current is None:
await self.set_kv("chart_bar", DEFAULT_CHART_BAR)
async def get_kv(self, key: str) -> str | None:
async with self.session_factory() as session:
row = await session.get(KvStore, key)
return row.value if row else None
async def set_kv(self, key: str, value: str) -> None:
async with self.session_factory() as session:
await session.execute(
sqlite_insert(KvStore)
.values(key=key, value=value, updated_at=datetime.utcnow())
.on_conflict_do_update(
index_elements=["key"],
set_={"value": value, "updated_at": datetime.utcnow()},
)
)
await session.commit()
async def has_recent_alert(
self,
symbol: str,
*,
chain: str,
within_hours: float,
) -> bool:
"""同一 symbol + chain 在 within_hours 内是否已有告警(用于去重显示与推送)。"""
if within_hours <= 0:
return False
sym = symbol.strip().upper()
cutoff = datetime.utcnow() - timedelta(hours=within_hours)
async with self.session_factory() as session:
stmt = (
select(AlertRecord.id)
.where(
AlertRecord.symbol == sym,
AlertRecord.chain == chain,
AlertRecord.created_at > cutoff,
)
.limit(1)
)
row = (await session.execute(stmt)).scalar_one_or_none()
return row is not None
async def add_alert(
self,
symbol: str,
venue: str,
trigger_types: list[str],
score: float,
details: dict,
) -> None:
async with self.session_factory() as session:
session.add(
AlertRecord(
symbol=symbol.strip().upper(),
chain=venue,
trigger_types=",".join(trigger_types),
score=score,
details_json=json.dumps(details, ensure_ascii=False),
)
)
await session.commit()
async def add_log(self, level: str, message: str) -> None:
async with self.session_factory() as session:
session.add(RuntimeLog(level=level.upper(), message=message))
await session.commit()
async def get_recent_alerts(self, limit: int = 100) -> list[dict]:
async with self.session_factory() as session:
stmt = select(AlertRecord).order_by(desc(AlertRecord.created_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"symbol": row.symbol,
"chain": row.chain,
"trigger_types": row.trigger_types.split(",") if row.trigger_types else [],
"score": row.score,
"details": json.loads(row.details_json),
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def get_recent_logs(self, limit: int = 200) -> list[dict]:
async with self.session_factory() as session:
stmt = select(RuntimeLog).order_by(desc(RuntimeLog.created_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"level": row.level,
"message": row.message,
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def get_alerts_between(
self,
start_utc_naive: datetime,
end_utc_naive: datetime,
limit: int = 2000,
) -> list[dict]:
async with self.session_factory() as session:
stmt = (
select(AlertRecord)
.where(AlertRecord.created_at >= start_utc_naive, AlertRecord.created_at < end_utc_naive)
.order_by(desc(AlertRecord.created_at))
.limit(limit)
)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"symbol": row.symbol,
"chain": row.chain,
"trigger_types": row.trigger_types.split(",") if row.trigger_types else [],
"score": row.score,
"details": json.loads(row.details_json),
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def close(self) -> None:
await self.engine.dispose()
+21
View File
@@ -0,0 +1,21 @@
"""北京时间(Asia/Shanghai)格式化,用于推送与展示。"""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
_TZ_CN = ZoneInfo("Asia/Shanghai")
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def format_beijing_wall(dt: datetime | None = None) -> str:
"""与微信示例一致:YYYY-MM-DD HH:MM(北京时间,无时区后缀)。"""
if dt is None:
dt = utc_now()
elif dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_TZ_CN).strftime("%Y-%m-%d %H:%M")
+626
View File
@@ -0,0 +1,626 @@
from __future__ import annotations
import hashlib
import json
import logging
from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from .config import Settings
from .daily_report import DailyReportService
from .gemma_client import OllamaGemmaClient
from .monitor import MonitorService
from .notifier import WeComNotifier
from .gate import GateClient
from .order_executors_store import (
add_executor,
delete_executor,
ensure_store_initialized,
read_snapshot,
update_executor,
write_global_settings,
)
from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job"
def _hash_password(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _asset_version(root: Path) -> str:
"""静态资源 ?v= 避免浏览器强缓存旧 app.js。"""
mt = 0
for name in ("app.js", "style.css"):
try:
mt = max(mt, int((root / "static" / name).stat().st_mtime))
except OSError:
continue
return str(mt or 1)
def _dedupe_funnel_alerts_by_symbol(alerts: list[dict]) -> list[dict]:
"""同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。"""
by_time = sorted(alerts, key=lambda x: str(x.get("created_at") or ""), reverse=True)
seen: set[str] = set()
out: list[dict] = []
for a in by_time:
sym = (a.get("symbol") or "").strip().upper()
if not sym or sym in seen:
continue
seen.add(sym)
out.append(a)
return out
def _slim_monitor_state(state) -> dict:
"""避免 monitoring_pool 全量下发(可达上千条),局域网面板极慢。"""
raw = dict(state.__dict__)
pool = list(raw.pop("monitoring_pool", []) or [])
raw["monitoring_pool_count"] = len(pool)
raw["monitoring_pool_preview"] = pool[:50]
return raw
def _parse_hhmm(raw: str) -> tuple[int, int]:
s = (raw or "").strip()
if ":" not in s:
return 8, 30
hh, mm = s.split(":", 1)
try:
h = max(0, min(23, int(hh)))
m = max(0, min(59, int(mm)))
return h, m
except ValueError:
return 8, 30
def _to_bool(raw: str | None, default: bool) -> bool:
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
def _normalize_manual_symbols(raw: object) -> list[str]:
if isinstance(raw, list):
text = "\n".join([str(x) for x in raw])
else:
text = str(raw or "")
out: list[str] = []
for token in text.replace(",", "\n").replace(";", "\n").splitlines():
s = token.strip().upper()
if not s:
continue
if "_USDT" in s:
s = s.split("_USDT", 1)[0]
elif "-USDT-SWAP" in s:
s = s.split("-USDT-SWAP", 1)[0]
elif "-USDT" in s:
s = s.split("-USDT", 1)[0]
s = "".join(ch for ch in s if ch.isalnum())
if not s:
continue
if s not in out:
out.append(s)
return out[:200]
def _normalize_symbol_token(raw: object) -> str:
s = str(raw or "").strip().upper()
if not s:
return ""
if "_USDT" in s:
s = s.split("_USDT", 1)[0]
elif "-USDT-SWAP" in s:
s = s.split("-USDT-SWAP", 1)[0]
elif "-USDT" in s:
s = s.split("-USDT", 1)[0]
s = "".join(ch for ch in s if ch.isalnum())
return s
def create_app(settings: Settings) -> FastAPI:
def require_login(request: Request) -> None:
if not settings.auth.enabled:
return
if request.session.get("logged_in") is not True:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
app = FastAPI(title="MATRIX FUNNEL", version="2.1.0")
app.add_middleware(GZipMiddleware, minimum_size=800)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app.session_secret,
max_age=60 * 60 * 24 * 7,
same_site="lax",
https_only=False,
)
root_dir = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(root_dir / "templates"))
app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static")
storage = Storage(settings.app.database_url)
proxy_url = settings.proxy.url if settings.proxy.enabled else None
gate_client = GateClient(settings.gate, proxy_url=proxy_url)
notifier = WeComNotifier(settings.wecom, proxy_url=None)
gemma_client = OllamaGemmaClient(settings.gemma) if settings.gemma.enabled else None
monitor = MonitorService(
settings=settings,
storage=storage,
gate_client=gate_client,
notifier=notifier,
gemma_client=gemma_client,
)
daily_report = DailyReportService(
settings=settings,
storage=storage,
gate_client=gate_client,
notifier=notifier,
gemma_client=gemma_client,
)
scheduler = AsyncIOScheduler(timezone="UTC")
app.state.settings = settings
app.state.storage = storage
app.state.monitor = monitor
app.state.scheduler = scheduler
app.state.auth_user = settings.auth.username
app.state.auth_password_hash = _hash_password(settings.auth.password)
@app.on_event("startup")
async def on_startup() -> None:
runtime_dir = Path(settings.app.log_file).resolve().parent
runtime_dir.mkdir(parents=True, exist_ok=True)
await storage.init_db()
ensure_store_initialized(settings)
await _ensure_runtime_defaults(storage)
monitor.state.chart_bar = FIXED_BAR
scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1)
dr = await _get_daily_report_settings(storage, settings)
if dr["enabled"]:
hh, mm = _parse_hhmm(str(dr["run_time_cn"]))
scheduler.add_job(
daily_report.run_once,
"cron",
hour=hh,
minute=mm,
max_instances=1,
timezone="Asia/Shanghai",
id=DAILY_REPORT_JOB_ID,
replace_existing=True,
)
scheduler.start()
await monitor.run_cycle()
if dr["enabled"] and dr["run_on_startup"]:
await daily_report.run_once()
await storage.add_log(
"INFO",
(
f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} "
f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} "
f"web_login={'on' if settings.auth.enabled else 'off'} "
f"daily_report={'on' if settings.daily_report.enabled else 'off'}"
),
)
LOGGER.info("Service started")
@app.on_event("shutdown")
async def on_shutdown() -> None:
scheduler.shutdown(wait=False)
await storage.add_log("INFO", "service_stopped")
await storage.close()
@app.get("/", response_class=HTMLResponse)
async def root(request: Request) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
if request.session.get("logged_in") is True:
return RedirectResponse("/dashboard", status_code=302)
return RedirectResponse("/login", status_code=302)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": ""})
@app.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
ok_user = username == app.state.auth_user
ok_pass = _hash_password(password) == app.state.auth_password_hash
if ok_user and ok_pass:
request.session["logged_in"] = True
request.session["username"] = username
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": "用户名或密码错误"})
@app.get("/logout")
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
return RedirectResponse("/login", status_code=302)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
if settings.auth.enabled and request.session.get("logged_in") is not True:
return RedirectResponse("/login", status_code=302)
display_name = request.session.get("username") or settings.auth.username or "admin"
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"username": display_name,
"asset_version": _asset_version(root_dir),
},
)
@app.get("/api/status")
async def api_status(_: None = Depends(require_login)) -> JSONResponse:
intraday = await _get_intraday_settings(storage)
return JSONResponse(
{
"running": True,
"state": _slim_monitor_state(monitor.state),
"poll_interval_seconds": settings.app.poll_interval_seconds,
"chart_bar": FIXED_BAR,
"mode": "GATE_USDT_PERP",
"universe": settings.monitor.universe,
"intraday_settings": intraday,
"gemma_enabled": settings.gemma.enabled,
"gemma_model": settings.gemma.model,
}
)
@app.get("/api/settings")
async def api_settings_get(_: None = Depends(require_login)) -> JSONResponse:
intraday = await _get_intraday_settings(storage)
daily = await _get_daily_report_settings(storage, settings)
blocklist = await _get_symbol_blocklist_settings(storage)
return JSONResponse(
{
"chart_bar": FIXED_BAR,
"intraday_settings": intraday,
"daily_report_settings": daily,
"symbol_blocklist_settings": blocklist,
"order_executors": read_snapshot(settings),
}
)
@app.get("/api/order-executors")
async def api_order_executors_get(_: None = Depends(require_login)) -> JSONResponse:
return JSONResponse(read_snapshot(settings))
@app.put("/api/order-executors/settings")
async def api_order_executors_settings(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
try:
snap = write_global_settings(
settings,
enabled=body.get("enabled") if "enabled" in body else None,
webhook_secret=body.get("webhook_secret") if "webhook_secret" in body else None,
timeout_seconds=body.get("timeout_seconds") if "timeout_seconds" in body else None,
)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
await storage.add_log(
"INFO",
(
"order_executors_settings_updated "
f"enabled={snap.get('enabled')} timeout={snap.get('timeout_seconds')} "
f"secret_set={bool((snap.get('webhook_secret') or '').strip())}"
),
)
return JSONResponse({"ok": True, "order_executors": snap})
@app.post("/api/order-executors")
async def api_order_executors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
try:
row = add_executor(
settings,
name=str(body.get("name") or ""),
base_url=str(body.get("base_url") or ""),
enabled=bool(body.get("enabled", True)),
)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
await storage.add_log(
"INFO",
f"order_executor_added name={row.get('name')} url={row.get('base_url')}",
)
return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)})
@app.patch("/api/order-executors/{executor_id}")
async def api_order_executors_patch(
executor_id: str, request: Request, _: None = Depends(require_login)
) -> JSONResponse:
body = await request.json()
try:
row = update_executor(
settings,
executor_id,
name=body.get("name") if "name" in body else None,
base_url=body.get("base_url") if "base_url" in body else None,
enabled=body.get("enabled") if "enabled" in body else None,
)
except ValueError as exc:
code = 404 if str(exc) == "executor_not_found" else 400
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=code)
await storage.add_log("INFO", f"order_executor_updated id={executor_id} name={row.get('name')}")
return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)})
@app.delete("/api/order-executors/{executor_id}")
async def api_order_executors_delete(executor_id: str, _: None = Depends(require_login)) -> JSONResponse:
try:
delete_executor(settings, executor_id)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=404)
await storage.add_log("INFO", f"order_executor_deleted id={executor_id}")
return JSONResponse({"ok": True, "order_executors": read_snapshot(settings)})
@app.post("/api/settings/intraday")
async def api_settings_intraday(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
range_hours = _must_float(body.get("range_hours"), "range_hours")
range_max_pct = _must_float(body.get("range_max_pct"), "range_max_pct")
volume_spike_mult = _must_float(body.get("volume_spike_mult"), "volume_spike_mult")
volume_lookback_bars = int(_must_float(body.get("volume_lookback_bars"), "volume_lookback_bars"))
breakout_buffer_pct = _must_float(body.get("breakout_buffer_pct"), "breakout_buffer_pct")
stop_buffer_pct = _must_float(body.get("stop_buffer_pct"), "stop_buffer_pct")
push_time_window_enabled = _to_bool(body.get("push_time_window_enabled"), True)
if range_hours < 1:
raise HTTPException(status_code=400, detail="range_hours must be >= 1")
if range_max_pct <= 0:
raise HTTPException(status_code=400, detail="range_max_pct must be > 0")
if volume_spike_mult < 1:
raise HTTPException(status_code=400, detail="volume_spike_mult must be >= 1")
if volume_lookback_bars < 5:
raise HTTPException(status_code=400, detail="volume_lookback_bars must be >= 5")
if breakout_buffer_pct < 0:
raise HTTPException(status_code=400, detail="breakout_buffer_pct must be >= 0")
if stop_buffer_pct < 0 or stop_buffer_pct > 10:
raise HTTPException(status_code=400, detail="stop_buffer_pct must be between 0 and 10")
await storage.set_kv("intraday_range_hours", str(range_hours))
await storage.set_kv("intraday_range_max_pct", str(range_max_pct))
await storage.set_kv("intraday_volume_spike_mult", str(volume_spike_mult))
await storage.set_kv("intraday_volume_lookback_bars", str(volume_lookback_bars))
await storage.set_kv("intraday_breakout_buffer_pct", str(breakout_buffer_pct))
await storage.set_kv("intraday_stop_buffer_pct", str(stop_buffer_pct))
await storage.set_kv("intraday_push_time_window_enabled", "1" if push_time_window_enabled else "0")
await storage.add_log(
"INFO",
(
"intraday_settings_updated "
f"range_hours={range_hours} range_max_pct={range_max_pct} "
f"volume_spike_mult={volume_spike_mult} volume_lookback_bars={volume_lookback_bars} "
f"breakout_buffer_pct={breakout_buffer_pct} stop_buffer_pct={stop_buffer_pct} "
f"push_time_window_enabled={push_time_window_enabled}"
),
)
return JSONResponse({"ok": True, "intraday_settings": await _get_intraday_settings(storage)})
@app.post("/api/settings/daily-report")
async def api_settings_daily_report(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
enabled = bool(body.get("enabled", True))
run_time_cn = str(body.get("run_time_cn") or "08:30").strip()
push_wecom = bool(body.get("push_wecom", True))
run_on_startup = bool(body.get("run_on_startup", False))
hh, mm = _parse_hhmm(run_time_cn)
run_time_cn = f"{hh:02d}:{mm:02d}"
await storage.set_kv("daily_report_enabled", "1" if enabled else "0")
await storage.set_kv("daily_report_run_time_cn", run_time_cn)
await storage.set_kv("daily_report_push_wecom", "1" if push_wecom else "0")
await storage.set_kv("daily_report_run_on_startup", "1" if run_on_startup else "0")
if scheduler.get_job(DAILY_REPORT_JOB_ID):
scheduler.remove_job(DAILY_REPORT_JOB_ID)
if enabled:
scheduler.add_job(
daily_report.run_once,
"cron",
hour=hh,
minute=mm,
max_instances=1,
timezone="Asia/Shanghai",
id=DAILY_REPORT_JOB_ID,
replace_existing=True,
)
daily = await _get_daily_report_settings(storage, settings)
await storage.add_log(
"INFO",
(
"daily_report_settings_updated "
f"enabled={daily['enabled']} run_time_cn={daily['run_time_cn']} "
f"push_wecom={daily['push_wecom']} run_on_startup={daily['run_on_startup']}"
),
)
return JSONResponse({"ok": True, "daily_report_settings": daily})
@app.post("/api/settings/symbol-blocklist")
async def api_settings_symbol_blocklist(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
symbols = _normalize_manual_symbols(body.get("symbols_text", ""))
await storage.set_kv("monitor_symbol_blocklist", json.dumps(symbols, ensure_ascii=False))
await storage.add_log(
"INFO",
f"symbol_blocklist_updated count={len(symbols)} symbols={','.join(symbols[:30])}{'' if len(symbols) > 30 else ''}",
)
return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)})
@app.get("/api/alerts")
async def api_alerts(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=120)
return JSONResponse({"items": alerts})
@app.get("/api/logs")
async def api_logs(_: None = Depends(require_login)) -> JSONResponse:
logs = await storage.get_recent_logs(limit=120)
return JSONResponse({"items": logs})
@app.get("/api/config")
async def api_config(_: None = Depends(require_login)) -> JSONResponse:
symbols = [{"symbol": w.symbol.upper()} for w in settings.watch_symbols]
g = settings.gemma
dr = await _get_daily_report_settings(storage, settings)
return JSONResponse(
{
"auth_enabled": settings.auth.enabled,
"host": settings.app.host,
"port": settings.app.port,
"poll_interval_seconds": settings.app.poll_interval_seconds,
"universe": settings.monitor.universe,
"min_24h_quote_volume_usdt": settings.monitor.min_24h_quote_volume_usdt,
"btc_daily_gate_enabled": settings.monitor.btc_daily_gate_enabled,
"btc_sideways_lookback_days": settings.monitor.btc_sideways_lookback_days,
"btc_sideways_max_range_pct": settings.monitor.btc_sideways_max_range_pct,
"symbol_signal_dedupe_hours": settings.monitor.symbol_signal_dedupe_hours,
"wecom_push_max_volume_rank": settings.monitor.wecom_push_max_volume_rank,
"gemma": {
"enabled": g.enabled,
"ollama_base_url": g.ollama_base_url,
"model": g.model,
"max_funnel_per_cycle": g.max_funnel_per_cycle,
"vision_top_n": g.vision_top_n,
"gemma_push_priority_min": g.gemma_push_priority_min,
"composite_push_min": g.composite_push_min,
},
"daily_report": dr,
"proxy": {
"enabled": settings.proxy.enabled,
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"order_executor": read_snapshot(settings),
"watch_symbols": symbols,
}
)
@app.get("/api/funnel")
async def api_funnel(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=500)
items = [a for a in alerts if (a.get("details") or {}).get("source") == "gemma_funnel"]
items = _dedupe_funnel_alerts_by_symbol(items)
items.sort(
key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0),
reverse=True,
)
return JSONResponse({"items": items[:100]})
@app.get("/api/daily-report")
async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse:
raw = await storage.get_kv("daily_report_latest")
if not raw:
return JSONResponse(
{
"ready": False,
"message": "晨报尚未生成。请等待定时任务,或开启 daily_report.run_on_startup。",
}
)
try:
obj = json.loads(raw)
except json.JSONDecodeError:
return JSONResponse({"ready": False, "message": "晨报解析失败"}, status_code=500)
return JSONResponse({"ready": True, "report": obj})
@app.post("/api/daily-report/run")
async def api_daily_report_run(_: None = Depends(require_login)) -> JSONResponse:
dr = await _get_daily_report_settings(storage, settings)
if not dr["enabled"]:
return JSONResponse({"ok": False, "message": "daily_report.enabled=false"}, status_code=400)
report = await daily_report.run_once()
return JSONResponse({"ok": True, "report": report})
return app
async def _ensure_runtime_defaults(storage: Storage) -> None:
defaults = {
"intraday_range_hours": "12",
"intraday_range_max_pct": "2.0",
"intraday_volume_spike_mult": "1.6",
"intraday_volume_lookback_bars": "18",
"intraday_breakout_buffer_pct": "0.03",
"intraday_push_time_window_enabled": "1",
"intraday_stop_buffer_pct": "0.2",
"daily_report_enabled": "1",
"daily_report_run_time_cn": "08:30",
"daily_report_push_wecom": "1",
"daily_report_run_on_startup": "0",
}
for key, value in defaults.items():
if await storage.get_kv(key) is None:
await storage.set_kv(key, value)
async def _get_intraday_settings(storage: Storage) -> dict:
return {
"range_hours": _to_float(await storage.get_kv("intraday_range_hours"), 24.0),
"range_max_pct": _to_float(await storage.get_kv("intraday_range_max_pct"), 1.5),
"volume_spike_mult": _to_float(await storage.get_kv("intraday_volume_spike_mult"), 1.6),
"volume_lookback_bars": int(_to_float(await storage.get_kv("intraday_volume_lookback_bars"), 20.0)),
"breakout_buffer_pct": _to_float(await storage.get_kv("intraday_breakout_buffer_pct"), 0.05),
"push_time_window_enabled": _to_bool(await storage.get_kv("intraday_push_time_window_enabled"), True),
"stop_buffer_pct": _to_float(await storage.get_kv("intraday_stop_buffer_pct"), 0.2),
}
async def _get_daily_report_settings(storage: Storage, settings: Settings) -> dict:
return {
"enabled": _to_bool(await storage.get_kv("daily_report_enabled"), settings.daily_report.enabled),
"run_time_cn": str(await storage.get_kv("daily_report_run_time_cn") or settings.daily_report.run_time_cn),
"push_wecom": _to_bool(await storage.get_kv("daily_report_push_wecom"), settings.daily_report.push_wecom),
"run_on_startup": _to_bool(await storage.get_kv("daily_report_run_on_startup"), settings.daily_report.run_on_startup),
}
async def _get_symbol_blocklist_settings(storage: Storage) -> dict:
raw = await storage.get_kv("monitor_symbol_blocklist")
symbols: list[str] = []
if raw and str(raw).strip():
try:
data = json.loads(raw)
if isinstance(data, list):
seen: set[str] = set()
for x in data:
s = str(x).strip().upper()
if s and s not in seen:
seen.add(s)
symbols.append(s)
except json.JSONDecodeError:
symbols = []
return {
"symbols": symbols,
"symbols_text": "\n".join(symbols),
"count": len(symbols),
}
def _to_float(raw: str | None, default: float) -> float:
try:
return float(raw) if raw is not None else default
except (TypeError, ValueError):
return default
def _must_float(raw: object, name: str) -> float:
try:
return float(raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"{name} must be a number")
+73
View File
@@ -0,0 +1,73 @@
app:
host: "0.0.0.0"
port: 8088
poll_interval_seconds: 300
log_file: "./runtime/system.log"
database_url: "sqlite+aiosqlite:///./runtime/alerts.db"
session_secret: "change-me-to-a-long-random-string"
auth:
# 纯局域网可设 false,打开 / 与 /dashboard 不再要求登录(勿在公网关闭)
enabled: true
username: "admin"
password: "Admin@123456"
wecom:
webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace"
mentioned_mobile_list: []
gate:
api_base: "https://api.gateio.ws/api/v4"
settle: "usdt"
quote_currency: "USDT"
# 出站代理:仅 Gate 等外网 httpx;企业微信与本机/局域网 Ollama 不走代理。需 pip 安装 httpx[socks]
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
# 企微「突破预警」推送成功后,向 gate_order_executor POST /v1/signal(与微信同源条件;方案 A 止盈止损)
# 首次启动时从本段导入 runtime/order_executors.json;之后以 Web 面板「下单执行器」为准(可热增删,无需重启)。
# 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。
order_executor:
enabled: false
base_url: "http://127.0.0.1:8090"
webhook_secret: "same-as-gate-order-executor-security-webhook_secret"
timeout_seconds: 15
monitor:
universe: "all_swaps"
# 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面
min_24h_quote_volume_usdt: 10000000
# 可选:记录 BTC 日线 regime 供面板参考;不再拦截山寨扫描(推送门控见 monitor 内 8h×15m + 本币4h
btc_daily_gate_enabled: true
btc_sideways_lookback_days: 14
btc_sideways_max_range_pct: 10.0
# 同一币种对同一类告警链 4 小时内只入库 + 推送一次(WATCH/TRIGGER/漏斗分别计数);0 关闭
symbol_signal_dedupe_hours: 4
# 企业微信主推送:仅成交量排名前 N;0 表示不限制
wecom_push_max_volume_rank: 30
# 仅在 universe=watchlist 时使用;all_swaps 下可留空列表
watch_symbols: []
# 本地 Ollama + Gemma 漏斗(扫描命中 → 日线+图 → JSON 打分 → 高优先级企业微信)
gemma:
enabled: true
ollama_base_url: "http://192.168.8.64:11434"
model: "gemma4:e4b"
timeout_seconds: 180
temperature: 0.15
json_mode: true
send_chart_image: true
max_funnel_per_cycle: 12
vision_top_n: 4
gemma_push_priority_min: 7.0
composite_push_min: 72.0
# 每日晨报:北京时间定时生成“昨天复盘 + BTC 方向”,展示于网页并可推送企业微信
daily_report:
enabled: true
run_time_cn: "08:30"
push_wecom: true
run_on_startup: false
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="${1:-/root/onchain_scout_gate}"
cd "$PROJECT_DIR"
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
if [ ! -f config.yaml ]; then
cp config.example.yaml config.yaml
echo "config.yaml created from template, please edit keys/password before start."
fi
mkdir -p runtime
echo "Bootstrap done."
@@ -0,0 +1,37 @@
/**
* PM2 守护 onchain_scout_gateGate USDT 永续行情扫描
*
* 在项目根目录
* pm2 start deploy/ecosystem.config.cjs
* pm2 logs onchain-scout
*
* 监听地址与端口来自 config.yaml app.host / app.portpython -m app.main uvicorn
*/
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const isWin = process.platform === "win32";
const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python"));
module.exports = {
apps: [
{
name: "onchain-scout",
cwd: ROOT,
script: py,
args: ["-m", "app.main"],
interpreter: "none",
autorestart: true,
watch: false,
max_restarts: 15,
min_uptime: "10s",
exp_backoff_restart_delay: 2000,
error_file: path.join(ROOT, "runtime", "pm2-error.log"),
out_file: path.join(ROOT, "runtime", "pm2-out.log"),
merge_logs: true,
time: true,
env: {
PYTHONUNBUFFERED: "1",
},
},
],
};
@@ -0,0 +1,18 @@
[Unit]
Description=Onchain Scout (Gate) via PM2 runtime
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/onchain_scout_gate
Environment=NODE_ENV=production
Environment=PYTHONUNBUFFERED=1
# 需全局安装 pm2npm install -g pm2
# 路径按实际安装位置调整(which pm2-runtime
ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# 前台调试:直接 uvicorn(无守护)。生产环境请用 PM2pm2 start deploy/ecosystem.config.cjs
set -euo pipefail
PROJECT_DIR="${1:-/root/onchain_scout_gate}"
cd "$PROJECT_DIR"
source .venv/bin/activate
exec uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1
@@ -0,0 +1,209 @@
# 多执行器与信号转发 · 设计归档
本文档归档 2026-05 前后关于 **onchain_scout_gate(扫描端)****gate_order_executor(执行器)** 联动的讨论结论与已实现能力,便于后期检阅。
---
## 1. 背景与目标
| 目标 | 说明 |
|------|------|
| **一套信号** | 扫描端在企微突破推送成功后,构造 **一份** 方案 A 止盈/止损 payload |
| **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 |
| **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 |
| **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` |
| **仅扫描端登记** | 执行器列表 **只能** 在扫描端 Web 面板(及对应 API)维护,**禁止执行器反向注册** |
---
## 2. 架构
```mermaid
flowchart LR
scout[onchain_scout_gate :8088]
scout -->|1 次| wecom[企业微信]
scout -->|同一 payload| exA[执行器 A]
scout -->|同一 payload| exB[执行器 B]
exA --> gateA[Gate 账户 A]
exB --> gateB[Gate 账户 B]
```
- 转发 **不走** 扫描端 `proxy`,直连各 `base_url`(通常 `http://127.0.0.1:8090` 或内网 IP)。
- 各执行器独立进程、独立 `config.yaml`、独立 SQLite;互不通信。
---
## 3. 扫描端实现要点(已实现)
### 3.1 持久化
| 文件 | 内容 |
|------|------|
| `runtime/order_executors.json` | 总开关、`webhook_secret``timeout_seconds`、执行器列表 |
| `config.yaml` `order_executor` | **仅冷启动**:首次无 json 时从 `base_url` / `enabled` / `secret` 导入一条 |
之后以 **面板修改** 为准;改 `config.yaml` **不会** 覆盖已有 json。
### 3.2 执行器列表字段
| 字段 | 说明 |
|------|------|
| `id` | UUID |
| `name` | 展示名(日志、面板) |
| `base_url` | 如 `http://127.0.0.1:8090` |
| `enabled` | 单条开关 |
| `last_forward` | 最近一次转发结果(HTTP、exec_status |
### 3.3 HTTP API(需登录)
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/api/order-executors` | 读取完整配置 |
| PUT | `/api/order-executors/settings` | 总开关、webhook_secret、timeout |
| POST | `/api/order-executors` | 新增 |
| PATCH | `/api/order-executors/{id}` | 改名称/URL/启用 |
| DELETE | `/api/order-executors/{id}` | 删除 |
### 3.4 转发逻辑
1. `build_order_executor_payload()` 仍只构建 **一次**(与企微方案 A 一致)。
2. 对 `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`
3. **同一 `signal_id`** 发往所有目标。
4. 部分失败只记日志,不阻断其他执行器。
### 3.5 Web 面板
路径:Dashboard → **「下单执行器 · 转发链」**
- 总开关、Webhook 密钥(可改)、超时
- 添加 / 启用 / 停用 / 删除
- 展示上次转发状态
### 3.6 代码模块
| 文件 | 职责 |
|------|------|
| `app/order_executors_store.py` | 读写 json、CRUD |
| `app/order_executor_forward.py` | 构建 payload、多路 POST |
| `app/monitor.py` | 企微成功后调用转发 |
| `app/web.py` | API + 启动时 `ensure_store_initialized` |
---
## 4. 执行器侧(gate_order_executor
本次 **未改** 执行器业务代码。多账户 = 多实例部署:
| 实例 | 典型差异 |
|------|----------|
| 目录/PM2 名 | 两份 `gate_order_executor` |
| `app.port` | 8090 / 8091 |
| `gate.api_key/secret` | 不同子账户 |
| `security.webhook_secret` | 与扫描端面板 **相同** |
| `risk.*`、移动保本 | 各实例自行实验 |
另见执行器仓库已实现的 **移动保本**(1R 拉至开仓价±0.2%、面板开关、`breakeven_prefs` 等),与多路转发正交。
---
## 5. 部署套数怎么选
| 场景 | 面板操作 |
|------|----------|
| 单账户 | 列表 **1 条** URL |
| 双账户对照 | **2 条** URL,各指向不同端口/机器 |
| 临时只跑一套 | 另一条 `enabled: false` 或关总开关 |
| 完全停止自动下单 | 总开关 `enabled: false` |
---
## 6. 盈亏比对照实验(用法)
1. 扫描端产生同一 `signal_id`、同一 TP/SL。
2. 执行器 A`min_reward_risk_ratio = 1.3` → 可能 `accepted`
3. 执行器 B`min_reward_risk_ratio = 1.8` → 可能 `skipped` / `reward_risk_below_min`
4. 分别在两个执行器面板「信号流」与 Gate 平仓统计中对比结果。
---
## 7. 云服务器关闭代理
### 7.1 何时关闭
- **本机 + 本地 SOCKS**`proxy.enabled: true`
- **境外云、可直连 Gate**`proxy.enabled: false`
### 7.2 扫描端
```yaml
proxy:
enabled: false
```
仅影响 **Gate 行情**;企微、转发执行器本就直连。
### 7.3 执行器(每个实例)
```yaml
proxy:
enabled: false
```
影响 Gate 下单/查仓及(若开启)企微出站。
### 7.4 自检
```bash
curl -I --max-time 15 https://api.gateio.ws
```
### 7.5 文档索引
- `onchain_scout_gate/交易系统部署说明.md` §7、§8
- `onchain_scout_gate/docs/本地部署-SOCKS5代理.md`(本地 SOCKS
- `gate_order_executor/docs/部署说明.md` §6.1
---
## 8. 日志关键字
扫描端运行日志(面板「运行日志」或 `runtime/system.log`):
| 日志前缀 | 含义 |
|----------|------|
| `order_executor_ok name=...` | 该执行器 HTTP 成功 |
| `order_executor_failed name=...` | HTTP 或业务失败 |
| `order_executor_no_active_targets` | 总开关开但无启用条目 |
| `webhook_secret is empty` | 未配置密钥 |
---
## 9. 安全与约束
- **Webhook 密钥** 在面板修改后,须手动同步到 **每一个** 执行器 `security.webhook_secret`
- **勿** 将执行器 `8090/8091` 对公网裸奔;建议仅本机或内网 + 防火墙。
- 执行器 **不会****不能** 向扫描端注册;避免运维混乱。
---
## 10. 变更记录
| 日期 | 内容 |
|------|------|
| 2026-05 | 多执行器运行时存储、面板 CRUD、并行广播、部署与代理文档 |
---
## 11. 相关路径速查
```text
onchain_scout_gate/
runtime/order_executors.json # 执行器列表(面板写入)
app/order_executors_store.py
app/order_executor_forward.py
templates/dashboard.html # 「下单执行器」区块
static/app.js
gate_order_executor/ # 多实例部署,代码无需为多账户改动
config.yaml # 每实例独立 API / risk / proxy
```
@@ -0,0 +1,233 @@
# 本地部署说明(含 SOCKS5 代理 `socks5h://127.0.0.1:1080`
> **云服务器部署**:若主机在境外且可直连 `api.gateio.ws`,请将 `config.yaml``proxy.enabled` 设为 **`false`**,无需 SOCKS。详见 [`交易系统部署说明.md`](../交易系统部署说明.md) §8 与 [`多执行器与信号转发归档.md`](./多执行器与信号转发归档.md) §7。
本文说明如何在**本机**部署 **onchain_scout_gate**Gate USDT 永续监控 + 可选 Gemma 漏斗 + Web 看板),并在访问 Gate 行情、企业微信等外网接口时使用 **本地 SOCKS5 代理**。环境变量统一使用 `**socks5h://127.0.0.1:1080`**(**h** = 主机名在代理端解析,等同 curl 的 `socks5h`,推荐)。端口 `**1080`** 与 Clash / v2rayN / Sing-box 等本地 SOCKS 入站一致。
---
## 1. 前置条件
### 1.1 系统与软件
| 项目 | 说明 |
| ------ | ---------------------------------------------------------------------------- |
| 操作系统 | Windows 10/11 或 Linux / macOS 均可 |
| Python | **3.10+**(推荐 3.11 / 3.12 |
| 代理客户端 | 本机已运行 **SOCKS5** 监听 `**127.0.0.1:1080`**(常见为 Clash / v2rayN 的「本地 SOCKS5 端口」) |
| 浏览器 | 用于打开 `http://127.0.0.1:8088`(或你在 `config.yaml` 中配置的端口) |
### 1.2 代理必须可用(自检)
在启动本服务前,请先确认 **1080 端口 SOCKS5 已连通外网**(否则 Gate 行情请求会超时或 TLS 失败)。
**Windows PowerShell**(若已安装 `curl` 且 curl 支持 socks5h):
```powershell
curl -x socks5h://127.0.0.1:1080 -I "https://api.gateio.ws" --max-time 15
```
期望看到 HTTP 状态行(如 `HTTP/1.1 200``HTTP/2 302` 等),而不是长时间卡住或 `Connection refused`
**说明**`socks5h` 表示把 **DNS 也走代理**(推荐,避免 DNS 污染)。Python 侧下文使用等价思路。
---
## 2. 获取代码与目录
将仓库(或 `onchain_scout` 目录)放到本机任意路径,例如:
- Windows`C:\opt\onchain_scout`
- Linux`/opt/onchain_scout`
下文以 `**onchain_scout` 为项目根目录**(即包含 `config.yaml``requirements.txt``app/` 的那一层)。
---
## 3. Python 虚拟环境
### 3.1 WindowsPowerShell
```powershell
cd C:\opt\onchain_scout
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -U pip
pip install -r requirements.txt
```
若执行策略禁止激活脚本:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
### 3.2 Linux / macOS
```bash
cd /opt/onchain_scout
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
```
### 3.3 SOCKS5 与 httpx
本项目通过 **httpx** 访问 Gate 行情(及按需的企业微信 Webhook)。走 **SOCKS5** 需要安装带 socks 依赖的 httpx。`requirements.txt` 中已使用:
```text
httpx[socks]==0.27.2
```
若你曾单独安装过无 extras 的 `httpx`,请在本虚拟环境中重新执行:
```bash
pip install -r requirements.txt
```
---
## 4. 配置文件 `config.yaml`
1. 若不存在,从示例复制:
```bash
copy config.example.yaml config.yaml
```
2. 至少修改:
- `auth.username` / `auth.password`:登录 Web 看板用(生产环境请使用强密码)。
- `wecom.webhook`:企业微信群机器人 Webhook(若暂不用推送,可先填占位,但**触发类推送**仍可能失败,可先关相关逻辑或接受报错日志)。
- `monitor.min_24h_quote_volume_usdt`:成交额过滤(默认一千万量级,按 README 说明)。
- `**gemma`**:若本机已装 Ollama 且要跑漏斗,将 `enabled: true` 并设置 `model`;否则保持 `enabled: false`
- `**proxy**`:访问 Gate 行情需走本机 SOCKS 时,设 `proxy.enabled: true``proxy.url` 一般为 `**socks5h://127.0.0.1:1080**`(与 Clash 等本地 SOCKS 端口一致)。**本机 Ollama 不会使用该代理**。
3. **不要将** `config.yaml` **提交到公开仓库**(内含密钥与 Webhook)。
---
## 5. 代理写入 `config.yaml`(推荐,无需环境变量)
`config.yaml` 根级增加或修改 `**proxy`** 段(与 `config.example.yaml` 一致):
```yaml
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
```
说明:
- `**socks5h://**`:配置里可继续写(与 curl 习惯一致)。程序在创建 httpx 客户端时会**自动改成 `socks5://`**,因部分环境下 httpx/socksio 不认 `socks5h` 会报 `Unknown scheme`;改为 `socks5` 后由**本机解析 DNS** 再走 SOCKS。若仍异常,可直接在配置里写 `**socks5://127.0.0.1:1080`**。
- **作用范围****Gate 行情**相关 httpx 请求使用 `config.yaml` 中的 `proxy`;企业微信当前实现为直连。**不会**对 `gemma.ollama_base_url`(本机 Ollama)套代理。
- `**proxy.enabled: false`**:Gate 客户端仍可使用系统环境变量中的 `HTTP_PROXY` / `ALL_PROXY``trust_env=True`);启用配置代理后则**固定走 `proxy.url`**,并 `trust_env=False`,避免与环境变量冲突。
---
## 6. PM2 守护进程(推荐)
### 6.1 安装 PM2
需已安装 **Node.js**,然后全局安装 PM2
```bash
npm install -g pm2
```
### 6.2 准备虚拟环境与配置
1. 项目根目录已创建 `.venv``pip install -r requirements.txt` 完成。
2. `config.yaml` 已按上文填写(含 `proxy``app.port` 等)。
3. 确保存在目录 `**runtime/**`(用于日志与 SQLite;首次启动会自动创建亦可)。
### 6.3 使用仓库内 `ecosystem` 启动
仓库提供 `**deploy/ecosystem.config.cjs**`
- `**cwd**`:自动设为项目根(`deploy` 的上一级)。
- `**script**`:根据操作系统选择 `**.venv/Scripts/python.exe`Windows** 或 `**.venv/bin/python`Linux/macOS**。
- `**args`**`python -m app.main`**监听地址与端口完全由 `config.yaml``app.host` / `app.port` 决定**,无需改 ecosystem 里的端口。
在项目根目录执行:
```bash
cd /opt/onchain_scout # 或你的实际路径
pm2 start deploy/ecosystem.config.cjs
pm2 status
pm2 logs onchain-scout
```
常用维护命令:
| 命令 | 说明 |
| --------------------------- | --------------------------- |
| `pm2 restart onchain-scout` | 热重启(改 `config.yaml` 后需重启生效) |
| `pm2 stop onchain-scout` | 停止 |
| `pm2 delete onchain-scout` | 从进程列表移除 |
| `pm2 save` | 保存当前进程列表 |
| `pm2 startup` | 生成开机自启脚本(按屏幕提示执行一次) |
标准输出与错误会写入项目 `**runtime/pm2-out.log**``**runtime/pm2-error.log**`(见 ecosystem 内配置)。
### 6.4 Windows 说明
**PowerShell****cmd** 中同样可使用 `pm2 start deploy\ecosystem.config.cjs`。若 `python.exe` 路径不对,请确认虚拟环境目录名为 `.venv` 且位于项目根。
---
## 7. 前台启动(调试用)
不经过 PM2、仅本地调试时,**无需**再设置 `ALL_PROXY` 等环境变量(代理已由 `config.yaml``proxy` 段控制):
```powershell
cd C:\opt\onchain_scout
.\.venv\Scripts\Activate.ps1
python -m app.main
```
`app.host` / `app.port``config.yaml` 为准。
---
## 8. 验证
1. **代理**:本机 SOCKS 入站已监听;`config.yaml``proxy.enabled: true`
2. **Web**:浏览器访问 `http://127.0.0.1:<app.port>`,登录后「监控池配置」JSON 中应出现 `**proxy`** 字段(`enabled` / `url`)。
3. **Gate**:等待一个 `poll_interval` 周期,看日志是否仍有 TLS/连接错误;若有,尝试 `socks5` 或检查端口。
4. **Ollama**`gemma.enabled: true` 时,访问 `127.0.0.1:11434` **不**走 `proxy.url`,一般无需 `NO_PROXY`
---
## 9. 常见问题(FAQ
### Q1`Connection refused` 连 Gate / 代理
- 本机代理未开或端口不是 **1080**
- `proxy.enabled` 未设为 `true``proxy.url` 写错。
### Q2:改了 `config.yaml` 不生效
- PM2 下需执行 `**pm2 restart onchain-scout`** 重新加载进程与配置。
### Q3:企业微信推送失败
- 多为 Webhook 无效;若走代理仍失败,检查代理是否允许访问 `qyapi.weixin.qq.com`
### Q4pip / git 走代理
- 与应用程序无关;可在安装依赖的终端自行 `export HTTPS_PROXY=...`**不必**写进应用 `config.yaml`)。
---
## 10. 备忘
```bash
cd /opt/onchain_scout
pm2 start deploy/ecosystem.config.cjs && pm2 save
```
---
文档版本:与仓库 `onchain_scout_gate` 当前结构对应;请以 `README.md``deploy/ecosystem.config.cjs``app/main.py` 为准。
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx[socks]==0.27.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-multipart==0.0.9
itsdangerous==2.2.0
jinja2==3.1.4
apscheduler==3.10.4
sqlalchemy==2.0.35
aiosqlite==0.20.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.1
pyyaml==6.0.2
matplotlib==3.9.2
+2
View File
@@ -0,0 +1,2 @@
from app.main import app # noqa: F401
@@ -0,0 +1,428 @@
from __future__ import annotations
import argparse
import csv
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
@dataclass
class Bar:
ts: str
open: float
high: float
low: float
close: float
@dataclass
class Trade:
side: str # LONG | SHORT
entry_ts: str
entry_price: float
exit_ts: str
exit_price: float
reason: str
gross_return_pct: float
net_return_pct: float
@dataclass
class BarWithEpoch:
bar: Bar
ts_epoch: int
def _to_float(row: dict[str, str], key: str) -> float:
raw = (row.get(key) or "").strip()
if not raw:
raise ValueError(f"Empty numeric field: {key}")
return float(raw)
def _read_csv(
csv_path: Path,
ts_col: str,
open_col: str,
high_col: str,
low_col: str,
close_col: str,
) -> list[Bar]:
bars: list[Bar] = []
with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
needed = {ts_col, open_col, high_col, low_col, close_col}
missing = [c for c in needed if c not in (reader.fieldnames or [])]
if missing:
raise ValueError(f"CSV 缺少列: {missing}. 当前列: {reader.fieldnames}")
for row in reader:
bars.append(
Bar(
ts=str(row[ts_col]),
open=_to_float(row, open_col),
high=_to_float(row, high_col),
low=_to_float(row, low_col),
close=_to_float(row, close_col),
)
)
if len(bars) < 200:
raise ValueError(f"数据量过少: {len(bars)} 行,无法可靠回测。")
return bars
def _parse_ts_to_epoch_seconds(ts_raw: str) -> int:
s = str(ts_raw).strip()
if not s:
raise ValueError("timestamp is empty")
if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
n = int(s)
# 13 digits => milliseconds
if abs(n) >= 10_000_000_000:
return int(n / 1000)
return n
s_norm = s.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(s_norm).timestamp())
except ValueError as exc:
raise ValueError(f"Unsupported timestamp format: {s}") from exc
def _sort_and_attach_epoch(bars: list[Bar]) -> list[BarWithEpoch]:
enriched = [BarWithEpoch(bar=b, ts_epoch=_parse_ts_to_epoch_seconds(b.ts)) for b in bars]
enriched.sort(key=lambda x: x.ts_epoch)
return enriched
def _aggregate_bars(bars_1m: list[Bar], timeframe_minutes: int) -> list[Bar]:
if timeframe_minutes <= 1:
return bars_1m
src = _sort_and_attach_epoch(bars_1m)
if not src:
return []
out: list[Bar] = []
bucket_sec = timeframe_minutes * 60
cur_bucket = None
agg_open = agg_high = agg_low = agg_close = 0.0
agg_ts = ""
for item in src:
b = item.bar
bucket = (item.ts_epoch // bucket_sec) * bucket_sec
if cur_bucket is None or bucket != cur_bucket:
if cur_bucket is not None:
out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close))
cur_bucket = bucket
agg_open = b.open
agg_high = b.high
agg_low = b.low
agg_close = b.close
agg_ts = datetime.utcfromtimestamp(bucket).isoformat() + "Z"
else:
agg_high = max(agg_high, b.high)
agg_low = min(agg_low, b.low)
agg_close = b.close
if cur_bucket is not None:
out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close))
return out
def _parse_timeframe_to_minutes(tf: str) -> int:
s = tf.strip().lower()
if s.endswith("m"):
return int(s[:-1])
if s.endswith("h"):
return int(s[:-1]) * 60
raise ValueError(f"Unsupported timeframe: {tf}. Use like 15m,30m,1h")
def _calc_stats(equity_curve: Iterable[float], trades: list[Trade], initial_capital: float) -> dict[str, float]:
curve = list(equity_curve)
if not curve:
return {}
final_capital = curve[-1]
total_return_pct = (final_capital / initial_capital - 1.0) * 100.0
peak = curve[0]
max_dd = 0.0
for eq in curve:
if eq > peak:
peak = eq
dd = (eq / peak - 1.0) * 100.0
if dd < max_dd:
max_dd = dd
wins = [t for t in trades if t.net_return_pct > 0]
losses = [t for t in trades if t.net_return_pct <= 0]
win_rate = (len(wins) / len(trades) * 100.0) if trades else 0.0
avg_win = sum(t.net_return_pct for t in wins) / len(wins) if wins else 0.0
avg_loss = sum(t.net_return_pct for t in losses) / len(losses) if losses else 0.0
profit_factor = (
abs(sum(t.net_return_pct for t in wins) / sum(t.net_return_pct for t in losses))
if losses and sum(t.net_return_pct for t in losses) != 0
else 0.0
)
return {
"initial_capital": initial_capital,
"final_capital": final_capital,
"total_return_pct": total_return_pct,
"max_drawdown_pct": max_dd,
"total_trades": float(len(trades)),
"win_rate_pct": win_rate,
"avg_win_pct": avg_win,
"avg_loss_pct": avg_loss,
"profit_factor": profit_factor,
}
def run_backtest(
bars: list[Bar],
box_len: int,
buf_pct: float,
min_box_pct: float,
sl_pct: float,
tp_pct: float,
commission_pct: float,
initial_capital: float,
) -> tuple[list[Trade], list[float]]:
high_win: deque[float] = deque(maxlen=box_len)
low_win: deque[float] = deque(maxlen=box_len)
close_hist: list[float] = []
trades: list[Trade] = []
equity_curve: list[float] = [initial_capital]
capital = initial_capital
position = 0 # 1 long, -1 short, 0 flat
entry_price = 0.0
entry_ts = ""
for i, bar in enumerate(bars):
# Build history first
close_hist.append(bar.close)
if i == 0:
high_win.append(bar.high)
low_win.append(bar.low)
continue
# Exit check (intrabar, after entry bar)
if position != 0:
if position == 1:
stop = entry_price * (1 - sl_pct / 100.0)
take = entry_price * (1 + tp_pct / 100.0)
exit_price = 0.0
reason = ""
# Conservative tie-break: stop first if both touched same bar
if bar.low <= stop:
exit_price, reason = stop, "SL"
elif bar.high >= take:
exit_price, reason = take, "TP"
if reason:
gross_ret = (exit_price / entry_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(
Trade("LONG", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret)
)
equity_curve.append(capital)
position = 0
elif position == -1:
stop = entry_price * (1 + sl_pct / 100.0)
take = entry_price * (1 - tp_pct / 100.0)
exit_price = 0.0
reason = ""
if bar.high >= stop:
exit_price, reason = stop, "SL"
elif bar.low <= take:
exit_price, reason = take, "TP"
if reason:
gross_ret = (entry_price / exit_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(
Trade("SHORT", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret)
)
equity_curve.append(capital)
position = 0
# Need full lookback and previous close for crossover.
if len(high_win) < box_len or len(low_win) < box_len or i < 2:
high_win.append(bar.high)
low_win.append(bar.low)
continue
box_high = max(high_win)
box_low = min(low_win)
box_mid = (box_high + box_low) / 2.0
box_pct = ((box_high - box_low) / box_mid * 100.0) if box_mid > 0 else 0.0
box_ok = box_pct >= min_box_pct
up_line = box_high * (1 + buf_pct / 100.0)
dn_line = box_low * (1 - buf_pct / 100.0)
prev_close = close_hist[-2]
long_trig = box_ok and prev_close <= up_line and bar.close > up_line
short_trig = box_ok and prev_close >= dn_line and bar.close < dn_line
# Reverse signal close at close price then flip.
if position == 1 and short_trig:
gross_ret = (bar.close / entry_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade("LONG", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret))
equity_curve.append(capital)
position = 0
elif position == -1 and long_trig:
gross_ret = (entry_price / bar.close - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade("SHORT", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret))
equity_curve.append(capital)
position = 0
if position == 0:
if long_trig:
position = 1
entry_price = bar.close
entry_ts = bar.ts
elif short_trig:
position = -1
entry_price = bar.close
entry_ts = bar.ts
high_win.append(bar.high)
low_win.append(bar.low)
# Force close at final close
if position != 0:
last = bars[-1]
if position == 1:
gross_ret = (last.close / entry_price - 1.0) * 100.0
side = "LONG"
else:
gross_ret = (entry_price / last.close - 1.0) * 100.0
side = "SHORT"
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade(side, entry_ts, entry_price, last.ts, last.close, "FORCE_CLOSE", gross_ret, net_ret))
equity_curve.append(capital)
return trades, equity_curve
def _save_trades(path: Path, trades: list[Trade]) -> None:
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(
[
"side",
"entry_ts",
"entry_price",
"exit_ts",
"exit_price",
"reason",
"gross_return_pct",
"net_return_pct",
]
)
for t in trades:
w.writerow(
[
t.side,
t.entry_ts,
f"{t.entry_price:.8f}",
t.exit_ts,
f"{t.exit_price:.8f}",
t.reason,
f"{t.gross_return_pct:.6f}",
f"{t.net_return_pct:.6f}",
]
)
def main() -> None:
parser = argparse.ArgumentParser(description="ETH 1m 裸K箱体突破回测")
parser.add_argument("--csv", required=True, help="K线 CSV 路径")
parser.add_argument("--ts-col", default="timestamp", help="时间列名")
parser.add_argument("--open-col", default="open", help="开盘列名")
parser.add_argument("--high-col", default="high", help="最高列名")
parser.add_argument("--low-col", default="low", help="最低列名")
parser.add_argument("--close-col", default="close", help="收盘列名")
parser.add_argument("--box-len", type=int, default=80, help="箱体回看K数")
parser.add_argument("--buf-pct", type=float, default=0.03, help="突破缓冲百分比")
parser.add_argument("--min-box-pct", type=float, default=1.5, help="最小箱体宽度百分比")
parser.add_argument("--sl-pct", type=float, default=0.8, help="止损百分比")
parser.add_argument("--tp-pct", type=float, default=2.4, help="止盈百分比")
parser.add_argument("--commission-pct", type=float, default=0.05, help="单边手续费百分比")
parser.add_argument("--capital", type=float, default=100000.0, help="初始资金")
parser.add_argument("--out", default="runtime/backtest_trades.csv", help="交易明细输出路径")
parser.add_argument(
"--timeframes",
default="15m,30m,1h",
help="回测周期,逗号分隔;会从1m聚合,如: 15m,30m,1h",
)
args = parser.parse_args()
csv_path = Path(args.csv).expanduser().resolve()
if not csv_path.exists():
raise FileNotFoundError(f"CSV 不存在: {csv_path}")
bars = _read_csv(
csv_path,
ts_col=args.ts_col,
open_col=args.open_col,
high_col=args.high_col,
low_col=args.low_col,
close_col=args.close_col,
)
tfs = [x.strip() for x in str(args.timeframes).split(",") if x.strip()]
if not tfs:
raise ValueError("timeframes 不能为空")
base_out = Path(args.out).expanduser().resolve()
base_out.parent.mkdir(parents=True, exist_ok=True)
print("=== Backtest Done (1m聚合多周期) ===")
print(f"source_1m_bars: {len(bars)}")
print(f"source_period: {bars[0].ts} -> {bars[-1].ts}")
print("")
for tf in tfs:
minutes = _parse_timeframe_to_minutes(tf)
agg = _aggregate_bars(bars, minutes)
trades, curve = run_backtest(
bars=agg,
box_len=args.box_len,
buf_pct=args.buf_pct,
min_box_pct=args.min_box_pct,
sl_pct=args.sl_pct,
tp_pct=args.tp_pct,
commission_pct=args.commission_pct,
initial_capital=args.capital,
)
stats = _calc_stats(curve, trades, args.capital)
out_path = base_out.with_name(f"{base_out.stem}_{tf}{base_out.suffix}")
_save_trades(out_path, trades)
print(f"[{tf}] bars={len(agg)} trades={int(stats.get('total_trades', 0))}")
print(f" period: {agg[0].ts} -> {agg[-1].ts}")
print(f" final_capital: {stats.get('final_capital', 0):.2f}")
print(f" total_return: {stats.get('total_return_pct', 0):.2f}%")
print(f" max_drawdown: {stats.get('max_drawdown_pct', 0):.2f}%")
print(f" win_rate: {stats.get('win_rate_pct', 0):.2f}%")
print(f" profit_factor: {stats.get('profit_factor', 0):.3f}")
print(f" trades_csv: {out_path}")
print("")
print(f"generated_at: {datetime.now().isoformat(timespec='seconds')}")
if __name__ == "__main__":
main()
+620
View File
@@ -0,0 +1,620 @@
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
credentials: "same-origin",
cache: "no-store",
...options,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
function pretty(data) {
return JSON.stringify(data, null, 2);
}
function renderItems(containerId, rows, rowRenderer) {
const target = document.getElementById(containerId);
if (!target) return;
target.innerHTML = "";
rows.forEach((row) => {
const el = document.createElement("div");
el.className = "item matrix-list-item";
el.innerHTML = rowRenderer(row);
target.appendChild(el);
});
}
function setInput(id, value) {
const el = document.getElementById(id);
if (el) el.value = value;
}
function setTextareaValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value != null ? String(value) : "";
}
function getTextareaValue(id) {
const el = document.getElementById(id);
return el ? String(el.value || "") : "";
}
function setCheck(id, value) {
const el = document.getElementById(id);
if (el) el.checked = !!value;
}
function getInputNumber(id) {
return Number(document.getElementById(id).value);
}
function getInputText(id) {
const el = document.getElementById(id);
return el ? String(el.value || "").trim() : "";
}
function getInputCheck(id) {
const el = document.getElementById(id);
return !!(el && el.checked);
}
/** SQLite 常为无时区 naive UTC,补 Z 再解析,避免浏览器当成本地时区 */
function normalizeUtcIsoString(iso) {
if (typeof iso !== "string") return iso;
const s = iso.trim();
if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) return `${s}Z`;
return s;
}
/** ISO 8601 → 北京时间展示 */
function formatIsoToBeijing(iso) {
if (!iso || typeof iso !== "string") return "—";
const t = Date.parse(normalizeUtcIsoString(iso));
if (Number.isNaN(t)) return iso;
const s = new Date(t).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
return s.replace("T", " ");
}
function tickClock() {
const el = document.getElementById("liveClock");
if (!el) return;
const s = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
el.textContent = s.replace("T", " ") + " 北京时间 (UTC+8)";
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function updateHud(status) {
const st = (status && status.state) || {};
setText("hudLink", "ONLINE");
setText("hudCycle", st.last_cycle_status || "—");
{
const env = st.btc_env_8h_15m || "—";
const daily = st.btc_gate_regime && st.btc_gate_regime !== "disabled" ? st.btc_gate_regime : "";
setText("hudBtc", daily ? `${env} · ${daily}` : env);
}
const pool = st.monitoring_pool_count;
setText("hudPool", pool != null ? String(pool) : "—");
setText("hudPush", st.pushed_alerts_count != null ? String(st.pushed_alerts_count) : "—");
const blChip = document.getElementById("symbolBlocklistCountChip");
if (blChip && st.symbol_blocklist_count != null) {
blChip.textContent = `${st.symbol_blocklist_count} 条规则`;
}
const lastRaw = st.last_cycle_at || st.last_cycle_msg || "—";
setText("hudLast", st.last_cycle_at ? `${formatIsoToBeijing(st.last_cycle_at)}(北京时间)` : lastRaw);
const gemOn = status && status.gemma_enabled;
const nFun = Array.isArray(st.last_funnel) ? st.last_funnel.length : 0;
const model = (status && status.gemma_model) || "";
let gLine = "—";
if (gemOn === false) {
gLine = "配置未开启";
} else if (gemOn === true) {
const msg = (st.gemma_cycle_msg || "").trim();
gLine = msg ? `${msg} · 记忆体${nFun}` : `${model || "ollama"} · 记忆体${nFun}`;
}
setText("hudGemma", gLine);
}
function renderFunnel(items, funnelCtx) {
const root = document.getElementById("funnelMatrix");
if (!root) return;
root.innerHTML = "";
const ctx = funnelCtx || {};
const gemmaOn = !!ctx.gemmaEnabled;
const cycleMsg = String(ctx.cycleMsg || "").trim();
const lastAt = String(ctx.lastFunnelAt || "").trim();
if (!items.length) {
const empty = document.createElement("div");
empty.className = "matrix-hint matrix-hint-empty";
let why =
"// 暂无漏斗记录:本面板只展示 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。";
if (!gemmaOn) {
why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。";
} else if (cycleMsg === "funnel_pending") {
why += " 状态 <strong>funnel_pending</strong>Gemma 在后台跑,完成后此处会出现卡片。";
} else if (cycleMsg === "no_funnel_candidates") {
why += " 本轮扫描无 WATCH/TRIGGER,无漏斗输入。";
} else if (cycleMsg === "gemma_client_none") {
why += " 服务未挂载 Gemma 客户端(检查配置并重启)。";
} else if (cycleMsg && cycleMsg.startsWith("funnel_failed")) {
why += ` 最近错误:<code>${escapeHtml(cycleMsg)}</code>`;
} else if (cycleMsg) {
why += ` 运行时:<code>${escapeHtml(cycleMsg)}</code>`;
}
if (lastAt) {
why += ` <span class="matrix-dim">last_funnel_at: ${escapeHtml(lastAt)}</span>`;
}
empty.innerHTML = `<span class='matrix-empty-icon'>◇</span> ${why}`;
root.appendChild(empty);
return;
}
items.forEach((a) => {
const d = a.details || {};
const g = d.gemma || {};
const comp = Number(d.composite_score || 0);
const pushed = !!d.priority_push;
const card = document.createElement("article");
card.className = "matrix-card" + (pushed ? " hot" : "");
const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—";
card.innerHTML = `
<div class="matrix-card-title">${a.symbol}</div>
<div class="matrix-card-meta">
COMPOSITE <strong>${comp.toFixed(1)}</strong> · P${g.priority || "?"} ·
结构 ${g.daily_structure || "?"} · ${g.volume_view || "?"} ·
上方 ${g.upside_space || "?"} · 阻力 ${g.mid_resistance || "?"}
</div>
<div class="matrix-bar-wrap"><div class="matrix-bar" style="width:${Math.min(100, comp)}%"></div></div>
<div class="matrix-card-line">${escapeHtml(g.one_liner || "")}</div>
<div class="matrix-card-meta">24h 估算 USDT: ${vol} · : ${d.image_sent ? "Y" : "N"}</div>
<span class="matrix-badge ${pushed ? "push" : ""}">${pushed ? "已优先推送" : "未达推送阈值"}</span>
`;
root.appendChild(card);
});
}
function renderDailyReport(payload) {
const root = document.getElementById("dailyReportBox");
const meta = document.getElementById("dailyReportMeta");
if (!root || !meta) return;
root.innerHTML = "";
if (!payload || !payload.ready || !payload.report) {
meta.textContent = `// ${payload && payload.message ? payload.message : "晨报暂不可用"}`;
root.innerHTML =
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> // 晨报会按北京时间定时生成,也可点“立即生成”。</div>";
return;
}
const r = payload.report;
const t = r.text || {};
const b = r.btc || {};
const s = r.stats || {};
const risks = Array.isArray(t.risk_points) ? t.risk_points : [];
meta.textContent =
`// 复盘日 ${r.report_day_cn || "—"} 生成 ${r.generated_at_cn || "—"} AI ${r.ai_used ? "on" : "fallback"} BTC ${b.direction || "—"}`;
const riskHtml = risks.map((x) => `<div>• ${escapeHtml(String(x))}</div>`).join("");
root.innerHTML = `
<div class="item matrix-list-item">
<div class="matrix-row-title"><strong>${escapeHtml(t.headline || "每日晨报")}</strong></div>
<div>BTC: ${escapeHtml(String(b.direction || "—"))} · 日涨跌 ${escapeHtml(String(b.day_change_pct ?? "—"))}% · SMA20 ${escapeHtml(String(b.sma20 ?? "—"))} · SMA60 ${escapeHtml(String(b.sma60 ?? "—"))}</div>
<div>统计: WATCH ${escapeHtml(String(s.watch_count ?? 0))} / TRIGGER ${escapeHtml(String(s.trigger_count ?? 0))} / 漏斗优先 ${escapeHtml(String(s.funnel_push_count ?? 0))}</div>
<div>方向说明: ${escapeHtml(t.btc_explain || "—")}</div>
<div>总结: ${escapeHtml(t.summary || "—")}</div>
<div>风险点:</div>
<div>${riskHtml || "• —"}</div>
<div>执行提示: ${escapeHtml(t.action_hint || "—")}</div>
<div class="time">${escapeHtml(String(r.generated_at_cn || "—"))}北京时间</div>
</div>
`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadIntradaySettings() {
const data = await fetchJson("/api/settings");
const s = data.intraday_settings || {};
setInput("rangeHoursInput", s.range_hours ?? 24);
setInput("rangeMaxPctInput", s.range_max_pct ?? 1.5);
setInput("volumeSpikeMultInput", s.volume_spike_mult ?? 1.6);
setInput("volumeLookbackInput", s.volume_lookback_bars ?? 20);
setInput("breakoutBufferInput", s.breakout_buffer_pct ?? 0.05);
setInput("stopBufferPctInput", s.stop_buffer_pct ?? 0.2);
setCheck("pushTimeWindowEnabledInput", s.push_time_window_enabled ?? true);
const b = data.symbol_blocklist_settings || {};
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
const chip = document.getElementById("symbolBlocklistCountChip");
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
}
async function loadDailyReportSettings() {
const data = await fetchJson("/api/settings");
const d = data.daily_report_settings || {};
setCheck("dailyReportEnabledInput", d.enabled ?? true);
setInput("dailyReportTimeInput", d.run_time_cn ?? "08:30");
setCheck("dailyReportPushInput", d.push_wecom ?? true);
setCheck("dailyReportStartupInput", d.run_on_startup ?? false);
}
async function saveIntradaySettings() {
const msg = document.getElementById("intradaySaveMsg");
if (!msg) return;
msg.textContent = "写入中…";
try {
const payload = {
range_hours: getInputNumber("rangeHoursInput"),
range_max_pct: getInputNumber("rangeMaxPctInput"),
volume_spike_mult: getInputNumber("volumeSpikeMultInput"),
volume_lookback_bars: getInputNumber("volumeLookbackInput"),
breakout_buffer_pct: getInputNumber("breakoutBufferInput"),
stop_buffer_pct: getInputNumber("stopBufferPctInput"),
push_time_window_enabled: getInputCheck("pushTimeWindowEnabledInput"),
};
await fetchJson("/api/settings/intraday", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
msg.textContent = "// 已写入,下一轮监控生效";
} catch (error) {
msg.textContent = `// 失败 ${error}`;
}
}
async function saveSymbolBlocklistSettings() {
const msg = document.getElementById("symbolBlocklistSaveMsg");
if (msg) msg.textContent = "写入中…";
try {
const payload = { symbols_text: getTextareaValue("symbolBlocklistInput") };
const data = await fetchJson("/api/settings/symbol-blocklist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const b = data.symbol_blocklist_settings || {};
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
const chip = document.getElementById("symbolBlocklistCountChip");
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
if (msg) msg.textContent = "// 已写入,下一轮监控生效";
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function runDailyReportNow() {
const meta = document.getElementById("dailyReportMeta");
if (meta) meta.textContent = "// 手动生成中…";
try {
const data = await fetchJson("/api/daily-report/run", { method: "POST" });
renderDailyReport({ ready: true, report: data.report || null });
} catch (error) {
if (meta) meta.textContent = `// 手动生成失败: ${error}`;
}
}
async function saveDailyReportSettings() {
const msg = document.getElementById("dailyReportSaveMsg");
if (msg) msg.textContent = "写入中…";
try {
const payload = {
enabled: getInputCheck("dailyReportEnabledInput"),
run_time_cn: getInputText("dailyReportTimeInput") || "08:30",
push_wecom: getInputCheck("dailyReportPushInput"),
run_on_startup: getInputCheck("dailyReportStartupInput"),
};
await fetchJson("/api/settings/daily-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (msg) msg.textContent = "// 已写入晨报配置,定时任务已更新";
} catch (error) {
if (msg) msg.textContent = `// 写入失败 ${error}`;
}
}
async function refresh() {
try {
const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([
fetchJson("/api/status"),
fetchJson("/api/alerts"),
fetchJson("/api/logs"),
fetchJson("/api/config"),
fetchJson("/api/funnel"),
fetchJson("/api/daily-report"),
]);
updateHud(status);
const statusPre = document.getElementById("status");
const cf = document.getElementById("config");
if (statusPre) statusPre.textContent = pretty(status);
if (cf) cf.textContent = pretty(config);
const runState = (status && status.state) || {};
renderFunnel(funnel.items || [], {
gemmaEnabled: !!(config.gemma && config.gemma.enabled),
cycleMsg: runState.gemma_cycle_msg || "",
lastFunnelAt: runState.last_funnel_at || "",
});
renderDailyReport(dailyReport);
try {
const oe = await fetchJson("/api/order-executors");
renderOrderExecutors(oe);
if (document.activeElement !== document.getElementById("oeWebhookSecret")) {
setCheck("oeGlobalEnabled", !!oe.enabled);
setInput("oeTimeout", oe.timeout_seconds ?? 15);
const sec = document.getElementById("oeWebhookSecret");
if (sec) sec.value = oe.webhook_secret != null ? String(oe.webhook_secret) : "";
}
} catch (eOe) {
console.warn("order executors refresh", eOe);
}
const poll = status.poll_interval_seconds != null ? String(status.poll_interval_seconds) : "?";
const pullCn = new Date()
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
.replace("T", " ");
const fc = (funnel.items || []).length;
const lfAt = runState.last_funnel_at ? formatIsoToBeijing(runState.last_funnel_at) : "—";
const gmsg = runState.gemma_cycle_msg || "—";
const fm = document.getElementById("funnelMeta");
if (fm) {
let line =
`// 浏览器刚拉完 API${pullCn} HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` +
`矩阵卡片 ${fc} 条:来自告警库「每币最新一条」| 记忆体 last_funnel 更新:${lfAt} 后轮 gemma${gmsg}`;
if (String(gmsg).includes("funnel_ranked=0") && fc > 0) {
line +=
" | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。";
} else {
line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。";
}
fm.textContent = line;
}
const allAlerts = alerts.items || [];
const watchRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "WATCH");
const triggerRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "TRIGGER");
renderItems("watchAlerts", watchRows, (a) => `
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
<div>评分: ${Number(a.score).toFixed(2)}</div>
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
`);
if (!triggerRows.length) {
const trig = document.getElementById("triggerAlerts");
if (trig) {
trig.innerHTML =
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> " +
"// 暂无 TRIGGER:触发层只显示 <code>signal_level=TRIGGER</code> 的告警(通常需横盘后<strong>放量突破</strong>等更严条件)。有 WATCH 不代表已进入 TRIGGER。</div>";
}
} else {
renderItems("triggerAlerts", triggerRows, (a) => `
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
<div>推送状态: ${((a.details || {}).strict_push_ok === true) ? "已推送" : "未推送"}</div>
<div>未推送原因: ${escapeHtml(String(((a.details || {}).push_block_reason || "—")))}</div>
<div>评分: ${Number(a.score).toFixed(2)}</div>
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
`);
}
renderItems("logs", logs.items || [], (l) => `
<div><strong class="matrix-log-lvl-${(l.level || "").toLowerCase()}">[${l.level}]</strong> ${escapeHtml(l.message)}</div>
<div class="time">${formatIsoToBeijing(l.created_at)}</div>
`);
} catch (error) {
console.error("refresh failed", error);
setText("hudLink", "ERR");
const fm = document.getElementById("funnelMeta");
if (fm) fm.textContent = `// 拉取失败(检查登录是否过期、网络): ${error}`;
}
}
/** 轻量 Canvas 代码雨(仅 dashboard 有 canvas */
function initMatrixRain() {
const canvas = document.getElementById("matrixRain");
if (!canvas || !canvas.getContext) return;
const ctx = canvas.getContext("2d");
const chars = "01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロ";
let w = 0;
let h = 0;
let columns = [];
const fontSize = 14;
function resize() {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
const colCount = Math.min(48, Math.ceil(w / fontSize));
columns = Array.from({ length: colCount }, () => ({
y: Math.random() * h,
speed: 0.8 + Math.random() * 2.2,
head: Math.floor(Math.random() * chars.length),
}));
}
window.addEventListener("resize", resize);
resize();
function frame() {
ctx.fillStyle = "rgba(2, 2, 6, 0.12)";
ctx.fillRect(0, 0, w, h);
ctx.font = `${fontSize}px ui-monospace, monospace`;
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const x = i * fontSize;
const ch = chars[(col.head + Math.floor(col.y / fontSize)) % chars.length];
const flicker = 0.35 + Math.random() * 0.45;
ctx.fillStyle = `rgba(0, 255, 200, ${flicker})`;
ctx.fillText(ch, x, col.y % (h + fontSize));
col.y += col.speed;
if (col.y > h + fontSize) col.y = -fontSize * (3 + Math.random() * 8);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function formatOeLastForward(lf) {
if (!lf || typeof lf !== "object") return "—";
const at = lf.at ? formatIsoToBeijing(lf.at) : "—";
const st = lf.exec_status != null ? String(lf.exec_status) : "—";
const http = lf.http_status != null ? String(lf.http_status) : "—";
return `${at} · HTTP ${http} · ${st}`;
}
function renderOrderExecutors(snap) {
const root = document.getElementById("oeList");
if (!root) return;
const rows = (snap && snap.executors) || [];
if (!rows.length) {
root.innerHTML =
"<div class='matrix-hint matrix-hint-empty'>// 尚未添加执行器。单账户填一条 Base URL;多账户对照实验填多条(如 :8090 / :8091)。</div>";
return;
}
root.innerHTML = rows
.map((ex) => {
const id = escapeHtml(String(ex.id || ""));
const en = !!ex.enabled;
const lf = formatOeLastForward(ex.last_forward);
return `
<div class="item matrix-list-item" data-oe-id="${id}">
<div class="matrix-row-title"><strong>${escapeHtml(ex.name || "—")}</strong>
<span class="matrix-chip ${en ? "" : "matrix-dim"}">${en ? "启用" : "停用"}</span></div>
<div class="mono">${escapeHtml(ex.base_url || "—")}</div>
<div>上次转发: ${escapeHtml(lf)}</div>
<div class="matrix-form-row matrix-form-row-tight" style="margin-top:8px">
<button type="button" class="matrix-btn ghost oe-toggle" data-id="${id}" data-enabled="${en ? "0" : "1"}">${en ? "停用" : "启用"}</button>
<button type="button" class="matrix-btn ghost oe-delete" data-id="${id}">删除</button>
</div>
</div>`;
})
.join("");
}
async function loadOrderExecutors() {
const data = await fetchJson("/api/order-executors");
setCheck("oeGlobalEnabled", !!data.enabled);
setInput("oeTimeout", data.timeout_seconds ?? 15);
const sec = document.getElementById("oeWebhookSecret");
if (sec && document.activeElement !== sec) {
sec.value = data.webhook_secret != null ? String(data.webhook_secret) : "";
}
renderOrderExecutors(data);
}
async function saveOrderExecutorsGlobal() {
const msg = document.getElementById("oeGlobalMsg");
if (msg) msg.textContent = "保存中…";
try {
const payload = {
enabled: getInputCheck("oeGlobalEnabled"),
webhook_secret: getInputText("oeWebhookSecret"),
timeout_seconds: Number(getInputText("oeTimeout") || "15"),
};
const data = await fetchJson("/api/order-executors/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (msg) msg.textContent = "// 已保存(改 webhook 后请同步各执行器 config";
renderOrderExecutors(data.order_executors || data);
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function addOrderExecutor() {
const msg = document.getElementById("oeAddMsg");
if (msg) msg.textContent = "提交中…";
try {
const data = await fetchJson("/api/order-executors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: getInputText("oeNewName"),
base_url: getInputText("oeNewUrl"),
enabled: getInputCheck("oeNewEnabled"),
}),
});
if (msg) msg.textContent = "// 已添加";
setInput("oeNewName", "");
setInput("oeNewUrl", "");
setCheck("oeNewEnabled", true);
renderOrderExecutors(data.order_executors || data);
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function patchOrderExecutor(id, body) {
return fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function wireOrderExecutorsPanel() {
const saveG = document.getElementById("oeSaveGlobalBtn");
if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal);
const addB = document.getElementById("oeAddBtn");
if (addB) addB.addEventListener("click", addOrderExecutor);
const list = document.getElementById("oeList");
if (list) {
list.addEventListener("click", async (ev) => {
const tgl = ev.target.closest && ev.target.closest(".oe-toggle");
const del = ev.target.closest && ev.target.closest(".oe-delete");
const id = (tgl || del) && (tgl || del).getAttribute("data-id");
if (!id) return;
try {
if (tgl) {
const en = tgl.getAttribute("data-enabled") === "1";
const data = await patchOrderExecutor(id, { enabled: en });
renderOrderExecutors(data.order_executors || data);
} else if (del) {
if (!confirm("确认从扫描端移除该执行器?(不会停止执行器进程)")) return;
const data = await fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
method: "DELETE",
});
renderOrderExecutors(data.order_executors || data);
}
} catch (error) {
alert(String(error));
}
});
}
}
const saveBtn = document.getElementById("saveIntradayBtn");
if (saveBtn) saveBtn.addEventListener("click", saveIntradaySettings);
const saveBlocklistBtn = document.getElementById("saveSymbolBlocklistBtn");
if (saveBlocklistBtn) saveBlocklistBtn.addEventListener("click", saveSymbolBlocklistSettings);
const runDailyBtn = document.getElementById("runDailyReportBtn");
if (runDailyBtn) runDailyBtn.addEventListener("click", runDailyReportNow);
const saveDailyBtn = document.getElementById("saveDailyReportBtn");
if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings);
loadIntradaySettings().catch(console.error);
loadDailyReportSettings().catch(console.error);
wireOrderExecutorsPanel();
loadOrderExecutors().catch(console.error);
tickClock();
setInterval(tickClock, 1000);
initMatrixRain();
refresh();
setInterval(refresh, 4000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refresh();
});
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MATRIX · FUNNEL</title>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
</head>
<body class="matrix-theme">
<canvas id="matrixRain" class="matrix-rain-canvas" aria-hidden="true"></canvas>
<div class="matrix-vignette" aria-hidden="true"></div>
<div class="matrix-aurora" aria-hidden="true"></div>
<div class="matrix-scanlines" aria-hidden="true"></div>
<div class="matrix-noise" aria-hidden="true"></div>
<div class="matrix-chrome">
<div class="matrix-hud" id="matrixHud">
<div class="matrix-hud-block matrix-hud-live">
<span class="matrix-hud-dot" aria-hidden="true"></span>
<div class="matrix-hud-stack">
<span class="matrix-hud-label">LINK</span>
<span class="matrix-hud-val" id="hudLink">SYNC…</span>
</div>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">CYCLE</span>
<span class="matrix-hud-val" id="hudCycle"></span>
</div>
<div class="matrix-hud-block" title="BTC:近8h×15m 环境(横盘时多空均可推送;否则涨→LONG、跌→SHORT);BTC·辅=日线 regime(仅参考)">
<span class="matrix-hud-label">BTC·辅</span>
<span class="matrix-hud-val" id="hudBtc"></span>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">POOL</span>
<span class="matrix-hud-val" id="hudPool"></span>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">PUSH</span>
<span class="matrix-hud-val" id="hudPush"></span>
</div>
<div class="matrix-hud-block matrix-hud-wide">
<span class="matrix-hud-label">GEMMA</span>
<span class="matrix-hud-val mono" id="hudGemma"></span>
</div>
<div class="matrix-hud-block matrix-hud-wide">
<span class="matrix-hud-label">LAST</span>
<span class="matrix-hud-val mono" id="hudLast"></span>
</div>
</div>
<header class="matrix-header matrix-header-crt">
<div class="matrix-brand">
<div class="matrix-title-wrap">
<div class="matrix-glitch matrix-glitch-xl" data-text="MATRIX // FUNNEL">
<span class="matrix-glitch-layer matrix-glitch-c">MATRIX // FUNNEL</span>
<span class="matrix-glitch-layer matrix-glitch-m">MATRIX // FUNNEL</span>
<span class="matrix-glitch-base">MATRIX // FUNNEL</span>
</div>
<div class="matrix-subdeck">
<span class="matrix-deco">[</span>
<span id="matrixTagline" class="matrix-tagline matrix-tagline-glow">Gate USDT 永续 · 5m 结构 → 日线漏斗 · Gemma 优先链</span>
<span class="matrix-deco">]</span>
</div>
</div>
</div>
<div class="matrix-radar-header" aria-hidden="true" title="战术雷达 · 装饰">
<div class="matrix-radar-hud">
<div class="matrix-radar-h-graticule"></div>
<div class="matrix-radar-h-cross"></div>
<div class="matrix-radar-h-rings"></div>
<div class="matrix-radar-h-sweep"></div>
<div class="matrix-radar-h-sweep matrix-radar-h-sweep-ghost"></div>
<div class="matrix-radar-h-blip"></div>
</div>
<span class="matrix-radar-h-caption">SCAN</span>
</div>
<div class="matrix-header-actions">
<span class="matrix-pill matrix-pill-clock" id="liveClock">--:--:--</span>
<span class="matrix-pill dim">OP ▸ <span id="opUser">{{ username }}</span></span>
<a class="matrix-btn ghost" href="/logout">断开链路</a>
</div>
</header>
<main class="matrix-main matrix-crt-inner">
<section class="matrix-panel matrix-panel-hero matrix-panel-chrome">
<div class="matrix-hero-radar" aria-hidden="true">
<div class="matrix-hero-radar-grid"></div>
<div class="matrix-hero-radar-sweep"></div>
<div class="matrix-hero-radar-sweep matrix-hero-radar-sweep-trail"></div>
<div class="matrix-hero-radar-ring"></div>
</div>
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// GEMMA 漏斗 · 优先矩阵</h2>
<span class="matrix-chip matrix-chip-magenta">LIVE FEED</span>
</div>
<p class="matrix-hint">合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送</p>
<p id="funnelMeta" class="matrix-hint matrix-dim">// 数据同步中…</p>
<div id="funnelMatrix" class="matrix-grid"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 每日晨报 · 昨日复盘</h2>
<button type="button" id="runDailyReportBtn" class="matrix-btn ghost">立即生成</button>
</div>
<div class="matrix-form-row">
<label>晨报开关</label>
<input id="dailyReportEnabledInput" type="checkbox" />
<label>北京时间执行</label>
<input id="dailyReportTimeInput" type="time" />
<label>推送企业微信</label>
<input id="dailyReportPushInput" type="checkbox" />
<label>启动即生成</label>
<input id="dailyReportStartupInput" type="checkbox" />
<button type="button" id="saveDailyReportBtn" class="matrix-btn matrix-btn-pulse">保存晨报配置</button>
</div>
<p id="dailyReportSaveMsg" class="matrix-msg"></p>
<p id="dailyReportMeta" class="matrix-hint matrix-dim">// 等待晨报数据…</p>
<div id="dailyReportBox" class="matrix-list"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 监控黑名单 · base</h2>
<span class="matrix-chip matrix-dim" id="symbolBlocklistCountChip">0 条规则</span>
</div>
<p class="matrix-hint">
Gate USDT 永续左侧 base(每行一个或逗号分隔),如 <code>XAU</code> <code>XAUT</code> <code>XAG</code><code>all_swaps</code>
<code>watchlist</code> 均会在入池后剔除。保存后下一轮扫描生效。
</p>
<textarea
id="symbolBlocklistInput"
class="matrix-input matrix-textarea"
rows="5"
spellcheck="false"
autocomplete="off"
placeholder="XAU&#10;XAUT&#10;XAG"
></textarea>
<div class="matrix-form-row matrix-form-row-tight">
<button type="button" id="saveSymbolBlocklistBtn" class="matrix-btn matrix-btn-pulse">保存黑名单</button>
</div>
<p id="symbolBlocklistSaveMsg" class="matrix-msg"></p>
</section>
<section class="matrix-panel matrix-panel-chrome" id="orderExecutorsPanel">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 下单执行器 · 转发链</h2>
<span class="matrix-chip matrix-dim">仅扫描端维护 · 同一信号广播</span>
</div>
<p class="matrix-hint">
企微突破推送成功后,向列表中<strong>已启用</strong>的执行器 POST <code>/v1/signal</code>(方案 A 止盈止损)。
各执行器自行配置 Gate API、盈亏比、移动保本等;<strong>不支持执行器反向注册</strong>
修改 webhook 密钥后请同步到各执行器 <code>security.webhook_secret</code>
</p>
<div class="matrix-form-row matrix-form-row-wrap">
<label>总开关</label>
<input id="oeGlobalEnabled" type="checkbox" />
<label>Webhook 密钥</label>
<input id="oeWebhookSecret" class="matrix-input" type="password" style="min-width:14rem" autocomplete="new-password" />
<label>超时(s)</label>
<input id="oeTimeout" class="matrix-input" type="number" min="3" max="120" step="1" style="width:5rem" />
<button type="button" id="oeSaveGlobalBtn" class="matrix-btn matrix-btn-pulse">保存全局</button>
</div>
<p id="oeGlobalMsg" class="matrix-msg"></p>
<div class="matrix-form-row matrix-form-row-wrap" style="margin-top:12px">
<label>名称</label>
<input id="oeNewName" class="matrix-input" type="text" placeholder="如 account_a" style="width:8rem" />
<label>Base URL</label>
<input id="oeNewUrl" class="matrix-input" type="text" placeholder="http://127.0.0.1:8090" style="min-width:16rem" />
<label>启用</label>
<input id="oeNewEnabled" type="checkbox" checked />
<button type="button" id="oeAddBtn" class="matrix-btn ghost">添加执行器</button>
</div>
<p id="oeAddMsg" class="matrix-msg"></p>
<div id="oeList" class="matrix-list" style="margin-top:12px"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 策略寄存器 · 5m</h2></div>
<p class="matrix-hint">横盘 + 5m 收盘上破 + 放量 · 保存后下一轮生效 · 止损缓冲为企微区间A/B共用</p>
<div class="matrix-form-row">
<label>横盘时长(h)</label>
<input id="rangeHoursInput" type="number" step="0.5" min="1" />
<label>横盘振幅上限(%)</label>
<input id="rangeMaxPctInput" type="number" step="0.1" min="0.1" />
<label>放量倍数</label>
<input id="volumeSpikeMultInput" type="number" step="0.1" min="1" />
<label>放量回看根数</label>
<input id="volumeLookbackInput" type="number" step="1" min="5" />
<label>突破缓冲(%)</label>
<input id="breakoutBufferInput" type="number" step="0.01" min="0" />
<label>止损缓冲(%)</label>
<input id="stopBufferPctInput" type="number" step="0.05" min="0" max="10" title="企微文案区间A/B共用:突破K极值与箱体边沿外侧缓冲" />
<label>启用推送时间窗(09:00-23:00)</label>
<input id="pushTimeWindowEnabledInput" type="checkbox" />
<button type="button" id="saveIntradayBtn" class="matrix-btn matrix-btn-pulse">写入寄存器</button>
</div>
<p id="intradaySaveMsg" class="matrix-msg"></p>
</section>
<div class="matrix-two-col">
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 观察层 · WATCH</h2></div>
<div id="watchAlerts" class="matrix-list"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 触发层 · TRIGGER</h2></div>
<div id="triggerAlerts" class="matrix-list"></div>
</section>
</div>
<section class="matrix-panel matrix-panel-wide matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 系统遥测</h2></div>
<div class="matrix-split">
<pre id="status" class="matrix-pre matrix-pre-glow"></pre>
<pre id="config" class="matrix-pre matrix-pre-glow"></pre>
</div>
</section>
<section class="matrix-panel matrix-panel-wide matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 运行日志</h2></div>
<div id="logs" class="matrix-list matrix-list-logs"></div>
</section>
</main>
</div>
<script src="/static/app.js?v={{ asset_version }}"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MATRIX · 接入</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="matrix-login">
<div class="matrix-aurora" aria-hidden="true"></div>
<div class="matrix-vignette" aria-hidden="true"></div>
<div class="matrix-scanlines matrix-scanlines-strong" aria-hidden="true"></div>
<div class="matrix-noise" aria-hidden="true"></div>
<div class="matrix-grid-bg" aria-hidden="true"></div>
<div class="matrix-login-card matrix-login-card-chrome">
<div class="matrix-login-badge">SECURE CHANNEL</div>
<div class="matrix-login-glitch" data-text="&gt; AUTHENTICATE">&gt; AUTHENTICATE</div>
<p class="matrix-login-sub">MATRIX // GATE USDT PERP FUNNEL · 未授权区域禁止访问</p>
<form method="post" action="/login" class="matrix-login-form">
<label class="matrix-label">操作员 ID</label>
<input class="matrix-input" type="text" name="username" required autocomplete="username" />
<label class="matrix-label">密钥</label>
<input class="matrix-input" type="password" name="password" required autocomplete="current-password" />
<button type="submit" class="matrix-btn matrix-btn-full matrix-btn-pulse">建立会话</button>
</form>
<div class="matrix-error">{{ error }}</div>
</div>
</body>
</html>
@@ -0,0 +1,75 @@
"""执行器列表存储单元测试。"""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.config import OrderExecutorConfig, Settings, AppConfig, AuthConfig, WeComConfig, GateConfig
from app import order_executors_store as store
def _minimal_settings() -> Settings:
return Settings(
app=AppConfig(
host="127.0.0.1",
port=8088,
poll_interval_seconds=60,
log_file="./runtime/system.log",
database_url="sqlite+aiosqlite:///./runtime/t.db",
session_secret="x",
),
auth=AuthConfig(enabled=False, username="a", password="b"),
wecom=WeComConfig(webhook="https://example.com/hook"),
gate=GateConfig(),
order_executor=OrderExecutorConfig(
enabled=True,
base_url="http://127.0.0.1:8090",
webhook_secret="sec",
timeout_seconds=15.0,
),
)
class TestOrderExecutorsStore(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "order_executors.json"
self._patch = patch.object(store, "_STORE_PATH", self.path)
self._patch.start()
def tearDown(self) -> None:
self._patch.stop()
self.tmp.cleanup()
def test_migrate_from_settings(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
snap = store.read_snapshot(s)
self.assertTrue(snap["enabled"])
self.assertEqual(snap["webhook_secret"], "sec")
self.assertEqual(len(snap["executors"]), 1)
self.assertEqual(snap["executors"][0]["base_url"], "http://127.0.0.1:8090")
def test_add_and_active(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
row = store.add_executor(s, name="b", base_url="http://127.0.0.1:8091", enabled=True)
active = store.active_executors(s)
urls = {e["base_url"] for e in active}
self.assertIn("http://127.0.0.1:8090", urls)
self.assertIn("http://127.0.0.1:8091", urls)
self.assertEqual(row["name"], "b")
def test_write_global(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
snap = store.write_global_settings(s, enabled=False, webhook_secret="new")
self.assertFalse(snap["enabled"])
self.assertEqual(snap["webhook_secret"], "new")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,206 @@
# 交易系统部署说明(Gate USDT 永续 · PM2
## 1. 系统概要
本系统是基于 Python 的云端监控服务,仅使用 **Gate.io** 交易所公共 APIREST v4)。
- 监控市场:Gate **USDT 本位线性永续**(合约名如 `BTC_USDT`
- 方向:策略支持多空信号;企业微信推送文案随信号方向变化
- 周期:监控主循环固定 **5m**
- 信号分级:WATCH / TRIGGER
- 数据:`/futures/usdt/contracts``/tickers``/candlesticks`
## 2. 当前策略(摘要)
- WATCH:横盘结构成立
- TRIGGER:横盘 + 5m 收盘突破边界 + 放量
- 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 Web 面板 / SQLite `kv_store`
## 3. config.yaml 示例
```yaml
app:
host: 0.0.0.0
port: 8088
poll_interval_seconds: 300
log_file: ./runtime/system.log
database_url: sqlite+aiosqlite:///./runtime/alerts.db
session_secret: please-replace-with-strong-random-value
auth:
username: admin
password: ChangeThisPassword!
wecom:
webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace
mentioned_mobile_list: []
gate:
api_base: https://api.gateio.ws/api/v4
settle: usdt
quote_currency: USDT
monitor:
universe: all_swaps
min_24h_quote_volume_usdt: 10000000
watch_symbols: []
# 可选:与并列项目 gate_order_executor 联动(企微推送成功后再 POST /v1/signal
order_executor:
enabled: false
base_url: "http://127.0.0.1:8090"
webhook_secret: "same-as-executor-security-webhook_secret"
timeout_seconds: 15
```
### 3.1 企微与自动下单
- 默认仅 **企业微信** 文本告警。若部署 **gate_order_executor** 并设置 `order_executor.enabled: true`、**`webhook_secret` 与执行器一致**,则在 **企微推送成功之后** 自动向执行器发结构化信号(方案 A 止盈/止损与企微文案一致)。
### 3.2 执行器联调(curl,无面板按钮)
并列项目 **`gate_order_executor`** 的 Web 面板 **不再提供**「拉取余额 / 测试市价」入口;需在服务器用 **`curl`** 或脚本调用 **`POST /api/test`**、**`POST /v1/test`** 做联调(`micro_market``gate.test_orders_enabled: true`)。**完整命令与鉴权说明**见 **`gate_order_executor/docs/使用说明.md` §4.1** 与 **`gate_order_executor/docs/部署说明.md` §11**。
## 4. 云端部署(Python venv + PM2,推荐)
以下以 Ubuntu/Debian、项目路径 `/root/onchain_scout_gate` 为例:
### 4.1 系统依赖
```bash
apt update && apt install -y python3 python3-pip python3-venv curl
```
安装 Node.js(用于 PM2),参见 NodeSource 或发行版自带 `nodejs` / `npm`
```bash
npm install -g pm2
```
### 4.2 上传项目
将项目放到 `/root/onchain_scout_gate`(包含 `app/``requirements.txt``deploy/ecosystem.config.cjs`)。
### 4.3 虚拟环境与 Python 依赖
```bash
cd /root/onchain_scout_gate && \
python3 -m venv .venv && \
source .venv/bin/activate && \
python -m pip install -U pip && \
pip install -r requirements.txt
```
### 4.4 配置文件
```bash
nano /root/onchain_scout_gate/config.yaml
```
至少修改:`auth``session_secret``wecom.webhook``monitor`。**旧版 `okx:` 配置需改为 `gate:`**(见上方示例)。
### 4.5 PM2 启动与自检
在项目根目录执行:
```bash
cd /root/onchain_scout_gate
pm2 start deploy/ecosystem.config.cjs
pm2 logs onchain-scout
```
验证:浏览器访问 `http://服务器IP:8088`(端口以 `config.yaml` 为准)。
常用运维:
```bash
pm2 restart onchain-scout
pm2 save
pm2 startup # 按提示配置开机自启
```
日志:`runtime/system.log`(应用轮转日志)、`runtime/pm2-out.log` / `runtime/pm2-error.log`PM2)。
## 5. 前台调试(非守护)
便于排查问题时临时使用:
```bash
cd /root/onchain_scout_gate && source .venv/bin/activate && \
python -m app.main
```
或:`uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1`
## 6. systemd(可选)
若希望用 systemd 托管 **pm2-runtime**(保持进程在前台供 systemd 监控),可复制并修改 `deploy/onchain-scout.service` 中的路径与 `ExecStart`。**生产环境更常见做法是仅用 PM2 自带的 `pm2 startup`。**
## 7. 多执行器转发(Web 面板维护)
同一套突破信号可向 **多个** `gate_order_executor` 广播(对照实验:各执行器自行配置盈亏比、仓位等)。
| 部署 | 操作 |
|------|------|
| **单账户** | 面板「下单执行器」中保留 **1 条** Base URL(如 `http://127.0.0.1:8090` |
| **多账户** | 添加多条 URL(如 `:8090``:8091`),各进程绑定不同 Gate API |
| **暂停某一账户** | 将该条设为「停用」,或关闭总开关 |
| **Webhook** | 面板保存的密钥须与各执行器 `security.webhook_secret` **一致**(改后需同步执行器 config |
- 列表保存在 `runtime/order_executors.json`,**仅扫描端维护**,执行器不会反向注册。
- 转发请求 **不走** `proxy`,直连 `base_url`(同机可用 `127.0.0.1`)。
- 详细设计见 `docs/多执行器与信号转发归档.md`
## 8. 云服务器:关闭代理
本机开发若使用 Clash 等 SOCKS`proxy.enabled: true`),迁到 **可直连 Gate 的境外云主机** 后应关闭代理。
### 8.1 扫描端 `config.yaml`
```yaml
proxy:
enabled: false
url: "socks5h://127.0.0.1:1080" # enabled=false 时可保留
```
修改后:`pm2 restart onchain-scout`(或你的 PM2 应用名)。
**说明:**
- `proxy` 仅影响 **Gate 行情** 请求;企业微信、转发执行器均为 **直连**
- 自检:`curl -I --max-time 15 https://api.gateio.ws` 成功后再关代理。
### 8.2 执行器 `gate_order_executor/config.yaml`(每个实例)
```yaml
proxy:
enabled: false
```
每个执行器进程改完后分别 `pm2 restart gate-order-executor`(多实例用不同应用名/端口)。
### 8.3 同机典型拓扑
```text
onchain_scout_gate :8088 proxy.enabled: false
gate_order_executor :8090 账户 A
gate_order_executor :8091 账户 B(第二份目录或第二 PM2 应用)
```
面板两条 Base URL 指向上述地址即可。
## 9. 常见问题
| 现象 | 处理 |
|------|------|
| `配置文件校验失败` / 缺少 `gate` | 将 `config.yaml``okx:` 改为本文 §3 的 `gate:` 段 |
| ModuleNotFoundError | 进入 `.venv` 后执行 `pip install -r requirements.txt` |
| 拉不到行情 | 检查网络、`proxy`、防火墙;可选 `curl -I https://api.gateio.ws` |
| 限流 / 周期过长 | 增大 `poll_interval_seconds` 或提高 `min_24h_quote_volume_usdt` |
| 有 TRIGGER 但未下单 | 看面板执行器总开关、列表是否为空、webhook 是否一致;查运行日志 `order_executor_*` |
## 10. 运维建议
- 公网建议 Nginx 反代 + HTTPS8088 仅内网暴露。
- 定期备份:`runtime/alerts.db``runtime/order_executors.json`
- 修改 `config.yaml` 后执行 **`pm2 restart onchain-scout`**(执行器列表以面板为准,已有 `order_executors.json` 不会被 yaml 覆盖)。
+67
View File
@@ -0,0 +1,67 @@
# 交易系统部署说明(Gate USDT 永续)
## 1. 系统概要
本系统是基于 Python 的监控服务,使用 **Gate.io** 公共 APIUSDT 永续)。
- 监控市场:Gate USDT 线性永续(如 `BTC_USDT`
- 周期:固定 5m 扫描
- 信号分级:WATCH / TRIGGER
## 2. config.yaml(节选)
```yaml
gate:
api_base: https://api.gateio.ws/api/v4
settle: usdt
quote_currency: USDT
monitor:
universe: all_swaps
min_24h_quote_volume_usdt: 10000000
watch_symbols: []
```
完整示例见 `config.example.yaml`。若仍使用旧字段 **`okx:`**,请改为 **`gate:`**,否则服务无法启动。
## 3. 安装(Linux 示例)
```bash
apt update && apt install -y python3 python3-pip python3-venv
cd /root/onchain_scout_gate
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
cp -n config.example.yaml config.yaml
nano config.yaml
```
## 4. 生产部署:PM2(推荐)
```bash
npm install -g pm2
cd /root/onchain_scout_gate
pm2 start deploy/ecosystem.config.cjs
pm2 logs onchain-scout
```
开机自启:`pm2 save``pm2 startup`(按 CLI 提示执行)。
## 5. 前台调试
```bash
cd /root/onchain_scout_gate && source .venv/bin/activate && python -m app.main
```
## 6. 常见问题
- 配置报错:确认已使用 `gate:` 配置块。
- 依赖缺失:在 venv 内重装 `requirements.txt`
- 网络:需要能访问 `api.gateio.ws`(或通过 `proxy`)。
## 7. 运维
- 备份 `runtime/alerts.db`
- 改配置后:`pm2 restart onchain-scout`