首次上传

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
+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 每项需含 id、name、base_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")