首次上传
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""On-chain first-mover monitoring system package."""
|
||||
|
||||
@@ -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, c(ts,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),
|
||||
},
|
||||
)
|
||||
@@ -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:
|
||||
"""
|
||||
生成简易日线蜡烛图 PNG(base64,无 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")
|
||||
@@ -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 v4(USDT 永续 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",
|
||||
)
|
||||
@@ -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:
|
||||
"""归一化子分数 0–100,供合成 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≈35,100M≈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 1–10;与程序化子分合成 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user