首次上传

This commit is contained in:
dekun
2026-05-16 22:25:48 +08:00
commit 2b8f902548
88 changed files with 16386 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# 根目录 monorepo:扫描端 + 执行器
# Python
**/__pycache__/
**/*.py[cod]
**/.venv/
**/venv/
**/*.egg-info/
**/.pytest_cache/
# 本地配置(勿提交密钥)
**/config.yaml
!**/config.example.yaml
# 运行时与数据
**/runtime/*.log
**/runtime/*.db
**/runtime/*.sqlite
**/runtime/*.json
**/runtime/pm2-*.log
**/runtime/backtest_*.csv
# 保留 example 与空目录占位(若有)
!**/runtime/.gitkeep
# IDE / OS
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# 环境变量
.env
.env.*
+167
View File
@@ -0,0 +1,167 @@
# Git 克隆与仓库说明
**远程仓库:** https://git.bz121.com/dekun/gate_scout_order.git
[dekun/gate_scout_order](https://git.bz121.com/dekun/gate_scout_order) · Gitea
**完整部署(含升级备份):** 见 [部署说明.md](部署说明.md)
`gate_scout_order` 目录采用 **monorepo**:一个 Git 仓库内包含两个子项目,无需分别克隆。
```text
gate_scout_order/ ← 仓库根目录(git clone 落点,目录名可自定)
├── onchain_scout_gate/ ← 扫描端
├── gate_order_executor/ ← 执行器
├── README.md
├── 部署说明.md
├── CLONE.md ← 本文件
└── .gitignore
```
---
## 一、从远程克隆(推荐)
### HTTPS
```bash
cd /opt
git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
### SSH(已配置 git.bz121.com 公钥时)
```bash
cd /opt
git clone git@git.bz121.com:dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
### 指定分支
```bash
git clone -b main https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
---
## 二、克隆后首次配置
两个子项目 **各自** 需要虚拟环境与 `config.yaml`(不要共用 `.venv`)。
### 扫描端
```bash
cd onchain_scout_gate
python -m venv .venv
```
Windows PowerShell
```powershell
.\.venv\Scripts\Activate.ps1
pip install -U pip
pip install -r requirements.txt
copy config.example.yaml config.yaml
```
Linux / macOS
```bash
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
cp config.example.yaml config.yaml
```
编辑 `config.yaml` 后运行:`python run.py`
### 执行器
```bash
cd ../gate_order_executor
python -m venv .venv
```
同样激活 venv、`pip install -r requirements.txt``cp config.example.yaml config.yaml`,编辑后 `python run.py`
---
## 三、本机尚无远程仓库时
若当前只有本地文件夹、尚未 `git init`,可在 **`onchain_scout` 根目录** 执行:
```bash
cd /path/to/onchain_scout
git init
git add .
git commit -m "Initial commit: onchain_scout_gate + gate_order_executor"
```
在 GitHub / Gitee 创建 **空仓库**(不要勾选自动 README),再:
```bash
git branch -M main
git remote add origin <REPO_URL>
git push -u origin main
```
之后他人即可按 **第一节** 克隆。
---
## 四、更新已克隆的仓库
```bash
cd onchain_scout
git pull
```
若子项目依赖有变,分别在两个目录重新安装:
```bash
cd onchain_scout_gate && pip install -r requirements.txt
cd ../gate_order_executor && pip install -r requirements.txt
```
修改 `config.yaml` 后重启对应进程(如 `pm2 restart onchain-scout` / `pm2 restart gate-order-executor`)。
---
## 五、不应提交的文件
根目录 `.gitignore` 已忽略常见敏感与运行时文件,例如:
- 各子项目下的 `config.yaml`(保留 `config.example.yaml`
- `.venv/``__pycache__/`
- `runtime/` 下日志、SQLite、`order_executors.json`
克隆后 **必须** 自行复制 example 配置并填写密钥,否则服务无法正常工作。
---
## 六、两个独立仓库(可选,非默认)
若你希望扫描端与执行器 **分开版本管理**,可拆成两个远程仓库;联动时仍通过 HTTP + 相同 `webhook_secret` 对接。
**当前推荐布局为 monorepo**,面板多执行器列表、归档文档均按此结构编写。
拆仓后克隆方式示例:
```bash
git clone <SCOUT_REPO_URL> onchain_scout_gate
git clone <EXECUTOR_REPO_URL> gate_order_executor
mkdir onchain_scout && mv onchain_scout_gate gate_order_executor onchain_scout/
```
需自行保证目录结构与本文一致,以便对照 [README.md](README.md) 部署。
---
## 七、相关文档
| 文档 | 说明 |
|------|------|
| [README.md](README.md) | 总览、端口、快速开始 |
| [onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md) | 扫描端 PM2 / 云部署 |
| [gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md) | 执行器 PM2 / 云部署 |
+157
View File
@@ -0,0 +1,157 @@
# gate_scout_orderonchain_scout
Gate.io **USDT 永续** 量化辅助工具集:包含 **扫描监控端****下单执行器** 两个独立 Python 服务,可同机或分机部署,通过 HTTP 联动。
**Git 仓库:** https://git.bz121.com/dekun/gate_scout_order.git
```text
gate_scout_order/ # git clone 后的根目录(名称可自定)
├── onchain_scout_gate/ # 扫描端 · 行情监控、企微告警、信号转发(默认 :8088)
├── gate_order_executor/ # 执行器 · 接信号、Gate 下单、面板风控(默认 :8090+)
├── 部署说明.md # ★ 完整部署 + 升级前备份清单
├── CLONE.md
└── README.md # 本文件
```
---
## 两个子项目
| 子目录 | 作用 | 默认端口 | 详细说明 |
|--------|------|----------|----------|
| [`onchain_scout_gate/`](onchain_scout_gate/) | 7×24 扫描 Gate 永续;5m 箱体突破;企微推送;可选 Gemma 漏斗;**面板维护多执行器转发列表** | `8088` | [onchain_scout_gate/README.md](onchain_scout_gate/README.md) |
| [`gate_order_executor/`](gate_order_executor/) | 接收 `POST /v1/signal`;市价开仓 + 计划止盈/止损;盈亏比门槛;移动保本;持仓面板 | `8090`(多账户可 `8091`…) | [gate_order_executor/README.md](gate_order_executor/README.md) |
---
## 协作关系
```mermaid
flowchart LR
gate[Gate.io 公共行情 API]
scout[onchain_scout_gate]
wecom[企业微信]
ex1[gate_order_executor 实例 A]
ex2[gate_order_executor 实例 B]
gate --> scout
scout --> wecom
scout -->|同一 signal 广播| ex1
scout -->|可选| ex2
ex1 --> gate2[Gate 私有 API 账户 A]
ex2 --> gate3[Gate 私有 API 账户 B]
```
1. 扫描端发现 **TRIGGER** 且通过推送门控 → **企业微信** 告警。
2. 企微成功后 → 向面板中 **已启用** 的执行器 `POST /v1/signal`(方案 A 止盈/止损,同一 `signal_id`)。
3. 各执行器自行决定是否接单(最低盈亏比等),**规则不在扫描端区分**。
4. 转发请求 **不走** 扫描端 `proxy`,直连各执行器 `base_url`
设计归档:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md)
---
## Git 克隆
```bash
git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
- 克隆步骤摘要:[CLONE.md](CLONE.md)
- **完整部署、升级备份、PM2、多执行器、关代理**:[部署说明.md](部署说明.md)
---
## 快速开始(本机)
### 1. 扫描端
```bash
cd onchain_scout_gate
python -m venv .venv
# Windows: .\.venv\Scripts\Activate.ps1
# Linux: source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml
# 编辑 config.yamlwecom、gate、proxy、auth
python run.py
```
- 面板:`http://127.0.0.1:8088/dashboard`
- 部署:[onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md)
- 本地 SOCKS[onchain_scout_gate/docs/本地部署-SOCKS5代理.md](onchain_scout_gate/docs/本地部署-SOCKS5代理.md)
### 2. 执行器(可先 dry_run
```bash
cd gate_order_executor
python -m venv .venv
pip install -r requirements.txt
cp config.example.yaml config.yaml
# 编辑 config.yamlsecurity.webhook_secret、gatedry_run: true 联调)
python run.py
```
- 健康检查:`http://127.0.0.1:8090/health`
- 面板:`http://127.0.0.1:8090/dashboard`
- 部署:[gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md)
### 3. 串联
1. 执行器 `security.webhook_secret` 与扫描端面板 **「下单执行器」** 里填写的 Webhook 密钥 **一致**
2. 扫描端面板添加执行器:`http://127.0.0.1:8090`,打开总开关。
3. 执行器 `gate.dry_run: false` 并配置子账户 API 后才会实盘下单。
---
## 多执行器 / 对照实验
- 在扫描端 Web **「下单执行器 · 转发链」** 添加多条 Base URL(如 `:8090``:8091`)。
- 每个 URL 对应 **独立进程 + 独立 Gate API**;盈亏比、移动保本在各执行器自己的面板/配置中修改。
- 列表保存在 `onchain_scout_gate/runtime/order_executors.json`**仅扫描端维护**。
---
## 云服务器:关闭代理
本机开发若使用 `proxy.enabled: true`SOCKS),迁到 **可直连** `api.gateio.ws` 的境外云主机后,扫描端与 **每个** 执行器均应设:
```yaml
proxy:
enabled: false
```
说明见:
- [onchain_scout_gate/交易系统部署说明.md §8](onchain_scout_gate/交易系统部署说明.md)
- [gate_order_executor/docs/部署说明.md §6.1](gate_order_executor/docs/部署说明.md)
---
## 文档索引
| 主题 | 路径 |
|------|------|
| **完整部署与备份** | **[部署说明.md](部署说明.md)** |
| 克隆与分支 | [CLONE.md](CLONE.md) |
| 多执行器归档 | [onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) |
| 扫描端安装 | [onchain_scout_gate/安装与说明.md](onchain_scout_gate/安装与说明.md) |
| 执行器使用 | [gate_order_executor/docs/使用说明.md](gate_order_executor/docs/使用说明.md) |
---
## 环境要求
- Python **3.10+**(推荐 3.11
- 访问 Gate.io API(扫描端可用公共行情;执行器需 API Key)
- 可选:Node.js + PM2Linux 生产部署)
- 可选:本地 Ollama(扫描端 Gemma 漏斗)
- 走 SOCKS 时需 `httpx[socks]` / `socksio`(见各子项目 `requirements.txt`
---
## 安全提示
- **勿** 将含 `config.yaml`、API 密钥、`runtime/` 数据库的目录提交到公开仓库。
- 执行器端口(8090 等)建议仅本机或内网访问;公网请 Nginx + HTTPS + 鉴权。
- `webhook_secret` 修改后须同步到 **所有** 执行器实例。
+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
config.yaml
runtime/
.env
*.log
+60
View File
@@ -0,0 +1,60 @@
# gate_order_executor
> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。
`onchain_scout_gate`MATRIX 扫描)**并列**的独立服务:接收结构化信号;在 **`gate.dry_run: false`** 且配置 API 密钥时,向 Gate USDT 永续发 **市价开仓 + 计划止盈/止损**(详见 [使用说明.md](docs/使用说明.md) §3.4.1)。扫描进程继续只用公共 API;本服务持有 API Key(仅本机 `config.yaml`)。
## 文档索引
| 文档 | 内容 |
|------|------|
| [docs/使用说明.md](docs/使用说明.md) | 职责、配置项、面板、接口、与扫描协作 |
| [docs/部署说明.md](docs/部署说明.md) | Ubuntu 安装、PM2、systemd、防火墙、同机部署 |
| [deploy/README.md](deploy/README.md) | `deploy/` 下各脚本与 service 文件说明 |
## 目录位置(示例)
- `山寨币扫描/onchain_scout_gate/` — 监控 + 企业微信
- `山寨币扫描/gate_order_executor/` — 本下单执行器
## 本地快速运行
```bash
cd gate_order_executor
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml
# 编辑 config.yaml
python run.py
```
- 健康检查:`GET http://127.0.0.1:8090/health`
- 面板:`http://127.0.0.1:8090/dashboard`**勿**用资源管理器直接打开 `templates/*.html`,否则为白底无样式;须先 `python run.py`)。详见 [使用说明.md](docs/使用说明.md)。
## 生产部署(PM2
```bash
chmod +x deploy/*.sh
bash deploy/bootstrap.sh /root/gate_order_executor
# 编辑 config.yaml 后:
bash deploy/pm2-start.sh
```
详见 **[docs/部署说明.md](docs/部署说明.md)**。
## 信号接口(摘要)
`POST /v1/signal`,请求头 `X-Webhook-Secret``config.yaml``security.webhook_secret` 一致。JSON 字段:`signal_id``contract`(如 `BTC_USDT`)、`side``long`/`short`)、`take_profit``stop_loss`(方案 A),可选 `reference_price`
**`gate.dry_run: true`** 时只记日志、不下单;**`false`** 且填写 `api_key` / `api_secret` 时走实盘(务必子账户、IP 白名单、先在小额上验证)。完整说明见 [使用说明.md](docs/使用说明.md)。
```bash
curl -sS -X POST http://127.0.0.1:8090/v1/signal \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: your-secret" \
-d '{"signal_id":"demo-1","contract":"BTC_USDT","side":"long","take_profit":99000,"stop_loss":97000}'
```
## 代理与 SOCKS
`proxy` 配置与扫描项目一致;SOCKS 需安装 `socksio`。详见 [使用说明.md](docs/使用说明.md) §3.6。
+1
View File
@@ -0,0 +1 @@
# Gate order executor (separate from onchain_scout_gate scanner).
@@ -0,0 +1,147 @@
"""移动保本运行态:登记 entry/initial_sl/sl_order_id,平仓后清除。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_ACTIVE_PATH = _ROOT / "runtime" / "breakeven_active.json"
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def _read_all_unlocked() -> dict[str, Any]:
if not _ACTIVE_PATH.is_file():
return {}
try:
raw = _ACTIVE_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
if not isinstance(data, dict):
return {}
return {str(k).strip().upper(): v for k, v in data.items() if isinstance(v, dict)}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_active_read_failed: %s", exc)
return {}
def _write_all_unlocked(rows: dict[str, Any]) -> None:
_ACTIVE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(rows, indent=2, ensure_ascii=False) + "\n"
tmp = _ACTIVE_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_ACTIVE_PATH)
def read_all_active() -> dict[str, dict[str, Any]]:
with _lock:
return dict(_read_all_unlocked())
def get_active(contract: str) -> dict[str, Any] | None:
ct = contract.strip().upper()
with _lock:
row = _read_all_unlocked().get(ct)
return dict(row) if isinstance(row, dict) else None
def upsert_active(
contract: str,
*,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
moved: bool = False,
status: str = "waiting_1r",
) -> dict[str, Any]:
ct = contract.strip().upper()
row: dict[str, Any] = {
"side": str(side).lower(),
"entry": float(entry),
"initial_sl": float(initial_sl),
"sl_order_id": str(sl_order_id).strip(),
"moved": bool(moved),
"status": status,
"registered_at": _now_iso(),
}
with _lock:
all_rows = _read_all_unlocked()
prev = all_rows.get(ct)
if isinstance(prev, dict) and prev.get("registered_at"):
row["registered_at"] = prev["registered_at"]
if isinstance(prev, dict) and prev.get("moved"):
row["moved"] = bool(prev["moved"])
row["status"] = prev.get("status") or row["status"]
all_rows[ct] = row
_write_all_unlocked(all_rows)
return row
def mark_unregistrable(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
all_rows[ct] = {
"status": "cannot_register",
"moved": False,
"registered_at": _now_iso(),
}
_write_all_unlocked(all_rows)
def mark_moved(contract: str, *, new_sl_order_id: str, breakeven_sl: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["moved"] = True
row["status"] = "moved"
row["sl_order_id"] = str(new_sl_order_id).strip()
row["breakeven_sl"] = str(breakeven_sl)
row["moved_at"] = _now_iso()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def update_sl_order_id(contract: str, sl_order_id: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["sl_order_id"] = str(sl_order_id).strip()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def remove_active(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
if ct in all_rows:
del all_rows[ct]
_write_all_unlocked(all_rows)
def remove_all_except(contracts: set[str]) -> None:
keep = {c.strip().upper() for c in contracts if c}
with _lock:
all_rows = _read_all_unlocked()
filtered = {k: v for k, v in all_rows.items() if k in keep}
if filtered != all_rows:
_write_all_unlocked(filtered)
+287
View File
@@ -0,0 +1,287 @@
"""移动保本:1R 判断、保本价、从计划单/信号登记。"""
from __future__ import annotations
import logging
from typing import Any
from .breakeven_active_store import get_active, mark_unregistrable, upsert_active
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float, post_stop_loss_price_order
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def sl_trigger_rule_for_side(side: str) -> int:
return 2 if side == "long" else 1
def risk_distance(side: str, entry: float, initial_sl: float) -> float | None:
if entry <= 0 or initial_sl <= 0:
return None
if side == "long":
dist = entry - initial_sl
elif side == "short":
dist = initial_sl - entry
else:
return None
return dist if dist > 0 else None
def is_1r_reached(
side: str,
mark: float,
entry: float,
initial_sl: float,
*,
trigger_r: float,
) -> bool:
dist = risk_distance(side, entry, initial_sl)
if dist is None or mark <= 0:
return False
target = trigger_r * dist
if side == "long":
return mark >= entry + target
if side == "short":
return mark <= entry - target
return False
def breakeven_sl_price(side: str, entry: float, buffer_pct: float) -> float | None:
if entry <= 0 or buffer_pct < 0:
return None
if side == "long":
return entry * (1.0 + buffer_pct)
if side == "short":
return entry * (1.0 - buffer_pct)
return None
def sl_already_at_or_better(side: str, current_sl: float, target_sl: float) -> bool:
if current_sl <= 0 or target_sl <= 0:
return False
if side == "long":
return current_sl >= target_sl
if side == "short":
return current_sl <= target_sl
return False
def _order_id_from_plan(plan: dict[str, Any]) -> str | None:
oid = plan.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def find_sl_plan(
side: str,
contract: str,
open_plans: list[dict[str, Any]],
) -> tuple[str | None, float | None]:
"""从 open 计划单中识别止损腿,返回 (order_id, trigger_price)。"""
ct = contract.strip().upper()
want_rule = sl_trigger_rule_for_side(side)
candidates: list[tuple[str, float]] = []
for p in open_plans:
if str(p.get("contract") or "").strip().upper() != ct:
continue
try:
rule = int(p.get("rule"))
except (TypeError, ValueError):
continue
if rule != want_rule:
continue
try:
px = float(str(p.get("trigger_price") or "").strip())
except ValueError:
continue
if px <= 0:
continue
oid = _order_id_from_plan(p)
if oid:
candidates.append((oid, px))
if not candidates:
return None, None
# 多仓 SL 在 entry 下方取最高触发价;空仓 SL 在 entry 上方取最低
if side == "long":
oid, px = max(candidates, key=lambda x: x[1])
else:
oid, px = min(candidates, key=lambda x: x[1])
return oid, px
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("id_string")
if oid is None:
return None
s = str(oid).strip()
return s or None
def register_from_execution_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> None:
if result.get("status") != "accepted":
return
contract = sig.contract.strip().upper()
if not read_effective_enabled(settings, contract):
return
entry = _float(result.get("reference_entry"))
sl_sent = result.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.stop_loss)
except (TypeError, ValueError):
mark_unregistrable(contract)
return
sl_order = result.get("stop_loss_order")
sl_id = _gate_order_id(sl_order)
if entry <= 0 or initial_sl <= 0 or not sl_id:
mark_unregistrable(contract)
return
upsert_active(
contract,
side=str(sig.side).lower(),
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered contract=%s entry=%s initial_sl=%s", contract, entry, initial_sl)
def register_from_signal_db_row(row: dict[str, Any], sl_order_id: str) -> dict[str, Any] | None:
res = row.get("result") if isinstance(row.get("result"), dict) else {}
sig = row.get("signal") if isinstance(row.get("signal"), dict) else {}
if res.get("status") != "accepted":
return None
entry = _float(res.get("reference_entry"))
sl_sent = res.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.get("stop_loss") or 0)
except (TypeError, ValueError):
return None
side = str(sig.get("side") or res.get("side") or "").lower()
if entry <= 0 or initial_sl <= 0 or side not in ("long", "short") or not sl_order_id:
return None
return {
"side": side,
"entry": entry,
"initial_sl": initial_sl,
"sl_order_id": sl_order_id,
}
async def try_register_existing_position(
settings: Settings,
*,
contract: str,
side: str,
open_plans: list[dict[str, Any]],
signal_repo: Any | None,
) -> bool:
"""有持仓但 active 无记录时尝试登记;失败则 cannot_register。返回是否已登记。"""
ct = contract.strip().upper()
existing = get_active(ct)
if existing:
return existing.get("status") != "cannot_register"
sl_id, sl_px = find_sl_plan(side, ct, open_plans)
if not sl_id or sl_px is None:
mark_unregistrable(ct)
return False
entry: float | None = None
initial_sl: float | None = sl_px
if signal_repo is not None:
try:
db_row = signal_repo.find_latest_accepted_for_contract(ct)
except Exception: # noqa: BLE001
logger.exception("breakeven_signal_db_lookup_failed contract=%s", ct)
db_row = None
if db_row:
reg = register_from_signal_db_row(db_row, sl_id)
if reg:
entry = reg["entry"]
initial_sl = reg["initial_sl"]
side = reg["side"]
if entry is None or entry <= 0:
mark_unregistrable(ct)
return False
upsert_active(
ct,
side=side,
entry=entry,
initial_sl=float(initial_sl),
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered_existing contract=%s", ct)
return True
async def move_sl_to_breakeven(
settings: Settings,
*,
contract: str,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
mark_price: float,
open_plans: list[dict[str, Any]],
) -> tuple[bool, str | None, str | None]:
"""撤旧 SL 并挂保本+缓冲止损。成功返回 (True, breakeven_sl_str, new_sl_order_id)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys", None
if settings.gate.dry_run:
return False, "dry_run_enabled", None
be = settings.risk.breakeven_stop
target = breakeven_sl_price(side, entry, float(be.buffer_pct))
if target is None:
return False, "invalid_breakeven_price", None
client = GateFuturesLive(settings)
cdata = await client._public_get(f"{client._prefix}/contracts/{contract.strip().upper()}")
if not isinstance(cdata, dict):
return False, "contract_not_found", None
tick = _trigger_price_tick(cdata)
target_s = _format_trigger_price(target, tick)
sl_id_plan, current_sl_px = find_sl_plan(side, contract, open_plans)
use_id = sl_order_id or sl_id_plan or ""
if current_sl_px is not None and sl_already_at_or_better(side, current_sl_px, float(target_s)):
keep_id = use_id or sl_id_plan or ""
return True, target_s, keep_id or None
from .gate_futures_live import cancel_price_triggered_order
from .oco_watcher import update_oco_sl_order_id
if use_id:
try:
await cancel_price_triggered_order(client, use_id)
except Exception as exc: # noqa: BLE001
return False, f"cancel_sl_failed:{exc}", None
try:
resp = await post_stop_loss_price_order(client, contract=contract, side=side, sl_price=target_s)
except Exception as exc: # noqa: BLE001
return False, f"post_sl_failed:{exc}", None
new_id = _gate_order_id(resp)
if not new_id:
return False, "post_sl_no_id", None
await update_oco_sl_order_id(settings, contract=contract, new_sl_id=new_id)
return True, target_s, new_id
@@ -0,0 +1,96 @@
"""移动保本偏好:全局与单合约开关,持久化 runtime/breakeven_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "breakeven_prefs.json"
_lock = threading.Lock()
def _read_file() -> dict[str, Any]:
if not _PREFS_PATH.is_file():
return {}
try:
raw = _PREFS_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_prefs_read_failed: %s", exc)
return {}
def _write_file(data: dict[str, Any]) -> None:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
def read_prefs_snapshot() -> dict[str, Any]:
with _lock:
return dict(_read_file())
def read_effective_global_enabled(settings: Settings) -> bool:
base = bool(settings.risk.breakeven_stop.enabled)
with _lock:
data = _read_file()
if "global_enabled" not in data:
return base
return bool(data.get("global_enabled"))
def read_contract_override(contract: str) -> bool | None:
ct = contract.strip().upper()
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
return None
row = contracts.get(ct)
if not isinstance(row, dict) or "enabled" not in row:
return None
return bool(row.get("enabled"))
def read_effective_enabled(settings: Settings, contract: str) -> bool:
ov = read_contract_override(contract)
if ov is not None:
return ov
return read_effective_global_enabled(settings)
def write_global_enabled(value: bool) -> bool:
with _lock:
data = _read_file()
data["global_enabled"] = bool(value)
_write_file(data)
return bool(value)
def write_contract_enabled(contract: str, value: bool) -> tuple[str, bool]:
ct = contract.strip().upper()
if not ct:
raise ValueError("empty_contract")
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
contracts = {}
contracts[ct] = {"enabled": bool(value)}
data["contracts"] = contracts
_write_file(data)
return ct, bool(value)
@@ -0,0 +1,243 @@
"""移动保本后台轮询:达 1R 后撤旧 SL、挂保本+缓冲(仅一次)。"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .breakeven_active_store import (
get_active,
mark_moved,
mark_unregistrable,
read_all_active,
remove_active,
remove_all_except,
)
from .breakeven_logic import (
is_1r_reached,
move_sl_to_breakeven,
try_register_existing_position,
)
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import _float
from .gate_operations import list_futures_positions, list_open_price_orders
from .wecom_notify import notify_breakeven_failed
logger = logging.getLogger(__name__)
_task: asyncio.Task[None] | None = None
_settings: Settings | None = None
_signal_repo: Any | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
def start_breakeven_watcher(settings: Settings, signal_repo: Any | None = None) -> None:
global _task, _settings, _signal_repo
_settings = settings
_signal_repo = signal_repo
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="breakeven_stop_watcher")
logger.info(
"breakeven_watcher_started poll=%ss enabled_default=%s",
settings.risk.breakeven_stop.poll_interval_sec,
settings.risk.breakeven_stop.enabled,
)
async def stop_breakeven_watcher() -> None:
global _task
if _task is None:
return
_task.cancel()
try:
await _task
except asyncio.CancelledError:
pass
_task = None
async def _poll_loop() -> None:
assert _settings is not None
interval = float(_settings.risk.breakeven_stop.poll_interval_sec)
while True:
await asyncio.sleep(interval)
try:
await _tick(_settings, _signal_repo)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("breakeven_watcher_tick_failed")
def _position_side(size: float) -> str | None:
if size > 1e-12:
return "long"
if size < -1e-12:
return "short"
return None
async def _tick(settings: Settings, signal_repo: Any | None) -> None:
if not _live_ok(settings):
return
positions, pos_err = await list_futures_positions(settings)
if pos_err or not isinstance(positions, list):
return
open_contracts: set[str] = set()
pos_by_contract: dict[str, dict[str, Any]] = {}
for row in positions:
ct = str(row.get("contract") or "").strip().upper()
sz = _float(row.get("size"))
if not ct or abs(sz) <= 1e-12:
continue
open_contracts.add(ct)
pos_by_contract[ct] = row
remove_all_except(open_contracts)
plans, _ = await list_open_price_orders(settings)
plan_list = plans if isinstance(plans, list) else []
be_cfg = settings.risk.breakeven_stop
trigger_r = float(be_cfg.trigger_r)
for ct, prow in pos_by_contract.items():
if not read_effective_enabled(settings, ct):
continue
sz = _float(prow.get("size"))
side = _position_side(sz)
if not side:
continue
mark = _float(prow.get("mark_price"))
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
if not active or active.get("status") != "cannot_register":
await try_register_existing_position(
settings,
contract=ct,
side=side,
open_plans=plan_list,
signal_repo=signal_repo,
)
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
continue
if active.get("moved") or active.get("status") == "moved":
continue
entry = _float(active.get("entry"))
initial_sl = _float(active.get("initial_sl"))
sl_order_id = str(active.get("sl_order_id") or "").strip()
reg_side = str(active.get("side") or side).lower()
if entry <= 0 or initial_sl <= 0 or not sl_order_id:
mark_unregistrable(ct)
continue
if not is_1r_reached(reg_side, mark, entry, initial_sl, trigger_r=trigger_r):
continue
ok, be_px, new_sl_id = await move_sl_to_breakeven(
settings,
contract=ct,
side=reg_side,
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_order_id,
mark_price=mark,
open_plans=plan_list,
)
if ok:
mark_moved(
ct,
new_sl_order_id=str(new_sl_id or sl_order_id),
breakeven_sl=str(be_px or ""),
)
logger.info("breakeven_moved contract=%s sl=%s", ct, be_px)
else:
logger.warning("breakeven_move_failed contract=%s detail=%s", ct, be_px)
try:
await notify_breakeven_failed(
settings,
contract=ct,
detail=str(be_px or "unknown"),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_breakeven_failed")
# 清除已无持仓的 activeremove_all_except 已处理;显式删 cannot_register 残留)
for ct in list(read_all_active().keys()):
if ct not in open_contracts:
remove_active(ct)
async def build_breakeven_state_for_api(
settings: Settings,
*,
exchange_positions: list[dict[str, Any]] | None,
) -> dict[str, Any]:
from .breakeven_prefs_store import read_effective_global_enabled, read_prefs_snapshot
be = settings.risk.breakeven_stop
prefs = read_prefs_snapshot()
active = read_all_active()
per_pos: list[dict[str, Any]] = []
for row in exchange_positions or []:
ct = str(row.get("contract") or "").strip().upper()
if not ct:
continue
sz = _float(row.get("size"))
if abs(sz) <= 1e-12:
continue
enabled = read_effective_enabled(settings, ct)
act = active.get(ct) or {}
st = "disabled"
if not enabled:
st = "disabled"
elif act.get("status") == "cannot_register":
st = "cannot_register"
elif act.get("moved") or act.get("status") == "moved":
st = "moved"
elif act.get("entry"):
st = "waiting_1r"
else:
st = "pending_register"
per_pos.append(
{
"contract": ct,
"effective_enabled": enabled,
"status": st,
"breakeven_sl": act.get("breakeven_sl"),
}
)
return {
"config": {
"enabled_default": bool(be.enabled),
"trigger_r": float(be.trigger_r),
"buffer_pct": float(be.buffer_pct),
"poll_interval_sec": float(be.poll_interval_sec),
},
"global_enabled": read_effective_global_enabled(settings),
"global_enabled_config_default": bool(be.enabled),
"contracts": prefs.get("contracts") if isinstance(prefs.get("contracts"), dict) else {},
"active": active,
"positions": per_pos,
}
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, field_validator
class AppConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 8090
log_file: str = "./runtime/executor.log"
session_secret: str = "please-change-session-secret"
class AuthConfig(BaseModel):
"""与扫描端一致:enabled=false 时仅建议局域网使用。"""
enabled: bool = False
username: str = "admin"
password: str = "changeme"
class SecurityConfig(BaseModel):
webhook_secret: str = ""
class GateConfig(BaseModel):
api_base: str = "https://api.gateio.ws/api/v4"
settle: str = "usdt"
api_key: str = ""
api_secret: str = ""
dry_run: bool = True
# 仅人工测试:为 true 时允许 micro_market 真实 IOC 市价(仍受 test_max_contracts 限制);通过 POST /api/test、/v1/test 联调,见 docs/使用说明 §4.1
test_orders_enabled: bool = False
test_max_contracts: int = Field(1, ge=1, le=30)
class BreakevenStopConfig(BaseModel):
"""移动保本:1R 相对初始止损触发后,止损拉至开仓价 ± buffer_pct(仅一次)。"""
enabled: bool = True
trigger_r: float = Field(1.0, ge=0.1, le=10.0, description="相对初始 SL 的 R 倍数")
buffer_pct: float = Field(0.002, ge=0.0, le=0.05, description="保本缓冲,价格的百分比(0.002=0.2%")
poll_interval_sec: float = Field(8.0, ge=3.0, le=120.0)
class RiskConfig(BaseModel):
risk_per_trade_frac: float = Field(0.005, ge=0.0001, le=0.05)
max_open_positions: int = Field(5, ge=1, le=50)
scheme: str = "A"
# Gate 永续 v4 无官方「单笔原生 OCO」双计划互撤时:为 true 则在净持仓为 0 后轮询 DELETE 本次挂出的另一腿计划单
oco_cleanup_enabled: bool = True
# 最低盈亏比(毛利/风险)门槛的 config 默认值;面板保存会写入 runtime/risk_prefs.json 覆盖
min_reward_risk_ratio: float = Field(1.3, ge=0.1, le=50.0)
breakeven_stop: BreakevenStopConfig = Field(default_factory=BreakevenStopConfig)
class StatsConfig(BaseModel):
"""面板「正式统计」:时区与起始时刻(仅统计该时刻之后平仓的 Gate 历史仓位记录)。"""
timezone: str = "Asia/Shanghai"
official_start: str = Field(
default="2026-05-13T02:00:00+08:00",
description="ISO8601,建议带 +08:00;仅统计平仓 time 不早于此的历史平仓记录",
)
max_trade_rows: int = Field(
20000,
ge=500,
le=100000,
description="从 Gate 分页拉 position_close 历史平仓的上限(防爆内存;沿用键名 max_trade_rows",
)
class DatabaseConfig(BaseModel):
"""信号流与执行结果默认写入 SQLite;面板与导出读同一库,进程重启后记录仍在。"""
enabled: bool = Field(
default=True,
description="已废弃,保留兼容旧配置;是否落库由 sqlite_path 决定(空串会自动回退为默认路径)",
)
sqlite_path: str = "./runtime/signals.sqlite"
@field_validator("sqlite_path", mode="before")
@classmethod
def _sqlite_path_default_if_blank(cls, v: object) -> str:
if v is None:
return "./runtime/signals.sqlite"
s = str(v).strip()
return s if s else "./runtime/signals.sqlite"
class ProxyConfig(BaseModel):
"""
出站 HTTPhttpx)代理,与 onchain_scout_gate 的 ``proxy:`` 块写法一致。
访问 Gate 私有 API 时使用此处;企业微信「策略类」仍由扫描端处理,执行结果见 ``wecom`` 配置。
"""
enabled: bool = False
url: str = "socks5h://127.0.0.1:1080"
class WecomNotifyConfig(BaseModel):
"""企业微信群机器人:仅推送执行器侧执行结果(成交/拒单/异常/平仓等);策略发现类仍由扫描端。"""
enabled: bool = False
webhook_url: str = Field(
default="",
description="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
)
@field_validator("webhook_url", mode="before")
@classmethod
def _strip_webhook(cls, v: object) -> str:
if v is None:
return ""
return str(v).strip()
class Settings(BaseModel):
app: AppConfig = Field(default_factory=AppConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
gate: GateConfig = Field(default_factory=GateConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
stats: StatsConfig = Field(default_factory=StatsConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
wecom: WecomNotifyConfig = Field(default_factory=WecomNotifyConfig)
def load_settings(path: str | Path | None = None) -> Settings:
cfg_path = Path(path or Path(__file__).resolve().parents[1] / "config.yaml")
if not cfg_path.is_file():
return Settings()
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
return Settings.model_validate(raw)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import Settings
from .models_signal import TradeSignal
from .positions import PositionBook
logger = logging.getLogger(__name__)
def _live_enabled(settings: "Settings") -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def handle_signal(settings: "Settings", book: "PositionBook", sig: "TradeSignal") -> dict:
"""
校验仓位上限与重复合约。
dry_run:只打日志并释放占位槽。
实盘:同步交易所持仓 → 市价开仓 → 计划委托止盈/止损;成功则保留占位槽。
"""
from .gate_futures_live import GateFuturesLive, execute_signal_live, fetch_open_contracts
contract = sig.contract.strip().upper()
open_on_ex: set[str] = set()
if _live_enabled(settings):
try:
gc = GateFuturesLive(settings)
open_on_ex = await fetch_open_contracts(gc)
book.sync_from_exchange(open_on_ex)
except Exception as exc: # noqa: BLE001
logger.warning("exchange_sync_failed: %s", exc)
if len(open_on_ex) >= settings.risk.max_open_positions:
return {
"status": "skipped",
"reason": "max_positions_exchange",
"max": settings.risk.max_open_positions,
}
if contract in open_on_ex:
return {"status": "skipped", "reason": "already_open_on_exchange", "contract": contract}
if book.has_contract(contract):
return {"status": "skipped", "reason": "already_open_for_contract", "contract": contract}
if not book.try_reserve(contract, sig.signal_id):
return {
"status": "skipped",
"reason": "max_positions_or_race",
"max": settings.risk.max_open_positions,
}
if not _live_enabled(settings):
logger.info(
"dry_run signal accepted contract=%s side=%s tp=%s sl=%s signal_id=%s",
contract,
sig.side,
sig.take_profit,
sig.stop_loss,
sig.signal_id,
)
book.release(contract)
return {
"status": "accepted",
"mode": "dry_run",
"contract": contract,
"side": sig.side,
"take_profit": sig.take_profit,
"stop_loss": sig.stop_loss,
"signal_id": sig.signal_id,
}
try:
out = await execute_signal_live(settings, sig)
except Exception as exc: # noqa: BLE001
logger.exception("live_execute_exception contract=%s", contract)
book.release(contract)
return {"status": "error", "reason": "exception", "detail": str(exc)}
if out.get("status") != "accepted":
book.release(contract)
return out
return out
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlparse
def gate_sign_path(api_base: str, path_rel: str) -> str:
"""签名用路径:/api/v4 + /futures/usdt/...(不含 host)。"""
root = urlparse(api_base).path.rstrip("/") or "/api/v4"
rel = path_rel if path_rel.startswith("/") else "/" + path_rel
return root + rel
def gate_sign_v4_headers(
*,
api_key: str,
api_secret: str,
method: str,
sign_path: str,
query_string: str,
body: str,
) -> dict[str, str]:
ts = str(int(time.time()))
m = hashlib.sha512()
m.update((body or "").encode("utf-8"))
hashed = m.hexdigest()
payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}"
sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest()
return {
"KEY": api_key,
"Timestamp": ts,
"SIGN": sign,
"Accept": "application/json",
"Content-Type": "application/json",
}
@@ -0,0 +1,439 @@
from __future__ import annotations
import json
import logging
import math
import re
from decimal import ROUND_DOWN, Decimal
from typing import Any
import httpx
from .config import Settings
from .gate_auth import gate_sign_path, gate_sign_v4_headers
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
PRICE_ORDER_EXPIRATION_SEC = 604800 # 7 天
def _safe_order_text(signal_id: str) -> str:
s = re.sub(r"[^0-9A-Za-z._-]", "_", (signal_id or "x").strip())[:22]
return "t-e" + s if s else "t-e"
def _json_compact(obj: Any) -> str:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
class GateFuturesLive:
"""Gate USDT 永续私有 REST(市价开仓 + 计划委托止盈止损)。"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._base = settings.gate.api_base.rstrip("/")
self._settle = settings.gate.settle.strip().lower()
self._prefix = f"/futures/{self._settle}"
self._key = settings.gate.api_key.strip()
self._secret = settings.gate.api_secret.strip()
self._kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url)
def _sign_path(self, rel: str) -> str:
return gate_sign_path(self._settings.gate.api_base, rel)
async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any:
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
r = await client.get(url, params=params)
r.raise_for_status()
return r.json()
async def _signed(
self,
method: str,
rel: str,
*,
query_string: str = "",
body_obj: dict[str, Any] | None = None,
) -> Any:
body_str = _json_compact(body_obj) if body_obj is not None else ""
sp = self._sign_path(rel)
headers = gate_sign_v4_headers(
api_key=self._key,
api_secret=self._secret,
method=method,
sign_path=sp,
query_string=query_string,
body=body_str,
)
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
if method.upper() == "GET":
r = await client.get(url, headers=headers, params=params_from_qs(query_string))
elif method.upper() == "POST":
r = await client.post(url, headers=headers, content=body_str.encode("utf-8"))
elif method.upper() == "DELETE":
r = await client.delete(url, headers=headers)
else:
raise ValueError(f"unsupported {method}")
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
detail = exc.response.json()
except Exception:
detail = exc.response.text if exc.response else ""
raise RuntimeError(f"gate_http_{exc.response.status_code}: {detail}") from exc
if not r.content.strip():
return None
return r.json()
async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float:
"""该合约净持仓张数(单向模式 size 正负表示方向)。"""
ct = contract.strip().upper()
data = await client._signed("GET", f"{client._prefix}/positions")
if not isinstance(data, list):
return 0.0
for p in data:
if str(p.get("contract") or "").strip().upper() != ct:
continue
return _float(p.get("size"))
return 0.0
async def post_stop_loss_price_order(
client: GateFuturesLive,
*,
contract: str,
side: str,
sl_price: str,
) -> dict[str, Any]:
"""POST 一条 reduce_only 全平止损计划单(与信号开仓 SL 同形态)。"""
ct = contract.strip().upper()
sd = str(side).lower()
if sd not in ("long", "short"):
raise ValueError("invalid_side")
cdata = await client._public_get(f"{client._prefix}/contracts/{ct}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
price_tick = _trigger_price_tick(cdata)
sl_s = _format_trigger_price(float(sl_price), price_tick)
_, sl_tr = _tp_sl_triggers(sd, sl_s, sl_s)
import time as _time
text = ("t-besl" + str(int(_time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": sl_tr,
}
resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
raise RuntimeError("price_order_response_invalid")
return resp
async def cancel_price_triggered_order(client: GateFuturesLive, order_id: str | int) -> bool:
"""DELETE /price_orders/{id}。成功删除返回 True。
单已不存在(404,或 Gate 400 + 1034 AUTO_ORDER_NOT_FOUND)视为目标已达成,返回 True,不抛错。
"""
oid = str(order_id).strip()
if not oid:
return False
rel = f"{client._prefix}/price_orders/{oid}"
try:
await client._signed("DELETE", rel)
return True
except RuntimeError as exc:
msg = str(exc)
if "gate_http_404" in msg:
return True
# OCO 一腿触发后另一腿常被交易所联动撤掉;再 DELETE 会得到 400+1034 而非 404。
if "gate_http_400" in msg and (
"AUTO_ORDER_NOT_FOUND" in msg or "'1034'" in msg or '"1034"' in msg
):
return True
raise
def params_from_qs(qs: str) -> dict[str, str]:
if not qs.strip():
return {}
out: dict[str, str] = {}
for part in qs.split("&"):
if "=" in part:
k, v = part.split("=", 1)
out[k] = v
return out
def _float(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
async def fetch_open_contracts(client: GateFuturesLive) -> set[str]:
rel = f"{client._prefix}/positions"
data = await client._signed("GET", rel)
if not isinstance(data, list):
return set()
out: set[str] = set()
for p in data:
if not isinstance(p, dict):
continue
c = str(p.get("contract") or "").strip().upper()
if not c:
continue
if abs(_float(p.get("size"))) > 1e-12:
out.add(c)
return out
def _round_contract_size(raw: float, *, enable_decimal: bool, order_size_min: float) -> str | None:
if raw <= 0 or not math.isfinite(raw):
return None
if enable_decimal:
d = Decimal(str(raw)).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
m = Decimal(str(order_size_min))
if d < m:
return None
s = format(d, "f").rstrip("0").rstrip(".")
return s or None
n = int(math.floor(raw))
if n < int(math.ceil(order_size_min)):
return None
return str(n)
def _tp_sl_triggers(side: str, tp_price: str, sl_price: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""返回 (tp_trigger, sl_trigger) 的 trigger 字段 dictprice 已为合约 tick 对齐后的字符串。"""
if side == "long":
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
else:
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
return tp_tr, sl_tr
async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict:
"""
市价开仓 + 计划委托止盈/止损(reduce_only 市价 IOC)。
以损订仓:用 futures 账户 total × risk_per_trade_frac / (|entry-sl|×quanto_multiplier) 估算张数。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ot = _safe_order_text(sig.signal_id)
try:
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
entry = float(sig.reference_price) if sig.reference_price else last
if entry <= 0:
return {"status": "error", "reason": "no_entry_price", "detail": "缺少 reference_price 且无法从 ticker 取 last"}
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
return {"status": "error", "reason": "contract_not_found", "contract": contract}
mult = _float(cdata.get("quanto_multiplier"))
if mult <= 0:
return {"status": "error", "reason": "invalid_quanto_multiplier", "contract": contract}
order_size_min = _float(cdata.get("order_size_min"), 1.0)
enable_decimal = bool(cdata.get("enable_decimal"))
price_tick = _trigger_price_tick(cdata)
if price_tick is None:
logger.warning("contract %s: missing order_price_round/mark_price_round; TP/SL may be rejected", contract)
accounts = await client._signed("GET", f"{client._prefix}/accounts")
if not isinstance(accounts, dict):
return {"status": "error", "reason": "accounts_unexpected", "detail": str(type(accounts))}
equity = _float(accounts.get("total"))
if equity <= 0:
return {"status": "error", "reason": "zero_equity", "detail": "futures accounts total 为 0"}
risk_usdt = equity * float(settings.risk.risk_per_trade_frac)
sl_dist = abs(entry - float(sig.stop_loss))
if sl_dist <= 0:
return {"status": "error", "reason": "invalid_stop_distance"}
raw_contracts = risk_usdt / (sl_dist * mult)
size_s = _round_contract_size(raw_contracts, enable_decimal=enable_decimal, order_size_min=order_size_min)
if not size_s:
return {
"status": "error",
"reason": "size_too_small",
"detail": f"以损订仓张数不足 order_size_min={order_size_min} raw={raw_contracts:.6f}",
}
if sig.side == "long":
open_size = size_s if not size_s.startswith("-") else size_s.lstrip("-")
else:
open_size = "-" + size_s.lstrip("-")
market_body: dict[str, Any] = {
"contract": contract,
"size": open_size,
"price": "0",
"tif": "ioc",
"text": ot,
"reduce_only": False,
}
order = await client._signed("POST", f"{client._prefix}/orders", body_obj=market_body)
if not isinstance(order, dict):
return {"status": "error", "reason": "order_response_invalid"}
st = str(order.get("status") or "")
finish = str(order.get("finish_as") or "")
left_abs = abs(_float(order.get("left")))
if st != "finished" or left_abs > 1e-12:
return {"status": "error", "reason": "market_not_filled", "order": order}
if finish and finish not in {"filled", "ioc"}:
return {"status": "error", "reason": "market_not_filled", "order": order}
tp_s = _format_trigger_price(float(sig.take_profit), price_tick)
sl_s = _format_trigger_price(float(sig.stop_loss), price_tick)
tp_tr, sl_tr = _tp_sl_triggers(sig.side, tp_s, sl_s)
def _price_order(trigger: dict[str, Any], text_val: str) -> dict[str, Any]:
# Gate:单向全平时 close=true 则 initial.size 必须为 0(否则会报 AUTO_INVALID_PARAM_INITIAL_SIZE
return {
"initial": {
"contract": contract,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text_val,
"reduce_only": True,
"close": True,
},
"trigger": trigger,
}
tp_po = _price_order(tp_tr, "api")
sl_po = _price_order(sl_tr, "app")
# 市价已成交后单独捕获计划委托失败,便于返回 market_order(及已挂上的 TP)供落库、对账
partial_base: dict[str, Any] = {
"status": "error",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
try:
tp_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=tp_po)
except RuntimeError as exc:
logger.exception("price_orders_take_profit_failed contract=%s", contract)
return {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "take_profit",
}
try:
sl_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=sl_po)
except RuntimeError as exc:
logger.exception("price_orders_stop_loss_failed contract=%s", contract)
out_partial: dict[str, Any] = {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "stop_loss",
"take_profit_order": tp_resp,
}
return out_partial
from .breakeven_logic import register_from_execution_result
from .oco_watcher import register_tp_sl_oco_cleanup
if isinstance(tp_resp, dict) and isinstance(sl_resp, dict):
await register_tp_sl_oco_cleanup(
settings,
contract=contract,
tp_order=tp_resp,
sl_order=sl_resp,
)
accepted_out = {
"status": "accepted",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"take_profit_order": tp_resp,
"stop_loss_order": sl_resp,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
register_from_execution_result(settings, sig, accepted_out)
return accepted_out
except httpx.HTTPStatusError as exc:
body = exc.response.text if exc.response else ""
logger.exception("gate_http_error %s", body[:500])
return {"status": "error", "reason": "http_error", "detail": str(exc)}
except RuntimeError as exc:
return {"status": "error", "reason": "gate_api", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
logger.exception("execute_signal_live failed")
return {"status": "error", "reason": "exception", "detail": str(exc)}
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import csv
import io
import time
from typing import Any
from urllib.parse import urlencode
from .config import Settings
from .gate_futures_live import GateFuturesLive
def _keys_ok(settings: Settings) -> bool:
return bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip())
def _default_range_ts() -> tuple[int, int]:
now = int(time.time())
return now - 86400 * 7, now
async def fetch_position_close_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/position_close — 历史平仓(与 App「历史仓位」同源类数据)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {
"from": int(from_ts),
"to": int(to_ts),
"limit": lim,
"offset": off,
}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/position_close", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_my_trades_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/my_trades_timerange — 成交记录(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {"from": int(from_ts), "to": int(to_ts), "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/my_trades_timerange", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_orders_list(
settings: Settings,
*,
status: str,
contract: str | None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/orders?status=… — 委托列表(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
st = (status or "finished").strip().lower()
if st not in ("open", "finished"):
st = "finished"
q: dict[str, Any] = {"status": st, "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
def trades_rows_to_csv(rows: list[dict[str, Any]]) -> str:
"""成交记录 → CSV 文本(UTF-8 BOM 便于 Excel 打开)。"""
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"trade_id",
"create_time",
"contract",
"order_id",
"size",
"price",
"fee",
"point_fee",
"role",
"text",
"close_size",
"pnl",
]
)
for r in rows:
w.writerow(
[
r.get("trade_id") or r.get("id") or "",
r.get("create_time"),
r.get("contract") or "",
r.get("order_id") or "",
r.get("size") or "",
r.get("price") or "",
r.get("fee") or "",
r.get("point_fee") or "",
r.get("role") or "",
r.get("text") or "",
r.get("close_size") or "",
r.get("pnl") if r.get("pnl") is not None else "",
]
)
return buf.getvalue()
def orders_rows_to_csv(rows: list[dict[str, Any]]) -> str:
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"id",
"create_time",
"finish_time",
"contract",
"size",
"left",
"price",
"fill_price",
"status",
"finish_as",
"tif",
"text",
"is_reduce_only",
]
)
for r in rows:
w.writerow(
[
r.get("id") or "",
r.get("create_time"),
r.get("finish_time"),
r.get("contract") or "",
r.get("size") or "",
r.get("left") or "",
r.get("price") or "",
r.get("fill_price") or "",
r.get("status") or "",
r.get("finish_as") or "",
r.get("tif") or "",
r.get("text") or "",
r.get("is_reduce_only"),
]
)
return buf.getvalue()
async def collect_trades_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取成交原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_my_trades_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_position_close_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取历史平仓原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_position_close_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_trades_csv(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
"""分页拉取成交并拼成 CSV(上限 max_rows)。"""
all_rows, err = await collect_trades_rows(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
max_rows=max_rows,
)
if err:
return None, err
if all_rows is None:
return None, None
return trades_rows_to_csv(all_rows), None
async def collect_orders_csv(
settings: Settings,
*,
status: str,
contract: str | None,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
cap = max(1, min(int(max_rows), 5000))
page = 100
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_orders_list(
settings,
status=status,
contract=contract,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return orders_rows_to_csv(all_rows[:cap]), None
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import math
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
PRICE_ORDER_EXPIRATION_SEC,
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
def _float_field(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
def _slim_futures_position(row: dict[str, Any]) -> dict[str, Any]:
"""GET /positions 单行摘要(面板用)。"""
return {
"contract": str(row.get("contract") or "").strip().upper(),
"size": row.get("size"),
"entry_price": row.get("entry_price") if row.get("entry_price") is not None else row.get("avg_entry_price"),
"mark_price": row.get("mark_price"),
"unrealised_pnl": row.get("unrealised_pnl"),
"leverage": row.get("leverage"),
"value": row.get("value"),
"liq_price": row.get("liq_price"),
"margin": row.get("margin"),
"mode": row.get("mode"),
}
async def list_futures_positions(
settings: Settings, *, limit: int = 80
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/positions,仅返回 |size|>0 的合约(最多 limit 条)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
cap = max(1, min(int(limit), 200))
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/positions")
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if not isinstance(row, dict):
continue
if abs(_float_field(row.get("size"))) <= 1e-12:
continue
out.append(_slim_futures_position(row))
if len(out) >= cap:
break
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def read_futures_balance(settings: Settings) -> tuple[dict[str, Any] | None, str | None]:
"""GET /futures/{{settle}}/accounts,返回 (payload, error_message)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/accounts")
if isinstance(data, dict):
return data, None
return None, f"unexpected_response:{type(data).__name__}"
except Exception as exc: # noqa: BLE001
return None, str(exc)
def _slim_price_order(row: dict[str, Any]) -> dict[str, Any]:
"""面板展示用字段(避免整对象过大)。"""
ini = row.get("initial") if isinstance(row.get("initial"), dict) else {}
tr = row.get("trigger") if isinstance(row.get("trigger"), dict) else {}
oid = row.get("id_string")
if oid is None and row.get("id") is not None:
oid = str(row.get("id"))
return {
"order_id": str(oid or "").strip(),
"contract": str(ini.get("contract") or "").strip().upper(),
"status": str(row.get("status") or ""),
"order_type": str(row.get("order_type") or ""),
"trigger_price": str(tr.get("price") or ""),
"rule": tr.get("rule"),
"size": ini.get("size"),
"reduce_only": ini.get("reduce_only"),
"create_time": row.get("create_time"),
}
async def list_open_price_orders(
settings: Settings, *, limit: int = 50
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/price_orders?status=open,返回精简列表。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
lim = max(1, min(int(limit), 100))
qs = f"status=open&limit={lim}"
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/price_orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if isinstance(row, dict):
out.append(_slim_price_order(row))
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def cancel_plan_price_order(settings: Settings, order_id: str) -> tuple[bool, str | None]:
"""撤销一条计划委托(price_orders)。"""
oid = (order_id or "").strip()
if not oid:
return False, "empty_order_id"
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys"
try:
c = GateFuturesLive(settings)
await cancel_price_triggered_order(c, oid)
return True, None
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def post_test_market_order(settings: Settings, *, contract: str, side: str, size_qty: int) -> dict[str, Any]:
"""
极小市价 IOC 测试单。需 config gate.test_orders_enabled=true。
size_qty 会被限制在 [1, test_max_contracts]。
"""
if not settings.gate.test_orders_enabled:
return {"ok": False, "error": "test_orders_disabled", "hint": "请在 config.yaml 设置 gate.test_orders_enabled: true"}
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return {"ok": False, "error": "missing_api_keys"}
cap = int(settings.gate.test_max_contracts)
n = max(1, min(int(size_qty), cap))
c = GateFuturesLive(settings)
ct = contract.strip().upper()
if "_" not in ct or not ct.endswith("_USDT"):
return {"ok": False, "error": "invalid_contract", "contract": ct}
sz = str(n) if side == "long" else f"-{n}"
text = "t-tst" + str(int(time.time()))[-12:]
body: dict[str, Any] = {
"contract": ct,
"size": sz,
"price": "0",
"tif": "ioc",
"text": text[:28],
"reduce_only": False,
}
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
return {"ok": True, "order": order, "request": body}
def _format_order_size_signed(value: float) -> str | None:
if value == 0 or not math.isfinite(value):
return None
if abs(value - round(value)) < 1e-8:
return str(int(round(value)))
s = f"{value:.10f}".rstrip("0").rstrip(".")
return s if s else None
async def market_close_futures_position(settings: Settings, *, contract: str) -> tuple[dict[str, Any] | None, str | None]:
"""市价 IOC + reduce_only 平掉该合约全部净持仓(单向 size 正负)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
c = GateFuturesLive(settings)
ct = contract.strip().upper()
try:
net = await fetch_net_position_size(c, ct)
except Exception as exc: # noqa: BLE001
return None, str(exc)
if abs(net) < 1e-12:
return None, "no_position"
flip = -net
sz_s = _format_order_size_signed(flip)
if not sz_s:
return None, "invalid_close_size"
text = ("t-mcls" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"contract": ct,
"size": sz_s,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
}
try:
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
if not isinstance(order, dict):
return {"response": order}, None
return order, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def post_reduce_close_price_order(
settings: Settings,
*,
contract: str,
trigger_price: str,
rule: int,
) -> tuple[dict[str, Any] | None, str | None]:
"""POST price_orders:全平 reduce_only 条件单(与信号挂 TP/SL 同一 initial 形态)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
ct = contract.strip().upper()
try:
fp = float(str(trigger_price).strip())
except ValueError:
return None, "invalid_trigger_price"
if fp <= 0 or not math.isfinite(fp):
return None, "invalid_trigger_price"
r = int(rule)
if r not in (1, 2):
return None, "invalid_rule"
trig: dict[str, Any] = {
"strategy_type": 0,
"price_type": 0,
"price": str(fp),
"rule": r,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
text = ("t-padd" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": trig,
}
c = GateFuturesLive(settings)
try:
resp = await c._signed("POST", f"{c._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
return {"response": resp}, None
return resp, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
@@ -0,0 +1,49 @@
"""Gate 计划委托触发价:按合约 tick 对齐(仅标准库,可被离线测试直接导入)。"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
def _parse_positive_decimal(raw: Any) -> Decimal | None:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
t = Decimal(s)
except Exception:
return None
if t <= 0 or not t.is_finite():
return None
return t
def _trigger_price_tick(cdata: dict[str, Any]) -> Decimal | None:
"""Gate 合约最小价格跳动;优先 order_price_round,其次 mark_price_round(小币种字段齐全)。"""
for key in ("order_price_round", "mark_price_round"):
t = _parse_positive_decimal(cdata.get(key))
if t is not None:
return t
return None
def _decimal_plain_str(d: Decimal) -> str:
s = format(d, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return s or "0"
def _format_trigger_price(price: float, tick: Decimal | None) -> str:
"""将信号里的浮点止盈/止损价对齐到合约 tick,避免 4752.700000000001 这类导致 price_orders 400。"""
p = Decimal(str(price))
if not p.is_finite():
raise ValueError("invalid trigger price")
if tick is not None and tick > 0:
q = (p / tick).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
snapped = q * tick
return _decimal_plain_str(snapped)
coarse = p.quantize(Decimal("1e-12"), rounding=ROUND_HALF_UP)
return _decimal_plain_str(coarse)
+708
View File
@@ -0,0 +1,708 @@
from __future__ import annotations
import csv
import hashlib
import io
import logging
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from .config import load_settings
from .executor import handle_signal
from .gate_history import (
_default_range_ts,
collect_orders_csv,
collect_trades_csv,
fetch_my_trades_timerange,
fetch_orders_list,
)
from .gate_operations import (
cancel_plan_price_order,
list_futures_positions,
list_open_price_orders,
market_close_futures_position,
post_reduce_close_price_order,
post_test_market_order,
read_futures_balance,
)
from .models_signal import TradeSignal
from .models_test import GateTestRequest
from .positions import PositionBook
from .proxy_util import effective_proxy_url
from .breakeven_active_store import remove_active
from .breakeven_prefs_store import (
read_effective_global_enabled,
read_prefs_snapshot,
write_contract_enabled,
write_global_enabled,
)
from .breakeven_watcher import build_breakeven_state_for_api, start_breakeven_watcher, stop_breakeven_watcher
from .risk_prefs_store import read_effective_min_reward_risk_ratio, write_min_reward_risk_ratio
from .signal_history import SignalHistory
from .signal_metrics import augment_signal_result, compute_signal_stream_metrics
from .signal_repository import SignalRepository
from .stats import build_dashboard_stats
from .wecom_notify import notify_manual_close, notify_signal_db_insert_failed, notify_signal_execution
settings = load_settings()
book = PositionBook(settings.risk.max_open_positions)
signal_history = SignalHistory()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
root_dir = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(root_dir / "templates"))
# 信号流:每条 POST /v1/signal 写入 SQLite;配置里 path 留空时会在 DatabaseConfig 中回退为 ./runtime/signals.sqlite
signal_repo: SignalRepository | None = SignalRepository.from_settings(
settings.database.sqlite_path,
root_dir,
)
if signal_repo:
try:
signal_repo.init_schema()
except Exception: # noqa: BLE001
logger.exception("signal_db_init_failed")
signal_repo = None
def _hash_password(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _asset_version() -> str:
mt = 0
for name in ("exec.js", "style.css", "theme-matrix-terminal.css"):
try:
mt = max(mt, int((root_dir / "static" / name).stat().st_mtime))
except OSError:
continue
return str(mt or 1)
def _password_hash() -> str:
return _hash_password(settings.auth.password)
class LoginBody(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class CancelPlanOrderBody(BaseModel):
order_id: str = Field(..., min_length=1, description="price_orders 的 id 或 id_string")
class ClosePositionBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64, description="如 BTC_USDT")
class ManualPriceOrderBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64)
trigger_price: str = Field(..., min_length=1, max_length=32)
rule: int = Field(1, ge=1, le=2, description="Gate:1 为价格>=触发价,2 为价格<=触发价")
class RiskPrefsBody(BaseModel):
min_reward_risk_ratio: float = Field(..., ge=0.1, le=50.0, description="面板保存的最低盈亏比")
class BreakevenPrefsBody(BaseModel):
global_enabled: bool | None = Field(None, description="全局移动保本开关")
contract: str | None = Field(None, min_length=3, max_length=64)
enabled: bool | None = Field(None, description="单合约覆盖;需同时传 contract")
@asynccontextmanager
async def _lifespan(_app: FastAPI):
log_path = Path(settings.app.log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
logger.info(
"executor %s:%s dry_run=%s max_positions=%s proxy=%s wecom=%s",
settings.app.host,
settings.app.port,
settings.gate.dry_run,
settings.risk.max_open_positions,
"on" if p else "off",
"on" if (settings.wecom.enabled and (settings.wecom.webhook_url or "").strip()) else "off",
)
start_breakeven_watcher(settings, signal_repo)
try:
yield
finally:
await stop_breakeven_watcher()
app = FastAPI(title="Gate Order Executor", version="0.2.0", lifespan=_lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app.session_secret,
max_age=60 * 60 * 24 * 7,
same_site="lax",
https_only=False,
)
app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static")
def _session_ok(request: Request) -> bool:
if not settings.auth.enabled:
return True
return request.session.get("logged_in") is True
def _require_ui_session(request: Request) -> None:
if not _session_ok(request):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
def _norm_contract_hist(contract: str | None) -> str | None:
if not contract:
return None
c = str(contract).strip()
return c.upper() if c else None
def _resolve_timerange(from_ts: int | None, to_ts: int | None) -> tuple[int, int]:
"""未传 from/to 时用最近 7 天;只传其一则补齐另一端。"""
now = int(time.time())
f, t = from_ts, to_ts
if f is None and t is None:
return _default_range_ts()
if f is None:
tt = int(t or now)
return tt - 86400 * 7, tt
if t is None:
ff = int(f)
return ff, now
return int(f), int(t)
@app.get("/health")
async def health() -> dict:
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
return {
"ok": True,
"dry_run": settings.gate.dry_run,
"open_slots": book.count(),
"proxy_on": bool(p),
"signal_db": bool(signal_repo),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
@app.get("/", response_model=None)
async def root(request: Request) -> RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return RedirectResponse("/dashboard", status_code=302)
@app.get("/login", response_model=None)
async def login_page(request: Request) -> HTMLResponse | RedirectResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
if _session_ok(request):
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse(
request,
"login.html",
{"asset_version": _asset_version()},
)
@app.post("/login", response_model=None)
async def login_post(request: Request, body: LoginBody) -> JSONResponse | RedirectResponse:
if not settings.auth.enabled:
return JSONResponse({"ok": True, "redirect": "/dashboard"})
if body.username.strip() != settings.auth.username.strip() or _hash_password(body.password) != _password_hash():
return JSONResponse({"ok": False, "detail": "账号或密码错误"}, status_code=401)
request.session["logged_in"] = True
return JSONResponse({"ok": True, "redirect": "/dashboard"})
@app.get("/logout", response_model=None)
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse("/login" if settings.auth.enabled else "/", status_code=302)
@app.get("/dashboard", response_model=None)
async def dashboard(request: Request) -> HTMLResponse | RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"username": settings.auth.username if settings.auth.enabled else "local",
"asset_version": _asset_version(),
},
)
@app.get("/api/state")
async def api_state(request: Request) -> dict:
_require_ui_session(request)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
fa: dict | None = None
fa_err: str | None = None
po: list[dict] | None = None
po_err: str | None = None
ex_pos: list[dict] | None = None
ex_pos_err: str | None = None
if settings.gate.api_key.strip() and settings.gate.api_secret.strip():
fa, fa_err = await read_futures_balance(settings)
po, po_err = await list_open_price_orders(settings)
ex_pos, ex_pos_err = await list_futures_positions(settings)
return {
"dry_run": settings.gate.dry_run,
"live_trading_enabled": (not settings.gate.dry_run)
and bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"gate_api_configured": bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"test_orders_enabled": bool(settings.gate.test_orders_enabled),
"test_max_contracts": int(settings.gate.test_max_contracts),
"futures_account": fa,
"futures_account_error": fa_err,
"open_price_orders": po,
"open_price_orders_error": po_err,
"proxy": {
"enabled": settings.proxy.enabled,
"effective": bool(p),
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"risk": {
"risk_per_trade_frac": settings.risk.risk_per_trade_frac,
"max_open_positions": settings.risk.max_open_positions,
"scheme": settings.risk.scheme,
"min_reward_risk_ratio": read_effective_min_reward_risk_ratio(settings),
"min_reward_risk_ratio_default": float(settings.risk.min_reward_risk_ratio),
},
"breakeven": await build_breakeven_state_for_api(
settings,
exchange_positions=ex_pos if isinstance(ex_pos, list) else None,
),
"positions": {
"open_slot_count": book.count(),
"exchange": ex_pos,
"exchange_error": ex_pos_err,
},
"recent_signals": _recent_signals_for_state(),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
def _recent_signals_for_state() -> list[dict]:
if not signal_repo:
return signal_history.list_recent()
try:
return signal_repo.list_recent(100)
except Exception: # noqa: BLE001
logger.exception("signal_db_list_failed")
return signal_history.list_recent()
def _signals_export_rows(limit: int = 500) -> list[dict]:
if signal_repo:
try:
return signal_repo.list_recent(limit)
except Exception: # noqa: BLE001
logger.exception("signal_export_list_failed")
return signal_history.list_recent()
def _test_http_status(body: GateTestRequest, out: dict) -> int:
if body.action == "balance":
return 502 if out.get("error") else 200
return 400 if out.get("ok") is False else 200
async def _run_gate_test(body: GateTestRequest) -> dict:
if body.action == "balance":
data, err = await read_futures_balance(settings)
return {"action": "balance", "balance": data, "error": err}
if body.action != "micro_market":
return {"ok": False, "error": "unsupported_action"}
if not body.contract.strip():
return {"ok": False, "error": "contract_required"}
return await post_test_market_order(
settings,
contract=body.contract.strip(),
side=body.side,
size_qty=body.size,
)
@app.post("/api/test")
async def api_test(request: Request, body: GateTestRequest) -> JSONResponse:
_require_ui_session(request)
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/api/risk-prefs")
async def api_risk_prefs(request: Request, body: RiskPrefsBody) -> JSONResponse:
"""面板保存最低盈亏比到 runtime/risk_prefs.json(需登录会话)。"""
_require_ui_session(request)
try:
v = write_min_reward_risk_ratio(body.min_reward_risk_ratio)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
return JSONResponse({"ok": True, "min_reward_risk_ratio": v})
@app.post("/api/breakeven-prefs")
async def api_breakeven_prefs(request: Request, body: BreakevenPrefsBody) -> JSONResponse:
"""保存移动保本全局/单合约开关到 runtime/breakeven_prefs.json。"""
_require_ui_session(request)
out: dict[str, Any] = {"ok": True}
if body.global_enabled is not None:
out["global_enabled"] = write_global_enabled(bool(body.global_enabled))
if body.contract is not None and body.enabled is not None:
try:
ct, en = write_contract_enabled(body.contract, bool(body.enabled))
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
remove_active(ct)
out["contract"] = ct
out["enabled"] = en
if body.global_enabled is None and (body.contract is None or body.enabled is None):
return JSONResponse({"ok": False, "detail": "nothing_to_save"}, status_code=400)
out["global_enabled"] = read_effective_global_enabled(settings)
out["prefs"] = read_prefs_snapshot()
return JSONResponse(out)
@app.post("/api/positions/market_close")
async def api_positions_market_close(request: Request, body: ClosePositionBody) -> JSONResponse:
_require_ui_session(request)
ct = body.contract.strip().upper()
order, err = await market_close_futures_position(settings, contract=ct)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"no_position",
"invalid_close_size",
}
code = 400 if err in bad_req else 502
try:
await notify_manual_close(settings, contract=ct, ok=False, detail=err, order=None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": False, "detail": err}, status_code=code)
book.release(ct)
remove_active(ct)
try:
await notify_manual_close(settings, contract=ct, ok=True, detail=None, order=order if isinstance(order, dict) else None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": True, "order": order})
@app.post("/api/price_orders/manual")
async def api_price_orders_manual(request: Request, body: ManualPriceOrderBody) -> JSONResponse:
_require_ui_session(request)
resp, err = await post_reduce_close_price_order(
settings,
contract=body.contract.strip().upper(),
trigger_price=body.trigger_price.strip(),
rule=body.rule,
)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"invalid_trigger_price",
"invalid_rule",
}
code = 400 if err in bad_req else 502
return JSONResponse({"ok": False, "detail": err}, status_code=code)
return JSONResponse({"ok": True, "price_order": resp})
@app.post("/api/price_orders/cancel")
async def api_cancel_plan_order(request: Request, body: CancelPlanOrderBody) -> JSONResponse:
_require_ui_session(request)
ok, err = await cancel_plan_price_order(settings, body.order_id)
if ok:
return JSONResponse({"ok": True})
return JSONResponse({"ok": False, "detail": err or "cancel_failed"}, status_code=400)
@app.get("/api/stats/summary")
async def api_stats_summary(
request: Request,
contract: str | None = None,
) -> dict:
"""正式统计:日/周/月(上海 08:00 统计日)基于 Gate 历史平仓 position_close 的 pnl 聚合。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
c = _norm_contract_hist(contract)
return await build_dashboard_stats(settings, contract=c)
@app.get("/api/gate/trades")
async def api_gate_trades(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""成交:Gate `GET /futures/{{settle}}/my_trades_timerange`(与面板「下载」同源)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
rows, err = await fetch_my_trades_timerange(
settings, contract=c, from_ts=f, to_ts=t, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/my_trades_timerange",
"contract": c,
"from": f,
"to": t,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/orders_history")
async def api_gate_orders_history(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""委托列表:Gate `GET /futures/{{settle}}/orders`status=open|finished)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
rows, err = await fetch_orders_list(
settings, status=st, contract=c, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/orders",
"status": st,
"contract": c,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/trades.csv", response_model=None)
async def api_gate_trades_csv(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
csv_text, err = await collect_trades_csv(
settings, contract=c, from_ts=f, to_ts=t, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="gate_futures_trades.csv"'},
)
@app.get("/api/gate/orders_history.csv", response_model=None)
async def api_gate_orders_history_csv(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
csv_text, err = await collect_orders_csv(
settings, status=st, contract=c, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
fn = "gate_futures_orders_open.csv" if st == "open" else "gate_futures_orders_finished.csv"
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)
@app.get("/api/signals/export.csv", response_model=None)
async def api_signals_export_csv(request: Request) -> Response:
"""面板「信号流」导出;需登录会话。"""
_require_ui_session(request)
rows = _signals_export_rows(500)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(
[
"ts_unix",
"time_utc",
"signal_id",
"contract",
"side",
"reference_price_used",
"take_profit_display",
"stop_loss_display",
"reward_risk_ratio",
"result_status",
"result_reason",
]
)
for row in rows:
s = row.get("signal") or {}
r = row.get("result") or {}
ts = row.get("ts")
try:
ts_f = float(ts) if ts is not None else 0.0
except (TypeError, ValueError):
ts_f = 0.0
tstr = datetime.fromtimestamp(ts_f, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
rp = r.get("reference_price_used")
if rp is None:
rp = r.get("reference_entry")
tp = r.get("take_profit_display")
if tp is None:
tp = r.get("take_profit_price_sent")
if tp is None:
tp = s.get("take_profit")
sl = r.get("stop_loss_display")
if sl is None:
sl = r.get("stop_loss_price_sent")
if sl is None:
sl = s.get("stop_loss")
rr = r.get("reward_risk_ratio")
w.writerow(
[
f"{ts_f:.6f}",
tstr,
s.get("signal_id") or "",
s.get("contract") or "",
s.get("side") or "",
"" if rp is None else rp,
"" if tp is None else tp,
"" if sl is None else sl,
"" if rr is None else rr,
r.get("status") or "",
r.get("reason") or "",
]
)
return Response(
content=buf.getvalue().encode("utf-8-sig"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="signal_stream.csv"'},
)
@app.post("/v1/test")
async def v1_test(
body: GateTestRequest,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/v1/signal")
async def post_signal(
body: TradeSignal,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
min_rr = read_effective_min_reward_risk_ratio(settings)
try:
m = await compute_signal_stream_metrics(settings, body, prior=None)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics_pre_gate failed signal_id=%s: %s", body.signal_id, exc)
out = {
"status": "skipped",
"reason": "reward_risk_missing",
"min_reward_risk_ratio": min_rr,
"reward_risk_reason": "metrics_failed",
"metrics_error": str(exc),
}
else:
rr = m.get("reward_risk_ratio")
if rr is None:
out = {"status": "skipped", "reason": "reward_risk_missing", "min_reward_risk_ratio": min_rr}
out.update(m)
elif float(rr) < min_rr:
out = {"status": "skipped", "reason": "reward_risk_below_min", "min_reward_risk_ratio": min_rr}
out.update(m)
else:
out = await handle_signal(settings, book, body)
out = await augment_signal_result(settings, body, out)
code = 200 if out.get("status") in {"accepted", "skipped"} else 500
signal_history.push({"signal": body.model_dump(), "result": out})
if signal_repo:
try:
signal_repo.insert_run(body.model_dump(), out, code)
except Exception as exc: # noqa: BLE001
logger.exception("signal_db_insert_failed")
try:
await notify_signal_db_insert_failed(
settings,
signal_id=str(body.signal_id or ""),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_db_failed")
try:
await notify_signal_execution(settings, signal=body.model_dump(), result=out, http_status=code)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_failed")
return JSONResponse(out, status_code=code)
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
Side = Literal["long", "short"]
class TradeSignal(BaseModel):
"""
扫描端在 TRIGGER(且你允许自动执行)时 POST 的载荷。
TP/SL 对应推送里「方案 A」已算好的价格;执行器永远按 scheme A 使用本字段。
"""
signal_id: str = Field(..., description="幂等键,建议 uuid 或 交易对+确认K时间戳")
contract: str = Field(..., description="Gate 永续合约名,如 BTC_USDT、XAU_USDT")
side: Side
take_profit: float = Field(..., gt=0, description="方案 A 止盈价")
stop_loss: float = Field(..., gt=0, description="方案 A 止损价")
# 可选:扫描端带的确认收盘价,用于日志与复核;市价单以成交为准
reference_price: float | None = Field(None, gt=0, description="如确认K收盘价")
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class GateTestRequest(BaseModel):
"""面板 / Webhook 测试请求体。"""
action: Literal["balance", "micro_market"] = "balance"
contract: str = Field("", description="如 BTC_USDTmicro_market 时必填")
side: Literal["long", "short"] = "long"
size: int = Field(1, ge=1, le=30, description="张数绝对值,服务端再与 test_max_contracts 取小")
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
from .wecom_notify import notify_oco_cancel_failed
logger = logging.getLogger(__name__)
_POLL_SEC = 18.0
_MAX_AGE_SEC = 604800.0 # 与计划单 expiration 同量级,超时丢弃避免列表泄漏
_pending: list[dict[str, Any]] = []
_lock = asyncio.Lock()
_task: asyncio.Task[None] | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def update_oco_sl_order_id(settings: Settings, *, contract: str, new_sl_id: str | int) -> None:
"""移动保本改挂新 SL 后,同步 OCO 清理队列中的 sl_id。"""
ct = contract.strip().upper()
nid = new_sl_id
async with _lock:
for row in _pending:
if str(row.get("contract") or "").strip().upper() != ct:
continue
row["sl_id"] = nid
logger.info("oco_sl_id_updated contract=%s new_sl_id=%s", ct, nid)
async def register_tp_sl_oco_cleanup(
settings: Settings,
*,
contract: str,
tp_order: dict[str, Any],
sl_order: dict[str, Any],
) -> None:
"""
登记一笔开仓挂出的止盈/止损计划单。当该合约净持仓为 0 时,尝试撤销两条计划单(另一腿未触发则清掉)。
"""
if not settings.risk.oco_cleanup_enabled:
return
if not _live_ok(settings):
return
tp_id = tp_order.get("id") if tp_order.get("id") is not None else tp_order.get("id_string")
sl_id = sl_order.get("id") if sl_order.get("id") is not None else sl_order.get("id_string")
if tp_id is None or sl_id is None:
return
row = {
"settings": settings,
"contract": contract.strip().upper(),
"tp_id": tp_id,
"sl_id": sl_id,
"t": time.monotonic(),
}
async with _lock:
_pending.append(row)
_ensure_loop()
def _ensure_loop() -> None:
global _task
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="oco_price_order_cleanup")
async def _poll_loop() -> None:
while True:
await asyncio.sleep(_POLL_SEC)
try:
await _tick()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("oco_watcher_tick_failed")
async def _tick() -> None:
async with _lock:
rows = list(_pending)
if not rows:
return
now = time.monotonic()
keep: list[dict[str, Any]] = []
for row in rows:
settings: Settings = row["settings"]
if not _live_ok(settings):
continue
if now - float(row["t"]) > _MAX_AGE_SEC:
logger.info("oco_watch_expired contract=%s", row["contract"])
continue
client = GateFuturesLive(settings)
contract = row["contract"]
try:
net = await fetch_net_position_size(client, contract)
except Exception as exc: # noqa: BLE001
logger.warning("oco_fetch_position_failed contract=%s: %s", contract, exc)
keep.append(row)
continue
if abs(net) > 1e-12:
keep.append(row)
continue
tp_id, sl_id = row["tp_id"], row["sl_id"]
cleanup_failed = False
for name, oid in (("tp", tp_id), ("sl", sl_id)):
try:
ok = await cancel_price_triggered_order(client, oid)
if ok:
logger.info("oco_cancelled contract=%s leg=%s order_id=%s", contract, name, oid)
except Exception as exc: # noqa: BLE001
logger.warning("oco_cancel_failed contract=%s leg=%s id=%s: %s", contract, name, oid, exc)
cleanup_failed = True
try:
await notify_oco_cancel_failed(
settings,
contract=contract,
leg=name,
order_id=str(oid),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_oco_failed")
if cleanup_failed:
keep.append(row)
async with _lock:
_pending[:] = keep + [r for r in _pending if r not in rows]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
@dataclass
class OpenSlot:
contract: str
signal_id: str
opened_at: float = field(default_factory=time.time)
class PositionBook:
"""进程内占位:实盘应对接交易所持仓或本地持久化。"""
def __init__(self, max_positions: int) -> None:
self._max = max_positions
self._lock = threading.Lock()
self._slots: dict[str, OpenSlot] = {}
def count(self) -> int:
with self._lock:
return len(self._slots)
def has_contract(self, contract: str) -> bool:
c = contract.strip().upper()
with self._lock:
return c in self._slots
def try_reserve(self, contract: str, signal_id: str) -> bool:
c = contract.strip().upper()
with self._lock:
if c in self._slots:
return False
if len(self._slots) >= self._max:
return False
self._slots[c] = OpenSlot(contract=c, signal_id=signal_id)
return True
def release(self, contract: str) -> None:
c = contract.strip().upper()
with self._lock:
self._slots.pop(c, None)
def sync_from_exchange(self, open_contracts: set[str]) -> None:
"""移除本地有占位但交易所已无持仓的合约(避免槽位永久占满)。"""
with self._lock:
for c in list(self._slots.keys()):
if c not in open_contracts:
self._slots.pop(c, None)
+39
View File
@@ -0,0 +1,39 @@
"""与 onchain_scout_gate 相同的代理 URL 处理,供 httpx 出站(Gate 私有 API 等)。"""
from __future__ import annotations
import httpx
def httpx_proxy_url(proxy_url: str | None) -> str | None:
"""
将配置中的代理地址转为 httpx 可用形式。
``socks5h://`` 在部分环境下会报 Unknown scheme,退化为 ``socks5://``。
"""
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
def effective_proxy_url(proxy_enabled: bool, proxy_url: str | None) -> str | None:
if not proxy_enabled:
return None
return httpx_proxy_url(proxy_url.strip() if proxy_url else None)
def httpx_client_kwargs(
proxy_enabled: bool,
proxy_url: str | None,
*,
timeout_connect: float = 10.0,
timeout_read: float = 16.0,
) -> dict:
"""与扫描端 Gate 客户端一致的出站策略:有代理则 trust_env=False。"""
timeout = httpx.Timeout(timeout_connect, read=timeout_read)
p = effective_proxy_url(proxy_enabled, proxy_url)
if p:
return {"timeout": timeout, "proxy": p, "trust_env": False}
return {"timeout": timeout, "trust_env": True}
@@ -0,0 +1,63 @@
"""面板可写的风险偏好:持久化到 runtime/risk_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "risk_prefs.json"
_lock = threading.Lock()
def _read_json_file(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
try:
raw = 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("risk_prefs_read_failed: %s", exc)
return None
def read_effective_min_reward_risk_ratio(settings: Settings) -> float:
"""优先 runtime 文件,否则 risk.min_reward_risk_ratioconfig 默认)。"""
base = float(settings.risk.min_reward_risk_ratio)
with _lock:
data = _read_json_file(_PREFS_PATH)
if not data:
return base
try:
v = float(data.get("min_reward_risk_ratio"))
except (TypeError, ValueError):
return base
lo, hi = 0.1, 50.0
if not (lo <= v <= hi):
return base
return v
def write_min_reward_risk_ratio(value: float) -> float:
"""写入并返回规范化后的值(与读侧范围一致)。"""
lo, hi = 0.1, 50.0
v = float(value)
if not (lo <= v <= hi):
raise ValueError(f"min_reward_risk_ratio must be in [{lo}, {hi}]")
payload = json.dumps({"min_reward_risk_ratio": v}, indent=2, ensure_ascii=False) + "\n"
with _lock:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
return v
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import threading
import time
from collections import deque
class SignalHistory:
"""最近信号与执行结果(内存,进程重启清空)。"""
def __init__(self, maxlen: int = 100) -> None:
self._q: deque[dict] = deque(maxlen=maxlen)
self._lock = threading.Lock()
def push(self, item: dict) -> None:
with self._lock:
self._q.appendleft({**item, "ts": time.time()})
def list_recent(self) -> list[dict]:
with self._lock:
return list(self._q)
+100
View File
@@ -0,0 +1,100 @@
"""信号流展示:现价、按合约 tick 对齐的 TP/SL 字符串、盈亏比(相对现价)。"""
from __future__ import annotations
import logging
from typing import Any
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def _reward_risk_ratio(side: str, p: float, tp: float, sl: float) -> tuple[float | None, str | None]:
if not (p > 0 and tp > 0 and sl > 0):
return None, "invalid_prices"
if side == "long":
reward = tp - p
risk = p - sl
elif side == "short":
reward = p - tp
risk = sl - p
else:
return None, "invalid_side"
if risk <= 0:
return None, "non_positive_risk"
if reward <= 0:
return None, "non_positive_reward"
return reward / risk, None
async def compute_signal_stream_metrics(
settings: Settings, sig: TradeSignal, prior: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
现价:优先信号 reference_price;否则 ticker last;再否则 prior.reference_entry(实盘已算 entry)。
止盈/止损展示:与下单相同的 tick 对齐字符串。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
p: float | None = None
if sig.reference_price is not None and float(sig.reference_price) > 0:
p = float(sig.reference_price)
elif last > 0:
p = last
elif prior:
ref_e = prior.get("reference_entry")
if ref_e is not None:
try:
pe = float(ref_e)
except (TypeError, ValueError):
pe = 0.0
if pe > 0:
p = pe
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
tick = _trigger_price_tick(cdata)
tp_s = _format_trigger_price(float(sig.take_profit), tick)
sl_s = _format_trigger_price(float(sig.stop_loss), tick)
rr: float | None = None
rr_reason: str | None = None
if p is not None and p > 0:
try:
tp_f = float(tp_s)
sl_f = float(sl_s)
except ValueError:
rr_reason = "invalid_trigger_float"
else:
rr, rr_reason = _reward_risk_ratio(str(sig.side), p, tp_f, sl_f)
return {
"reference_price_used": float(p) if p is not None and p > 0 else None,
"take_profit_display": tp_s,
"stop_loss_display": sl_s,
"reward_risk_ratio": round(rr, 6) if rr is not None else None,
"reward_risk_reason": rr_reason,
}
async def augment_signal_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> dict[str, Any]:
out = dict(result)
try:
m = await compute_signal_stream_metrics(settings, sig, prior=out)
out.update(m)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics failed signal_id=%s: %s", sig.signal_id, exc)
return out
@@ -0,0 +1,204 @@
"""信号与执行结果 SQLite 落库(标准库 sqlite3)。"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def _resolve_sqlite_path(raw: str, root: Path) -> Path:
p = Path(raw.strip())
if not p.is_absolute():
p = (root / p).resolve()
return p
class SignalRepository:
"""线程安全;每条 POST /v1/signal 处理完成后写入一行。"""
def __init__(self, sqlite_path: Path) -> None:
self._path = sqlite_path
self._lock = threading.Lock()
@classmethod
def from_settings(cls, sqlite_path_cfg: str, root: Path) -> SignalRepository | None:
"""配置了非空 ``sqlite_path`` 即落库;空字符串则仅内存环形表(与旧版一致)。"""
raw = (sqlite_path_cfg or "").strip()
if not raw:
return None
return cls(_resolve_sqlite_path(raw, root))
def init_schema(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
CREATE TABLE IF NOT EXISTS signal_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
http_status INTEGER NOT NULL,
signal_id TEXT NOT NULL,
contract TEXT NOT NULL,
side TEXT NOT NULL,
take_profit REAL,
stop_loss REAL,
reference_price REAL,
signal_json TEXT NOT NULL,
result_status TEXT NOT NULL,
result_mode TEXT,
result_reason TEXT,
result_detail TEXT,
stage TEXT,
market_order_id TEXT,
take_profit_order_id TEXT,
stop_loss_order_id TEXT,
result_json TEXT NOT NULL
)
"""
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_signal_id ON signal_runs(signal_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_created_at ON signal_runs(created_at)"
)
con.commit()
finally:
con.close()
def insert_run(self, signal: dict[str, Any], result: dict[str, Any], http_status: int) -> None:
created = time.time()
sig_json = json.dumps(signal, ensure_ascii=False, separators=(",", ":"))
res_json = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
market_obj = result.get("market_order") or result.get("order")
detail_raw = result.get("detail")
if detail_raw is None:
detail_s: str | None = None
elif isinstance(detail_raw, str):
detail_s = detail_raw
else:
try:
detail_s = json.dumps(detail_raw, ensure_ascii=False)
except Exception:
detail_s = str(detail_raw)
row = (
created,
int(http_status),
str(signal.get("signal_id") or ""),
str(signal.get("contract") or "").strip().upper(),
str(signal.get("side") or ""),
signal.get("take_profit"),
signal.get("stop_loss"),
signal.get("reference_price"),
sig_json,
str(result.get("status") or ""),
result.get("mode"),
result.get("reason"),
detail_s,
result.get("stage") if isinstance(result.get("stage"), str) else None,
_gate_order_id(market_obj),
_gate_order_id(result.get("take_profit_order")),
_gate_order_id(result.get("stop_loss_order")),
res_json,
)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
INSERT INTO signal_runs (
created_at, http_status, signal_id, contract, side,
take_profit, stop_loss, reference_price, signal_json,
result_status, result_mode, result_reason, result_detail, stage,
market_order_id, take_profit_order_id, stop_loss_order_id, result_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
row,
)
con.commit()
finally:
con.close()
def find_latest_accepted_for_contract(self, contract: str) -> dict[str, Any] | None:
ct = str(contract or "").strip().upper()
if not ct:
return None
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
WHERE contract = ? AND result_status = 'accepted'
ORDER BY id DESC
LIMIT 1
""",
(ct,),
)
row = cur.fetchone()
finally:
con.close()
if not row:
return None
created_at, sig_j, res_j = row
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
return {"ts": float(created_at), "signal": sig, "result": res}
def list_recent(self, limit: int = 100) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 500))
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
ORDER BY id DESC
LIMIT ?
""",
(lim,),
)
rows = cur.fetchall()
finally:
con.close()
out: list[dict[str, Any]] = []
for created_at, sig_j, res_j in rows:
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
out.append({"ts": float(created_at), "signal": sig, "result": res})
return out
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from .config import Settings
from .gate_history import collect_position_close_rows
def _parse_official_start_iso(s: str) -> float:
raw = (s or "").strip()
if not raw:
raise ValueError("stats.official_start is empty")
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
raise ValueError("stats.official_start must include a timezone offset (e.g. +08:00)")
return dt.timestamp()
def _position_close_ts(row: dict[str, Any]) -> float | None:
"""Gate `GET .../position_close` 行的平仓时间戳(字段 `time`)。"""
t = row.get("time")
if t is None:
return None
try:
return float(t)
except (TypeError, ValueError):
return None
def _float_field(row: dict[str, Any], *keys: str) -> float | None:
for k in keys:
v = row.get(k)
if v is None or v == "":
continue
try:
return float(v)
except (TypeError, ValueError):
return None
return None
def _row_close_pnl(row: dict[str, Any]) -> float | None:
"""历史平仓记录上的净盈亏(字符串数值)。"""
return _float_field(row, "pnl", "realised_pnl", "realized_pnl")
def stat_day_date(ts: float, tz: ZoneInfo) -> date:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.date()
def stat_month_key(ts: float, tz: ZoneInfo) -> tuple[int, int]:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.year, shifted.month
def stat_day_window(d: date, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(d.year, d.month, d.day, 8, 0, 0, tzinfo=tz)
end = start + timedelta(days=1)
return start.timestamp(), end.timestamp()
def month_window(y: int, m: int, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(y, m, 1, 8, 0, 0, tzinfo=tz)
if m == 12:
end = datetime(y + 1, 1, 1, 8, 0, 0, tzinfo=tz)
else:
end = datetime(y, m + 1, 1, 8, 0, 0, tzinfo=tz)
return start.timestamp(), end.timestamp()
def monday_of_week(d: date) -> date:
return d - timedelta(days=d.weekday())
def aggregate_pnls(pnls_ordered: list[float]) -> dict[str, Any]:
if not pnls_ordered:
return {
"trade_count": 0,
"wins": 0,
"losses": 0,
"breakeven": 0,
"win_rate": None,
"profit_factor": None,
"gross_profit": 0.0,
"gross_loss": 0.0,
"net_pnl": 0.0,
"max_single_loss": None,
"max_drawdown": 0.0,
"max_consecutive_losses": 0,
}
wins = losses = be = 0
gp = 0.0
gl = 0.0
max_consec = 0
streak = 0
for p in pnls_ordered:
if p > 0:
wins += 1
gp += p
streak = 0
elif p < 0:
losses += 1
gl += p
streak += 1
max_consec = max(max_consec, streak)
else:
be += 1
streak = 0
total = len(pnls_ordered)
win_rate = wins / total if total else None
gross_loss_abs = abs(gl)
if gross_loss_abs > 1e-12:
profit_factor: float | None = gp / gross_loss_abs
else:
profit_factor = None
min_p = min(pnls_ordered)
max_single_loss = min_p if min_p < -1e-12 else None
eq = 0.0
peak = 0.0
mdd = 0.0
for p in pnls_ordered:
eq += p
peak = max(peak, eq)
mdd = max(mdd, peak - eq)
return {
"trade_count": total,
"wins": wins,
"losses": losses,
"breakeven": be,
"win_rate": win_rate,
"profit_factor": profit_factor,
"gross_profit": gp,
"gross_loss": gl,
"net_pnl": sum(pnls_ordered),
"max_single_loss": max_single_loss,
"max_drawdown": mdd,
"max_consecutive_losses": max_consec,
}
def _detect_pnl_field(rows: list[dict[str, Any]]) -> str:
for r in rows:
if _float_field(r, "pnl") is not None:
return "pnl"
if _float_field(r, "realised_pnl", "realized_pnl") is not None:
return "realised_pnl"
return "missing"
def _pnls_for_stat_day(events: list[tuple[float, float]], d: date, tz: ZoneInfo, official_ts: float) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_day_date(ts, tz) != d:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_week(
events: list[tuple[float, float]], mon: date, sun: date, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
dd = stat_day_date(ts, tz)
if dd < mon or dd > sun:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_month(
events: list[tuple[float, float]], y: int, m: int, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_month_key(ts, tz) != (y, m):
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
async def build_dashboard_stats(
settings: Settings,
*,
contract: str | None,
) -> dict[str, Any]:
try:
tz = ZoneInfo(settings.stats.timezone)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"invalid stats.timezone: {exc}"}
try:
official_ts = _parse_official_start_iso(settings.stats.official_start)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
now = time.time()
cap = int(settings.stats.max_trade_rows)
rows, err = await collect_position_close_rows(
settings,
contract=contract,
from_ts=int(official_ts),
to_ts=int(now),
max_rows=cap,
)
if err:
return {"ok": False, "error": err}
if rows is None:
return {"ok": False, "error": "api keys not configured"}
missing_pnl = 0
events: list[tuple[float, float]] = []
for row in rows:
ts = _position_close_ts(row)
if ts is None or ts < official_ts:
continue
pnl = _row_close_pnl(row)
if pnl is None:
missing_pnl += 1
continue
events.append((ts, pnl))
events.sort(key=lambda x: x[0])
pnl_field = _detect_pnl_field(rows)
truncated = len(rows) >= cap
d_cur = stat_day_date(now, tz)
day_start, day_end = stat_day_window(d_cur, tz)
mon, sun = monday_of_week(d_cur), monday_of_week(d_cur) + timedelta(days=6)
my, mm = stat_month_key(now, tz)
m_start, m_end = month_window(my, mm, tz)
def pack_period(
*,
bucket: str,
label: str,
start_ts: float,
end_ts: float,
pnls: list[float],
) -> dict[str, Any]:
partial = now < end_ts
metrics = aggregate_pnls(pnls)
return {
"bucket": bucket,
"label": label,
"start_ts": start_ts,
"end_ts": end_ts,
"partial": partial,
"metrics": metrics,
}
day_pnls = _pnls_for_stat_day(events, d_cur, tz, official_ts)
week_pnls = _pnls_for_week(events, mon, sun, tz, official_ts)
month_pnls = _pnls_for_month(events, my, mm, tz, official_ts)
return {
"ok": True,
"timezone": settings.stats.timezone,
"official_start": settings.stats.official_start,
"official_start_ts": official_ts,
"now_ts": now,
"contract": contract,
"pnl_field": pnl_field,
"closing_rows_missing_pnl": missing_pnl,
"fetched_position_close_rows": len(rows),
"fetched_trade_rows": len(rows),
"truncated": truncated,
"definitions": {
"unit": "每条 Gate 历史平仓(GET /futures/{settle}/position_close)且能解析到 pnl 的记录,按平仓 time 排序后做序列指标。",
"win_rate": "盈利笔数 / 总笔数(含盈亏为 0)。",
"profit_factor": "毛利和 / |毛亏和|;若无亏损则 null。",
"max_single_loss": "单笔最小 pnl(最负的一笔),无亏损为 null。",
"max_drawdown": "按时间累加 pnl 的权益曲线,相对历史峰值的 max(peakequity)。",
"max_consecutive_losses": "连续 pnl<0 的最大笔数。",
"day": "统计日 [D 08:00, D+1 08:00) 上海;D=(本地时刻−8h) 的日历日。",
"week": "自然周周一至周日(上海日历),聚合落在该周内的平仓记录。",
"month": "自然月 [当月1日08:00, 次月1日08:00) 上海,与统计日对齐。",
},
"day": pack_period(
bucket="day",
label=d_cur.isoformat(),
start_ts=day_start,
end_ts=day_end,
pnls=day_pnls,
),
"week": pack_period(
bucket="week",
label=f"{mon.isoformat()}_{sun.isoformat()}",
start_ts=stat_day_window(mon, tz)[0],
end_ts=stat_day_window(sun, tz)[1],
pnls=week_pnls,
),
"month": pack_period(
bucket="month",
label=f"{my:04d}-{mm:02d}",
start_ts=m_start,
end_ts=m_end,
pnls=month_pnls,
),
}
+181
View File
@@ -0,0 +1,181 @@
"""企业微信群机器人:仅推送执行器侧执行结果(策略/发现类仍由扫描端)。"""
from __future__ import annotations
import logging
import time
from typing import Any
import httpx
from .config import Settings
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
_MAX_MD_LEN = 3500
_oco_last_sent: dict[str, float] = {}
_OCO_COOLDOWN_SEC = 600.0
def _wecom_ready(settings: Settings) -> str | None:
w = settings.wecom
if not w.enabled:
return None
url = (w.webhook_url or "").strip()
return url or None
def _clip(s: str, n: int = 800) -> str:
t = str(s).replace("\r\n", "\n").strip()
if len(t) > n:
return t[: n - 1] + ""
return t
async def _post_markdown(settings: Settings, title: str, body_lines: list[str]) -> None:
url = _wecom_ready(settings)
if not url:
return
text = "\n".join([f"## {_clip(title, 120)}", ""] + body_lines)
if len(text) > _MAX_MD_LEN:
text = text[: _MAX_MD_LEN - 20] + "\n…(truncated)"
payload = {"msgtype": "markdown", "markdown": {"content": text}}
kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url, timeout_connect=6.0, timeout_read=12.0)
try:
async with httpx.AsyncClient(**kw) as client:
r = await client.post(url, json=payload)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and int(data.get("errcode") or 0) != 0:
logger.warning("wecom_api_err: %s", data)
except Exception: # noqa: BLE001
logger.exception("wecom_post_failed")
async def notify_signal_execution(
settings: Settings,
*,
signal: dict[str, Any],
result: dict[str, Any],
http_status: int,
) -> None:
"""每条 POST /v1/signal 处理结束后推送摘要。"""
if not _wecom_ready(settings):
return
st = str(result.get("status") or "")
title = "执行器 · 信号结果"
if st == "accepted":
title += " · accepted"
elif st == "skipped":
title += " · skipped"
else:
title += " · error"
lines = [
f">signal_id: `{_clip(str(signal.get('signal_id') or ''), 80)}`",
f">contract: **{_clip(str(signal.get('contract') or ''), 32)}** side: `{_clip(str(signal.get('side') or ''), 8)}`",
f">http: **{http_status}** mode: `{_clip(str(result.get('mode') or ''), 20)}`",
f">status: **{st}**",
]
if result.get("reason"):
lines.append(f">reason: `{_clip(str(result.get('reason')), 200)}`")
if result.get("stage"):
lines.append(f">stage: `{_clip(str(result.get('stage')), 40)}`")
if result.get("detail"):
lines.append(f">detail: `{_clip(str(result.get('detail')), 500)}`")
if result.get("sized_contracts") is not None:
lines.append(f">size: `{_clip(str(result.get('sized_contracts')), 40)}`")
if result.get("market_order") and isinstance(result.get("market_order"), dict):
mo = result["market_order"]
oid = mo.get("id") or mo.get("order_id")
if oid is not None:
lines.append(f">market_order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_execution_failed")
async def notify_manual_close(
settings: Settings,
*,
contract: str,
ok: bool,
detail: str | None,
order: dict[str, Any] | None,
) -> None:
"""面板一键市价全平结果。"""
if not _wecom_ready(settings):
return
title = "执行器 · 一键平仓 · 成功" if ok else "执行器 · 一键平仓 · 失败"
lines = [f">contract: **{_clip(contract, 32)}**"]
if detail:
lines.append(f">detail: `{_clip(detail, 400)}`")
if ok and isinstance(order, dict):
oid = order.get("id") or order.get("order_id")
if oid is not None:
lines.append(f">order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_manual_close_failed")
async def notify_oco_cancel_failed(
settings: Settings,
*,
contract: str,
leg: str,
order_id: str,
detail: str,
) -> None:
"""OCO 清理撤另一腿失败:带冷却,避免 18s 轮询刷屏。"""
if not _wecom_ready(settings):
return
key = f"{contract}:{leg}:{order_id}"
now = time.time()
if now - _oco_last_sent.get(key, 0.0) < _OCO_COOLDOWN_SEC:
return
_oco_last_sent[key] = now
if len(_oco_last_sent) > 500:
_oco_last_sent.clear()
title = "执行器 · OCO 撤单异常"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">leg: `{_clip(leg, 8)}` price_order_id: `{_clip(order_id, 40)}`",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_oco_cancel_failed_post")
async def notify_breakeven_failed(settings: Settings, *, contract: str, detail: str) -> None:
"""移动保本改挂止损失败(仅失败推送)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 移动保本失败"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_breakeven_failed_post")
async def notify_signal_db_insert_failed(settings: Settings, *, signal_id: str, detail: str) -> None:
"""SQLite 落库失败(HTTP 仍返回信号结果时单独告警)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 信号落库失败"
lines = [
f">signal_id: `{_clip(signal_id, 80)}`",
f">detail: `{_clip(detail, 600)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_db_insert_failed_post")
+63
View File
@@ -0,0 +1,63 @@
# 复制为 config.yaml 后修改;勿将含密钥的 config.yaml 提交到 git。
app:
host: "127.0.0.1"
port: 8090
log_file: "./runtime/executor.log"
session_secret: "change-me-to-long-random-string"
auth:
enabled: true
username: "admin"
password: "ChangeThisPassword!"
security:
webhook_secret: "change-me-to-long-random-string"
# 企业微信群机器人:仅推送执行器「执行结果」(信号处理结果、一键平仓、OCO 撤单异常、SQLite 落库失败)。
# 策略/发现类通知仍由 onchain_scout_gate 扫描端发送。需在企业微信建群机器人并复制 Webhook URL。
wecom:
enabled: false
webhook_url: ""
gate:
api_base: "https://api.gateio.ws/api/v4"
settle: "usdt"
api_key: ""
api_secret: ""
# true:只校验与日志,不下单。实盘:改为 false,并填写 api_key / api_secret(子账户 + IP 白名单)
dry_run: true
# 仅人工测试:为 true 时允许 POST /api/test 与 /v1/test 的 micro_market 发真实 IOC 市价(张数受 test_max_contracts 限制;联调见 docs/使用说明 §4.1)
test_orders_enabled: false
test_max_contracts: 1
risk:
risk_per_trade_frac: 0.005
max_open_positions: 5
scheme: "A"
# Gate 官方未提供「单笔双触发 OCO」时,为 true 表示净持仓为 0 后由本进程撤掉另一腿计划单(推荐保持 true)
oco_cleanup_enabled: true
# 最低盈亏比(config 默认);信号流面板可保存到 runtime/risk_prefs.json 覆盖,无需改 yaml
min_reward_risk_ratio: 1.3
# 移动保本:浮盈达 1R(相对初始止损)后,止损拉至开仓价 ± buffer_pct;面板可写 runtime/breakeven_prefs.json
breakeven_stop:
enabled: true
trigger_r: 1.0
buffer_pct: 0.002
poll_interval_sec: 8
# 面板「统计」:正式起始时刻与统计日边界(见 docs/使用说明.md)
stats:
timezone: "Asia/Shanghai"
official_start: "2026-05-13T02:00:00+08:00"
max_trade_rows: 20000
# 信号流与执行结果:写入 SQLite(默认 ./runtime/signals.sqlite);留空会自动回退为该路径,保证重启后仍可读
database:
enabled: true # 兼容旧字段
sqlite_path: "./runtime/signals.sqlite"
# 与 onchain_scout_gate 的 proxy 块相同写法;enabled=true 时访问 Gate 走此代理
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
+17
View File
@@ -0,0 +1,17 @@
# deploy 脚本说明
所有脚本默认项目路径为 **`/root/gate_order_executor`**;可通过第一个参数传入你的绝对路径(`bootstrap.sh``start.sh`)。
| 文件 | 作用 |
|------|------|
| `ecosystem.config.cjs` | PM2 配置:单实例、`PYTHONUNBUFFERED=1`、日志在 `runtime/` |
| `bootstrap.sh` | 首次:`venv` + `pip install -r requirements.txt` + 生成 `config.yaml` 模板 |
| `start.sh` | 前台运行 `python run.py`(调试用) |
| `pm2-start.sh` | PM2 启动;若应用已存在则 `restart` |
| `pm2-restart.sh` | `pm2 restart gate-order-executor` |
| `pm2-stop.sh` | `pm2 stop gate-order-executor` |
| `pm2-delete.sh` | `pm2 delete gate-order-executor` |
| `gate-order-executor.service` | systemd 示例(需改 `WorkingDirectory``pm2-runtime` 路径) |
使用说明与接口文档:`../docs/使用说明.md`
部署步骤:`../docs/部署说明.md`
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 首次部署:创建 venv、安装依赖、生成 config 模板、创建 runtime 目录。
# 用法:bash deploy/bootstrap.sh [项目绝对路径]
# 默认:/root/gate_order_executor
set -euo pipefail
PROJECT_DIR="${1:-/root/gate_order_executor}"
cd "$PROJECT_DIR"
if ! command -v python3 >/dev/null 2>&1; then
echo "请先安装 python3" >&2
exit 1
fi
python3 -m venv .venv
# shellcheck source=/dev/null
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -r requirements.txt
# SOCKS 代理与扫描一致时建议安装(与 httpx[socks] 一致)
python -m pip install "socksio>=1.0,<2" || true
if [ ! -f config.yaml ]; then
cp -n config.example.yaml config.yaml
echo "已从 config.example.yaml 创建 config.yaml,请编辑:auth、security.webhook_secret、proxy、gate 等。"
fi
mkdir -p runtime
echo "Bootstrap 完成:$PROJECT_DIR"
@@ -0,0 +1,38 @@
/**
* PM2 守护 gate_order_executorGate 下单执行器)
*
* 在项目根目录执行:
* ./deploy/pm2-start.sh
* 或:
* pm2 start deploy/ecosystem.config.cjs
*
* 监听 host/port 来自项目根目录 config.yaml → app.host / app.port(由 run.py 内 uvicorn 读取)。
*/
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const isWin = process.platform === "win32";
const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python"));
module.exports = {
apps: [
{
name: "gate-order-executor",
cwd: ROOT,
script: py,
args: ["run.py"],
interpreter: "none",
autorestart: true,
watch: false,
max_restarts: 20,
min_uptime: "10s",
exp_backoff_restart_delay: 2000,
error_file: path.join(ROOT, "runtime", "pm2-executor-error.log"),
out_file: path.join(ROOT, "runtime", "pm2-executor-out.log"),
merge_logs: true,
time: true,
env: {
PYTHONUNBUFFERED: "1",
},
},
],
};
@@ -0,0 +1,18 @@
[Unit]
Description=Gate Order Executor (Python) via PM2
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/gate_order_executor
Environment=NODE_ENV=production
Environment=PYTHONUNBUFFERED=1
# 需全局安装:npm install -g pm2
# 若 pm2-runtime 不在 PATH,请改为绝对路径:which pm2-runtime
ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 delete gate-order-executor || true
pm2 save || true
echo "已从 PM2 删除 gate-order-executor"
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 restart gate-order-executor --update-env
echo "已重启 gate-order-executor"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# 使用 PM2 启动 gate-order-executor(需已 bootstrap 且已配置 config.yaml
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2,请先执行:npm install -g pm2" >&2
exit 1
fi
if [ ! -x ".venv/bin/python" ]; then
echo "未找到 .venv/bin/python,请先执行:bash deploy/bootstrap.sh \"$ROOT\"" >&2
exit 1
fi
mkdir -p runtime
if pm2 describe gate-order-executor >/dev/null 2>&1; then
echo "进程已存在,改为重启:pm2 restart gate-order-executor"
pm2 restart gate-order-executor --update-env
else
pm2 start "$ROOT/deploy/ecosystem.config.cjs"
fi
pm2 save || true
echo "已启动。日志:pm2 logs gate-order-executor"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 stop gate-order-executor || true
echo "已停止 gate-order-executor(进程仍在列表中可用 pm2 delete 删除)"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 前台调试(无 PM2)。生产请用:./deploy/pm2-start.sh
# 用法:bash deploy/start.sh [项目绝对路径]
set -euo pipefail
PROJECT_DIR="${1:-/root/gate_order_executor}"
cd "$PROJECT_DIR"
# shellcheck source=/dev/null
source .venv/bin/activate
exec python run.py
+218
View File
@@ -0,0 +1,218 @@
# Gate 下单执行器 · 使用说明
本文说明 `gate_order_executor` 的职责、配置项、Web 面板与 HTTP 接口。与 **部署安装** 相关的步骤见 [部署说明.md](./部署说明.md)。
## 1. 系统定位
-`onchain_scout_gate`MATRIX 扫描)**分进程**运行:扫描只用 Gate **公共**行情;本服务接收信号并可在 **`gate.dry_run: false`** 且配置密钥时 **向 Gate 发真实委托**
- **`gate.dry_run: true`(默认)**:只校验占位与日志,不下单。
- 不建议把 Gate API Key 放在扫描服务的配置里;密钥仅配置在本项目的 `config.yaml`(本机、权限收紧)。
## 2. 目录结构(常用)
| 路径 | 说明 |
|------|------|
| `config.yaml` | 本地配置(勿提交 git;由 `config.example.yaml` 复制) |
| `run.py` | 启动入口(uvicorn 读取 `app.host` / `app.port` |
| `app/main.py` | FastAPI:健康检查、登录、面板、`POST /v1/signal``GET /api/state``GET /api/stats/summary``POST /api/test`、**Gate 成交/委托查询与 CSV**`GET /api/gate/*` |
| `app/gate_history.py` | 拉取 Gate **`my_trades_timerange`** / **`orders`**,拼 CSVUTF-8 BOM);分页原始行 `collect_trades_rows` |
| `app/stats.py` | 面板统计:正式起点后历史平仓(`position_close`)的日/周/月聚合 |
| `app/gate_operations.py` | 拉取账户余额、**测试市价接口**(`post_test_market_order`)、列出/撤销计划委托、**非零持仓摘要**(`GET /futures/{settle}/positions` |
| `app/gate_futures_live.py` | 实盘:签名请求、市价 IOC 开仓、计划委托止盈/止损、以损订仓 |
| `app/oco_watcher.py` | 净持仓为 0 后撤掉本次止盈/止损计划单另一腿(见 §3.4.2) |
| `app/gate_auth.py` | Gate APIv4 请求签名 |
| `app/proxy_util.py` | 与扫描相同的代理处理;`httpx_client_kwargs` 供访问 Gate 时使用 |
| `deploy/ecosystem.config.cjs` | PM2 配置 |
| `deploy/bootstrap.sh` | 首次创建 venv 与依赖 |
| `deploy/pm2-start.sh` | PM2 启动/已存在则重启 |
## 3. 配置说明(`config.yaml`
### 3.1 `app`
| 字段 | 说明 |
|------|------|
| `host` | 监听地址。仅本机访问面板/接口用 `127.0.0.1`;局域网或 SSH 隧道外访问用 `0.0.0.0`(请配合防火墙与 `auth`)。 |
| `port` | 默认 `8090`,避免与扫描默认 `8088` 冲突。 |
| `log_file` | 应用日志路径(目录会自动创建)。 |
| `session_secret` | Cookie 会话密钥,请改为长随机串。 |
### 3.2 `auth`
| 字段 | 说明 |
|------|------|
| `enabled` | `false` 时跳过登录(仅建议纯局域网)。公网或不可信网络务必 `true`。 |
| `username` / `password` | 面板登录;密码以 SHA256 摘要校验(与扫描面板思路一致)。 |
### 3.3 `security`
| 字段 | 说明 |
|------|------|
| `webhook_secret` | 调用 `POST /v1/signal``POST /v1/test` 时请求头 `X-Webhook-Secret` 必须与此一致。 |
### 3.4 `gate`
| 字段 | 说明 |
|------|------|
| `api_base` / `settle` | 与扫描端 Gate 公共 API 一致即可。 |
| `api_key` / `api_secret` | 实盘必填(建议子账户、只开合约、IP 白名单)。 |
| `dry_run` | `true` 不下单;**实盘改为 `false`** 且密钥有效时,收到信号将 **市价开仓** 并挂 **计划委托** 止盈/止损。 |
| `test_orders_enabled` | 默认 `false`。为 `true` 时允许通过 `POST /api/test``POST /v1/test``action: micro_market` 发送 **真实** 极小 IOC 市价单(**仅接口联调**,见 §4.1);务必使用子账户、小额、`test_max_contracts` 限制张数。 |
| `test_max_contracts` | 与测试请求里的张数取 **最小值**,上限 30,默认 1。 |
### 3.4.1 实盘逻辑摘要(`app/gate_futures_live.py`
- **开仓**`POST /futures/usdt/orders``price=0``tif=ioc`,做多 `size` 为正、做空为负;`text``t-e` + 清洗后的 `signal_id`
- **止盈 / 止损**:各 `POST /futures/usdt/price_orders``initial``reduce_only` + 市价 IOC + **`close: true`** + **`size: 0`**Gate 要求:单向全平时 `close=true``initial.size` 必须为 0,否则会报 `AUTO_INVALID_PARAM_INITIAL_SIZE`),`trigger` 规则与方向匹配(多:TP 用 rule≥、SL 用 rule≤;空相反)。
- **一腿成交后撤另一腿**:见 **§3.4.2**;默认 **`risk.oco_cleanup_enabled: true`** 时由 **`oco_watcher`**(约每 18s)在净持仓为 0 后 `DELETE` 本次两条 `price_orders` 中仍挂着的一腿。
- **以损订仓**:用 `GET /futures/usdt/accounts``total`USDT)× `risk.risk_per_trade_frac`,再除以 `|参考价−止损|×quanto_multiplier``quanto_multiplier` 来自合约详情)。若低于 `order_size_min` 则拒绝该笔信号。
- **参考价**:优先 `reference_price`;否则用合约 ticker `last`
- **持仓上限**:每次信号前拉取 `GET /futures/usdt/positions`,与本地占位同步;交易所已有 **≥ max_open_positions** 个非零持仓则跳过;**该合约在交易所已有仓**也跳过(避免重复加仓)。
- **假设**:经典 **单向持仓** 模式。若为双向对冲模式,Gate 对平仓字段要求不同,本实现可能需调整(`auto_size` 等);请在子账户上先以单向模式验证。
### 3.4.2 Gate 与「交易所原生 OCO」
- **官方能力**USDT 永续 v4 的 `POST /futures/{settle}/price_orders` 在公开文档中是 **「一条请求 = 一条计划委托」**;**没有**与「网页止盈+止损一体、撮合层保证互撤」等价的、**写进 OpenAPI 且稳定**的单请求 OCO。文档里部分 `order_type`(如 `close-long-order`)存在 **read-only、不可在请求体传入** 的说明,**不能**当作已支持的双绑参数来用。
- **现货**侧 `POST /spot/orders``take_profit`/`stop_loss` 等字段,与 **永续计划委托** 不是同一套接口,不能照搬。
- **本仓库策略**:默认开启 **`oco_cleanup`**(应用侧在持仓清空后撤另一腿),**效果上接近 OCO**,代价是 **秒级延迟**、以及进程/网络异常时的理论竞态;这是在不依赖非官方 `text` 编码技巧的前提下较稳妥的做法。若你关闭 `oco_cleanup_enabled`,则完全依赖交易所对 `reduce_only` 计划单的处理,**常见现象是另一腿仍以 `open` 挂在计划列表**,需自行在网页撤单。
### 3.5 `risk`
| 字段 | 说明 |
|------|------|
| `risk_per_trade_frac` | 以损订仓目标比例(如 `0.005` = 0.5%),用于按止损距离换算张数。 |
| `max_open_positions` | 同时占位/持仓品种上限(默认 5)。 |
| `scheme` | 固定为方案 **A**(与推送文案「入场区间 A」一致)。 |
| `oco_cleanup_enabled` | 默认 `true`:净持仓为 0 后由 **`oco_watcher`** 尝试 `DELETE` 本次挂出的两条计划单中仍有效的一腿(见 §3.4.2)。`false` 则不做应用侧撤单。 |
### 3.6 `stats`(面板「统计」正式口径)
| 字段 | 说明 |
|------|------|
| `timezone` | 统计用 IANA 时区,默认 `Asia/Shanghai`。 |
| `official_start` | **正式统计起点**ISO8601**必须带时区**,如 `2026-05-13T02:00:00+08:00`)。仅统计 Gate `position_close` 返回里 **`time`(平仓时间)不早于此** 的记录。 |
| `max_trade_rows` | 从 `position_close` 分页拉取的最大条数(默认 20000,上限 100000;配置键名沿用 `max_trade_rows`);超过则可能 `truncated: true`,序列型指标(如回撤)在截断下不完整。 |
**时间桶(均按 `timezone` 本地理解)**
- **统计日**`[日历 D 日 08:00, D+1 日 08:00)`。标签 **D**`(本地时刻 8h)` 的日历日(与 08:00 换日对齐)。
- **自然周**:周一至周日(上海日历);聚合 **统计日标签**落在该周内的历史平仓记录。
- **自然月**`[当月 1 日 08:00, 次月 1 日 08:00)`,与统计日对齐。
**统计单元**`GET /futures/{settle}/position_close` 每条历史平仓记录(与 App「历史仓位」同类);**`pnl`(或 `realised_pnl`)可解析**的才计入笔数与盈亏序列,按 **`time`(平仓时间)** 排序。**胜率** = 盈利笔数 / 总笔数(含盈亏为 0);**盈亏比** = 毛利和 / \|毛亏和\|(无亏损时为 `null`);**最大单笔亏损** = 最小 `pnl`**最大回撤** = 按时间累加 `pnl` 的权益曲线相对历史峰值的 `max(peak equity)`**最大连续亏损次数** = 连续 `pnl < 0` 的最大长度。
面板 **「统计」** 分区调用 `GET /api/stats/summary`(需登录),**不会**随 `/api/state` 每 2 秒刷新,需手动点「刷新统计」。
### 3.7 `proxy`
`onchain_scout_gate``proxy` **块写法一致**
- `enabled: true` 且填写 `url`(如 `socks5h://127.0.0.1:1080`)时,使用 `httpx_client_kwargs` 的出站逻辑与扫描端 Gate 客户端相同(`socks5h` 会转为 `socks5` 以兼容 httpx)。
- 使用 **SOCKS** 时需安装 `socksio``pip install socksio``bootstrap.sh` 会尝试安装)。
### 3.8 `wecom`(企业微信群机器人 · 执行结果)
- **定位**:仅推送本执行器侧 **执行结果**(每条 `POST /v1/signal` 处理摘要、面板一键平仓成败、OCO 撤另一腿失败、SQLite 落库失败)。**策略/发现类**仍由 `onchain_scout_gate` 扫描端发企业微信。
- **配置**`enabled: true` 且填写 `webhook_url`(群机器人 Webhook 完整 URL)。走与 Gate 相同的 **proxy** 出站策略(若启用)。
- **关闭**`enabled: false` 或留空 `webhook_url` 即不发送。
## 4. Web 面板
- **必须用运行中的服务访问**,在浏览器地址栏输入 `http://<host>:<port>/dashboard`(例如本机 `http://127.0.0.1:8090/dashboard`)。不要双击或用「打开文件」方式直接打开 `templates/dashboard.html`:那样是 `file://` 协议,**不会**经过 FastAPI、**不会**加载 `/static/style.css`,页面会变成未样式的白底裸 HTML,模板里的 `{{ username }}` 等也不会被渲染。
- 开启 `auth.enabled` 时先访问 `/login`,使用 JSON 方式提交账号密码。
- 面板为顶部分区:**概览**、**持仓与计划**、**成交与委托**、**统计**、**信号流**(`POST /v1/signal` 写入 SQLite,默认 `database.sqlite_path`,配置留空时亦回退为该路径;面板与 `GET /api/signals/export.csv` 读库,**进程重启后记录仍在**`/api/state` 返回 `signals_persisted` / `signals_sqlite_path` 供前端展示持久化状态)。
- 前端约 **每 2 秒** 轮询 `GET /api/state`,已配置密钥时会附带拉取账户、**持仓**、计划委托。
- **联调(拉取余额 / 极小测试市价)不再放在面板**,请在服务器用 **`curl`** 或自写脚本调用 `POST /api/test``POST /v1/test`,见下文 **§4.1** 与 [部署说明.md](./部署说明.md) **§11**。
### 4.1 用 curl 联调 `POST /api/test` 与 `POST /v1/test`
以下端口、账号、密钥请替换为你的 `config.yaml` 实际值;路径以本机 `127.0.0.1:8090` 为例。
**鉴权说明**
- `POST /api/test`:需 **面板登录 Cookie**`auth.enabled: true` 时先登录再带 `-b` cookie 文件)。
- `POST /v1/test`:仅需请求头 **`X-Webhook-Secret`**,与 `security.webhook_secret` 一致,**无需 Cookie**(适合 SSH 在服务器上一条命令联调)。
**1)若开启 `auth.enabled`,先登录保存 Cookie**
```bash
curl -s -c /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"你的面板密码"}'
```
`auth.enabled: false`,可跳过登录,下面 `/api/test``-b` 可省略。
**2)仅读合约账户(balance**
```bash
curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"balance"}'
```
**3)极小真实 IOC 市价(micro_market**
须同时满足:`gate.test_orders_enabled: true`,且已配置 `api_key` / `api_secret`。张数与 `test_max_contracts` 取小。
```bash
curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}'
```
**4)无 Cookie:用 Webhook 密钥调同一套逻辑(`/v1/test`)**
```bash
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: 与_config.yaml_security.webhook_secret_一致" \
-d '{"action":"balance"}'
```
`micro_market` 同理,把 `body` 换成与上一步相同的 JSON 即可。
**HTTP 状态**`balance` 且 Gate 账户接口失败时一般为 **502**`micro_market``{"ok":false}` 时一般为 **400**
**Pythonhttpx)示例(`/v1/test`**
```python
import httpx
r = httpx.post(
"http://127.0.0.1:8090/v1/test",
headers={"X-Webhook-Secret": "your-secret"},
json={"action": "balance"},
timeout=30.0,
)
print(r.status_code, r.text)
```
## 5. HTTP 接口摘要
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查;无需登录。 |
| GET | `/dashboard` | 面板页面。 |
| GET | `/api/state` | JSON 状态;未登录且开启 auth 时返回 401。已配密钥时含 `futures_account` / `futures_account_error``open_price_orders`(最多 50 条 open 计划委托摘要)/ `open_price_orders_error``positions.open_slot_count`(本地占位)、`positions.exchange`(非零持仓摘要列表)、`positions.exchange_error`。 |
| GET | `/api/stats/summary` | 需登录且已配密钥。Query`contract`(可选,大写合约如 `BTC_USDT`)。返回当前 **统计日 / 自然周 / 自然月**(见 §3.6)内正式起点后的历史平仓 `pnl` 聚合;`ok: false` 时见 `error`。拉取可能较慢,勿高频轮询。 |
| GET | `/api/gate/trades` | 需登录。查询成交:Gate `GET /futures/{settle}/my_trades_timerange`。Query`contract`(可选)、`from` / `to`(Unix 秒,均可省略则默认最近 7 天至此刻)、`limit`1500)、`offset`。返回 `rows``error`(接口异常时非空)。 |
| GET | `/api/gate/trades.csv` | 需登录。同上时间范围,分页拉取至最多 `max` 行(默认 2000,上限 5000),返回 CSV 附件 `gate_futures_trades.csv`。 |
| GET | `/api/gate/orders_history` | 需登录。Gate `GET /futures/{settle}/orders`。Query`status=open|finished`(默认 `finished`)、`contract`(可选)、`limit``offset`。 |
| GET | `/api/gate/orders_history.csv` | 需登录。委托导出 CSV`status``contract` 同上,`max` 同成交导出。 |
| POST | `/api/positions/market_close` | 需登录。JSON`{"contract":"BTC_USDT"}`。实盘且非 `dry_run` 时向 Gate 发 **市价 IOC + reduce_only** 平掉该合约净持仓;成功后释放本地占位槽。`dry_run` / 无仓 / 无密钥 时 400Gate 错误 502。 |
| POST | `/api/price_orders/manual` | 需登录。JSON`contract``trigger_price`(字符串数字)、`rule`1 或 2,与 Gate `price_orders` 触发规则一致)。挂一条 **全平** 条件计划单(`close`+`size`0 形态)。`dry_run` 时 400。 |
| POST | `/api/price_orders/cancel` | 需登录 Cookie。JSON`{"order_id":"2054233581303107584"}`(与面板列表一致)。调用 Gate `DELETE .../price_orders/{id}`;失败时 400。 |
| POST | `/api/test` | 需登录 Cookie。JSON`{"action":"balance"}` 仅读余额;`{"action":"micro_market","contract":"BTC_USDT","side":"long"|"short","size":1}` 发极小 IOC 市价(需 `test_orders_enabled`)。HTTP`balance` 且 API 错误时 502`micro_market``ok:false` 时 400。 |
| POST | `/v1/test` | 与 `/api/test` 相同 JSON;鉴权为请求头 `X-Webhook-Secret`(与 `security.webhook_secret` 一致),无需 Cookie。 |
| POST | `/v1/signal` | 扫描端推送信号;请求头 `X-Webhook-Secret`JSON 体见项目根目录 `README.md` 表格。 |
## 6. 与扫描服务协作
`onchain_scout_gate` 判定需要自动执行时,由扫描机 **内网** `httpx` 请求本服务 `POST /v1/signal`(例如 `http://127.0.0.1:8090/v1/signal`)。**不要**解析企业微信文本做下单。
## 7. 日志与排错
- PM2 标准输出/错误:`runtime/pm2-executor-out.log``runtime/pm2-executor-error.log`
-`config.yaml` 后需 **重启进程**`bash deploy/pm2-restart.sh``pm2 restart gate-order-executor`
- 若面板无法访问:检查 `app.host`/`app.port`、本机防火墙、以及是否与扫描端口冲突。
+189
View File
@@ -0,0 +1,189 @@
# Gate 下单执行器 · 部署说明
本文面向 **Ubuntu / Debian** 等 Linux 服务器,说明从零安装、PM2 守护、可选 systemd 开机自启,以及与 `onchain_scout_gate` 同机部署时的注意点。**功能与配置项含义**见 [使用说明.md](./使用说明.md)。
## 1. 环境要求
- Python 3.10+(推荐 3.11
- 可访问公网拉取 PyPI(或自备镜像)
- 使用 PM2`Node.js` + `npm install -g pm2`
- 若使用 **SOCKS** 代理访问 Gate`pip install socksio``bootstrap.sh` 会尝试安装)
## 2. 上传代码
将仓库目录放到服务器,例如:
```text
/root/gate_order_executor/
```
下文以该路径为例;若你的目录不同,请替换所有命令中的路径,并修改 `deploy/gate-order-executor.service` 里的 `WorkingDirectory`
## 3. 首次安装(bootstrap
```bash
cd /root/gate_order_executor
chmod +x deploy/*.sh
bash deploy/bootstrap.sh /root/gate_order_executor
```
脚本会:
- 创建 `.venv` 并安装 `requirements.txt`
- 尝试安装 `socksio`(失败可忽略,按需手动安装)
- 若不存在 `config.yaml`,从 `config.example.yaml` 复制一份
- 创建 `runtime/` 目录
然后 **必须** 编辑 `config.yaml`
- `app.session_secret``security.webhook_secret`
- `auth`(若对外暴露建议 `enabled: true` 并改密码)
- `proxy`:本机走 SOCKS 时 `enabled: true`**云服务器能直连 `api.gateio.ws` 时设为 `false`**(见下文 §6.1
- 需要远端访问面板时,将 `app.host` 设为 `0.0.0.0`,并限制防火墙来源 IP
## 4. PM2 启动与维护
### 4.1 启动(推荐脚本)
```bash
cd /root/gate_order_executor
bash deploy/pm2-start.sh
```
若进程已在 PM2 列表中,脚本会执行 **`pm2 restart`** 而非重复 `start`
### 4.2 常用命令
```bash
pm2 logs gate-order-executor # 实时日志
pm2 status # 状态列表
bash deploy/pm2-restart.sh # 改配置后重启
bash deploy/pm2-stop.sh # 停止
bash deploy/pm2-delete.sh # 从 PM2 删除该应用
pm2 save # 保存进程列表(配合开机自启)
```
### 4.3 等价手动命令
```bash
cd /root/gate_order_executor
mkdir -p runtime
pm2 start deploy/ecosystem.config.cjs
pm2 save
```
`ecosystem.config.cjs` 使用项目内 `.venv/bin/python` 执行 `run.py`,日志写入 `runtime/pm2-executor-*.log`,并设置 `PYTHONUNBUFFERED=1`
## 5. 前台调试(无 PM2
```bash
cd /root/gate_order_executor
bash deploy/start.sh /root/gate_order_executor
```
用于排查问题;生产环境请用 PM2。
## 6. 与扫描服务同机部署
典型端口:
- 扫描:`8088`(以 `onchain_scout_gate/config.yaml` 为准)
- 执行器:`8090`(以本仓库 `config.yaml` 为准);多账户可再起 `8091`
两者使用 **不同 PM2 应用名**`onchain-scout``gate-order-executor`),互不影响。
**多执行器:** 由扫描端 Web 面板维护转发列表(`runtime/order_executors.json`),同一信号广播到多个 URL;本仓库 **不** 提供向扫描端「反向注册」。详见 `onchain_scout_gate/docs/多执行器与信号转发归档.md`
扫描端向本机执行器发信号示例:
```text
POST http://127.0.0.1:8090/v1/signal
Header: X-Webhook-Secret: <与扫描端面板及本机 security.webhook_secret 一致>
```
### 6.1 云服务器关闭代理
境外云主机通常 **无需** SOCKS。在 **本仓库** `config.yaml` 中:
```yaml
proxy:
enabled: false
```
保存后 `pm2 restart gate-order-executor`。自检:`curl -I --max-time 15 https://api.gateio.ws`
扫描端 `onchain_scout_gate``proxy` 也需同样关闭(仅影响其拉行情,不影响 POST 信号)。见 `onchain_scout_gate/交易系统部署说明.md` §8。
## 7. 可选:systemd + pm2-runtime 开机自启
适合希望 **不依赖 `pm2 startup` 脚本**、由 systemd 直接拉起 PM2 托管进程的场景。
1. 编辑 `deploy/gate-order-executor.service`:将 `WorkingDirectory=` 改为你的项目绝对路径;确认 `ExecStart``pm2-runtime` 路径(`which pm2-runtime`)。
2. 安装单元:
```bash
sudo cp /root/gate_order_executor/deploy/gate-order-executor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable gate-order-executor
sudo systemctl start gate-order-executor
sudo systemctl status gate-order-executor
```
注意:若同时使用 **交互式 `pm2 start`****systemd pm2-runtime**,容易重复启动;二选一即可。
## 8. 防火墙与安全
- 仅本机扫描调用信号时:可将 `app.host` 保持 `127.0.0.1`,则外网无法直连 HTTP。
- 若需浏览器远程看面板:使用 `0.0.0.0` + 防火墙白名单 + **务必开启 `auth`**,并配合 HTTPS 反向代理(Nginx/Caddy)更佳。
- `config.yaml` 含 API Key 与 Webhook 密钥,权限建议:`chmod 600 config.yaml`
## 9. 升级代码后
```bash
cd /root/gate_order_executor
git pull # 或上传新文件
source .venv/bin/activate
pip install -r requirements.txt
bash deploy/pm2-restart.sh
```
## 10. 故障速查
| 现象 | 可能原因 |
|------|----------|
| `TypeError: unhashable type: 'dict'`Jinja 加载模板) | Starlette ≥0.29 起 `TemplateResponse` 须为 `TemplateResponse(request, "x.html", {...})`,勿把 `request` 放在第一个位置。请拉取最新 `app/main.py` 后重启。 |
| PM2 反复重启 | `config.yaml` 校验失败、端口被占用、依赖缺失;看 `pm2 logs``runtime/pm2-executor-error.log` |
| 面板打不开 | `host``127.0.0.1` 却从外网访问;或防火墙未放行 `port` |
| SOCKS 代理失败 | 未安装 `socksio`;或代理地址/协议错误 |
| 401 on `/v1/signal` | `X-Webhook-Secret` 与配置不一致 |
## 11. 联调测试(curl / 无面板)
面板 **不提供**「拉取余额 / 测试市价」按钮;联调请在本机或 SSH 到服务器后用 **`curl`**(或脚本调用 `httpx`)请求 **`POST /api/test`**、**`POST /v1/test`**。详细参数、Cookie 登录、`micro_market` 条件见 [使用说明.md](./使用说明.md) **§4.1**。
**快速示例(已关闭 `auth` 或已另行登录拿到 Cookie 时省略 `-b`**
```bash
# 仅读合约账户(需 Cookie 时加:-b /tmp/gate_exec_cookies.txt
curl -s -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"balance"}'
```
```bash
# 无 Cookie,用 Webhook 密钥(与 security.webhook_secret 一致)
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \
-d '{"action":"balance"}'
```
```bash
# 极小真实 IOC(须 gate.test_orders_enabled: true;建议仍用子账户)
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \
-d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}'
```
`config.yaml` 后执行 **`bash deploy/pm2-restart.sh`** 再测。
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.27,<1
httpx>=0.27,<1
# 使用 SOCKS 代理时再装:pip install socksio(与扫描端 httpx[socks] 一致)
pydantic>=2.6,<3
PyYAML>=6.0,<7
jinja2>=3.1,<4
itsdangerous>=2.2,<3
+16
View File
@@ -0,0 +1,16 @@
"""本地启动:python run.py(需先 config.yaml + venv)。"""
from __future__ import annotations
import uvicorn
from app.config import load_settings
if __name__ == "__main__":
s = load_settings()
uvicorn.run(
"app.main:app",
host=s.app.host,
port=s.app.port,
reload=False,
)
File diff suppressed because it is too large Load Diff
+874
View File
@@ -0,0 +1,874 @@
/* Gate Order Executor — refined dark console */
:root {
--exec-bg-deep: #070708;
--exec-bg-raised: #0c0c10;
--exec-bg-card: #101118;
--exec-bg-card-hover: #16161f;
--exec-border: rgba(255, 255, 255, 0.055);
--exec-border-strong: rgba(255, 255, 255, 0.12);
--exec-text: #f7f7fb;
--exec-text-muted: #a8a8b8;
--exec-text-dim: #6d6d7c;
--exec-accent: #ececf4;
--exec-accent-soft: rgba(236, 236, 244, 0.09);
--exec-line: rgba(255, 255, 255, 0.038);
--exec-glow: rgba(120, 160, 255, 0.08);
--exec-radius: 17px;
--exec-radius-sm: 12px;
--exec-font: "Plus Jakarta Sans", "SF Pro Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--exec-mono: "JetBrains Mono", "SF Mono", ui-monospace, "Cascadia Code", monospace;
--exec-shadow:
0 0 0 1px rgba(255, 255, 255, 0.045),
0 2px 4px rgba(0, 0, 0, 0.35),
0 28px 56px rgba(0, 0, 0, 0.5),
0 64px 128px rgba(0, 0, 0, 0.28);
--exec-shadow-hover:
0 0 0 1px rgba(255, 255, 255, 0.07),
0 36px 72px rgba(0, 0, 0, 0.55);
--exec-header-h: 78px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* —— Shell(登录 + 控制台共用底层) —— */
.exec-shell {
margin: 0;
min-height: 100vh;
font-family: var(--exec-font);
background: var(--exec-bg-deep);
color: var(--exec-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
color-scheme: dark;
}
.exec-ambient {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 110% 85% at 50% -35%, rgba(115, 155, 255, 0.11), transparent 52%),
radial-gradient(ellipse 55% 42% at 100% 8%, rgba(255, 255, 255, 0.045), transparent 48%),
radial-gradient(ellipse 50% 38% at 0% 88%, rgba(72, 200, 190, 0.05), transparent 46%),
linear-gradient(180deg, #0b0b0f 0%, var(--exec-bg-deep) 38%, #050506 100%);
}
.exec-noise {
position: fixed;
inset: 0;
z-index: 0;
opacity: 0.035;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.exec-grid-faint {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-size: 48px 48px;
background-image:
linear-gradient(var(--exec-line) 1px, transparent 1px),
linear-gradient(90deg, var(--exec-line) 1px, transparent 1px);
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%);
opacity: 0.5;
}
/* —— 顶栏模块 —— */
.exec-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: var(--exec-header-h);
padding: 0 clamp(22px, 4.5vw, 44px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(14, 14, 18, 0.92) 0%, rgba(8, 8, 11, 0.82) 100%);
backdrop-filter: blur(22px) saturate(1.45);
-webkit-backdrop-filter: blur(22px) saturate(1.45);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 20px 48px rgba(0, 0, 0, 0.45);
}
.exec-header__brand {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.exec-header__text {
min-width: 0;
}
.exec-mark {
flex-shrink: 0;
width: 46px;
height: 46px;
border-radius: 14px;
display: grid;
place-items: center;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--exec-text);
background: linear-gradient(152deg, #222632 0%, #12141d 48%, #161a24 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.4),
0 10px 28px rgba(0, 0, 0, 0.45);
}
.exec-title {
margin: 0;
font-size: clamp(1.08rem, 2.1vw, 1.32rem);
font-weight: 600;
letter-spacing: -0.028em;
color: var(--exec-text);
line-height: 1.2;
}
.exec-title__sep {
margin: 0 0.12em;
font-weight: 500;
color: var(--exec-text-dim);
}
.exec-subtitle {
margin: 6px 0 0;
font-size: 0.8rem;
color: var(--exec-text-dim);
letter-spacing: 0.03em;
font-weight: 500;
}
.exec-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.exec-clock {
font-family: var(--exec-mono);
font-size: 0.78rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--exec-text-muted);
padding: 9px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.exec-user {
font-size: 0.78rem;
color: var(--exec-text-muted);
padding: 9px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.exec-user strong {
color: var(--exec-text);
font-weight: 600;
}
/* —— 主内容区:模块化栅格 —— */
.exec-main {
position: relative;
z-index: 1;
max-width: 1360px;
margin: 0 auto;
padding: clamp(20px, 3vw, 32px) clamp(20px, 4vw, 40px) 48px;
}
/* 标签页布局:顶部分区切换,内容区各自滚动(高度 ≈ 视口 − 顶栏) */
.exec-main--tabbed {
min-height: calc(100vh - var(--exec-header-h));
display: flex;
flex-direction: column;
width: 100%;
max-width: 1360px;
margin: 0 auto;
padding: clamp(14px, 2.2vw, 22px) clamp(22px, 4.5vw, 44px) 0;
padding-bottom: 0;
}
.exec-tabs {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 22px;
flex-shrink: 0;
padding: 6px 7px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.exec-tab {
appearance: none;
margin: 0;
cursor: pointer;
font: inherit;
font-size: 0.83rem;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--exec-text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 11px;
padding: 10px 17px;
transition:
color 0.18s ease,
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.exec-tab:hover {
color: var(--exec-text);
background: rgba(255, 255, 255, 0.055);
}
.exec-tab:focus-visible {
outline: 2px solid rgba(130, 165, 255, 0.35);
outline-offset: 2px;
}
.exec-tab--active {
color: var(--exec-text);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 100%);
border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 0 28px rgba(100, 140, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.exec-tab-panels {
flex: 1;
min-height: 0;
position: relative;
margin-bottom: clamp(16px, 2vw, 28px);
}
.exec-tab-panel {
display: none;
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: 32px;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
}
.exec-tab-panel::-webkit-scrollbar {
width: 9px;
}
.exec-tab-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 99px;
border: 2px solid transparent;
background-clip: padding-box;
}
.exec-tab-panel::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
background-clip: padding-box;
}
.exec-tab-panel--active {
display: block;
}
.exec-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.exec-modal.exec-modal--open {
display: flex;
}
.exec-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.68);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
}
.exec-modal__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 440px;
padding: 24px 24px 22px;
border-radius: calc(var(--exec-radius) + 2px);
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(24, 24, 32, 0.98) 0%, rgba(14, 14, 18, 0.99) 100%);
box-shadow: var(--exec-shadow), 0 0 80px rgba(0, 0, 0, 0.5);
}
.exec-modal__title {
margin: 0 0 10px;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--exec-text);
}
.exec-btn--sm {
font-size: 0.72rem;
padding: 5px 10px;
border-radius: 8px;
}
.exec-pos-actions {
white-space: nowrap;
}
.exec-section-label {
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin: 0 0 14px 4px;
opacity: 0.92;
}
.exec-module-row {
display: grid;
gap: 16px;
margin-bottom: 16px;
}
.exec-module-row--3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1020px) {
.exec-module-row--3 {
grid-template-columns: 1fr;
}
}
/* 单模块卡片 */
.exec-module {
background: linear-gradient(165deg, rgba(22, 22, 30, 0.92) 0%, rgba(12, 12, 16, 0.96) 55%, rgba(10, 10, 14, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--exec-radius);
box-shadow: var(--exec-shadow);
overflow: hidden;
transition:
border-color 0.22s ease,
box-shadow 0.22s ease,
transform 0.22s ease;
}
.exec-module:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: var(--exec-shadow-hover);
transform: translateY(-1px);
}
.exec-module--wide {
grid-column: 1 / -1;
}
.exec-module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 17px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 100%);
}
.exec-module-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--exec-text);
}
.exec-module-meta {
font-size: 0.7rem;
color: var(--exec-text-dim);
letter-spacing: 0.06em;
font-weight: 500;
text-align: right;
max-width: 52%;
line-height: 1.45;
}
.exec-module-body {
padding: 20px 22px 22px;
}
/* 指标栅格(子模块) */
.exec-metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.exec-metric {
padding: 15px 15px 13px;
border-radius: var(--exec-radius-sm);
border: 1px solid rgba(255, 255, 255, 0.055);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.015) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.exec-metric-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin-bottom: 9px;
}
.exec-metric-value {
font-size: 0.96rem;
font-weight: 500;
color: var(--exec-text);
line-height: 1.35;
}
.exec-metric-value--mono {
font-family: var(--exec-mono);
font-size: 0.83rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
word-break: break-all;
color: var(--exec-text-muted);
}
/* 状态芯片 */
.exec-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 8px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.exec-chip::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.exec-chip--neutral {
color: var(--exec-text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--exec-border);
}
.exec-chip--ok {
color: #86efac;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.22);
}
.exec-chip--warn {
color: #fcd34d;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.22);
}
.exec-chip--live {
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.22);
}
/* 持仓:交易所张数符号 = 方向;未实现盈亏着色 */
.exec-dir {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
text-transform: lowercase;
vertical-align: middle;
}
.exec-dir--long {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.exec-dir--short {
color: #fca5a5;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.25);
}
.exec-pnl--profit {
color: #86efac;
font-weight: 600;
}
.exec-pnl--loss {
color: #fca5a5;
font-weight: 600;
}
.exec-pnl--flat {
color: var(--exec-text-muted);
}
.exec-prose {
margin: 16px 0 0;
font-size: 0.78rem;
line-height: 1.65;
color: var(--exec-text-dim);
}
.exec-prose code {
font-family: var(--exec-mono);
font-size: 0.73rem;
color: var(--exec-text-muted);
padding: 3px 7px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* 表格模块 */
.exec-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.exec-table-hint {
margin: 0;
font-size: 0.75rem;
color: var(--exec-text-dim);
flex: 1 1 280px;
min-width: 0;
}
.exec-sig-export {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.exec-sig-export-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--exec-text-muted);
cursor: pointer;
user-select: none;
}
.exec-sig-export-label input {
accent-color: #22c55e;
}
.exec-sig-persist {
margin: 0 0 14px;
padding: 9px 14px;
font-size: 0.74rem;
line-height: 1.5;
border-radius: var(--exec-radius-sm);
border: 1px solid var(--exec-border);
background: rgba(255, 255, 255, 0.03);
color: var(--exec-text-muted);
}
.exec-sig-persist--ok {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.08);
color: #86efac;
}
.exec-sig-persist--warn {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.1);
color: #fcd34d;
}
.exec-table-scroll {
overflow-x: auto;
margin: 0 -2px;
padding: 0;
border-radius: calc(var(--exec-radius-sm) + 2px);
border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(8, 8, 11, 0.95) 0%, rgba(5, 5, 8, 0.98) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.exec-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.exec-table th {
text-align: left;
padding: 13px 16px;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--exec-text-dim);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
white-space: nowrap;
}
.exec-table td {
padding: 13px 16px;
border-bottom: 1px solid var(--exec-line);
color: var(--exec-text-muted);
}
.exec-table tbody tr {
transition: background 0.16s ease;
}
.exec-table tbody tr:hover {
background: rgba(120, 155, 255, 0.045);
}
.exec-table tbody tr:last-child td {
border-bottom: none;
}
.exec-table .exec-mono {
font-family: var(--exec-mono);
font-size: 0.8rem;
color: var(--exec-text);
}
.exec-cell-status {
font-weight: 500;
}
.exec-cell-status--ok {
color: #86efac;
}
.exec-cell-status--skip {
color: var(--exec-text-muted);
}
.exec-cell-status--err {
color: #fca5a5;
}
.exec-muted {
color: var(--exec-text-dim);
text-align: center;
padding: 28px 16px !important;
}
/* 按钮 */
.exec-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 19px;
border-radius: 11px;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.02em;
text-decoration: none;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%);
color: var(--exec-text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition:
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.15s ease;
}
.exec-btn:hover {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.05) 100%);
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 8px 24px rgba(0, 0, 0, 0.35);
transform: translateY(-0.5px);
}
.exec-btn--primary {
border: 1px solid rgba(255, 255, 255, 0.28);
background: linear-gradient(185deg, #ffffff 0%, #e4e4ea 48%, #c8c8d4 100%);
color: #0a0a0f;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.65),
0 10px 28px rgba(0, 0, 0, 0.35);
}
.exec-btn--primary:hover {
background: linear-gradient(185deg, #ffffff 0%, #f0f0f6 50%, #dcdce6 100%);
border-color: rgba(255, 255, 255, 0.38);
}
/* —— 登录页(模块化单卡) —— */
.exec-login-body.exec-shell {
display: grid;
place-items: center;
padding: 24px;
}
.exec-login-stage {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
}
.exec-login-card {
border-radius: calc(var(--exec-radius) + 2px);
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(165deg, rgba(22, 22, 30, 0.95) 0%, rgba(12, 12, 16, 0.98) 100%);
box-shadow: var(--exec-shadow);
overflow: hidden;
}
.exec-login-card::before {
content: "";
display: block;
height: 3px;
background: linear-gradient(
90deg,
transparent,
rgba(130, 165, 255, 0.45),
rgba(255, 255, 255, 0.35),
rgba(130, 165, 255, 0.45),
transparent
);
}
.exec-login-inner {
padding: 36px 32px 32px;
}
.exec-login-kicker {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.2em;
color: var(--exec-text-dim);
margin-bottom: 12px;
}
.exec-login-title {
margin: 0 0 10px;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.03em;
color: var(--exec-text);
}
.exec-login-desc {
margin: 0 0 28px;
font-size: 0.85rem;
line-height: 1.55;
color: var(--exec-text-muted);
}
.exec-field {
margin-bottom: 18px;
}
.exec-label {
display: block;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin-bottom: 8px;
}
.exec-input {
width: 100%;
padding: 12px 14px;
border-radius: var(--exec-radius-sm);
border: 1px solid var(--exec-border);
background: var(--exec-bg-deep);
color: var(--exec-text);
font-size: 0.95rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.exec-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.22);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
}
.exec-login-form .exec-btn--primary {
width: 100%;
margin-top: 8px;
padding: 12px;
}
.exec-error {
min-height: 22px;
margin-top: 16px;
font-size: 0.82rem;
color: #fca5a5;
}
.exec-shell ::selection {
background: rgba(130, 165, 255, 0.28);
color: var(--exec-text);
}
@media (prefers-reduced-motion: reduce) {
.exec-module,
.exec-btn,
.exec-tab {
transition: none !important;
}
.exec-module:hover,
.exec-btn:hover {
transform: none !important;
}
}
@@ -0,0 +1,664 @@
/**
* MATRIX 式交易终端:黑底 · 霓虹青 · 洋红点缀 · 等宽信息密度
* 仅当 body 含 .exec-theme-matrix 时由 dashboard / login 引入
*/
body.exec-theme-matrix.exec-shell {
font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
background: #000;
color: rgba(126, 232, 234, 0.92);
letter-spacing: 0.02em;
}
body.exec-theme-matrix .exec-ambient {
background:
radial-gradient(ellipse 90% 55% at 50% -10%, rgba(0, 255, 234, 0.12), transparent 52%),
radial-gradient(ellipse 45% 35% at 100% 80%, rgba(255, 46, 166, 0.06), transparent 50%),
linear-gradient(180deg, #030308 0%, #000 45%, #020204 100%);
}
body.exec-theme-matrix .exec-noise {
opacity: 0.045;
}
body.exec-theme-matrix .exec-grid-faint {
background-size: 28px 28px;
background-image:
linear-gradient(rgba(0, 255, 234, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 234, 0.07) 1px, transparent 1px);
mask-image: radial-gradient(ellipse 75% 65% at 50% 35%, black 15%, transparent 75%);
opacity: 0.4;
}
/* —— 顶栏:状态条 + 主标题行 —— */
body.exec-theme-matrix .exec-header {
flex-direction: column;
align-items: stretch;
gap: 0;
min-height: auto;
padding: 0;
border-bottom: 1px solid rgba(0, 255, 234, 0.45);
background: linear-gradient(180deg, rgba(0, 8, 10, 0.97) 0%, rgba(0, 0, 0, 0.92) 100%);
box-shadow:
0 0 32px rgba(0, 255, 234, 0.12),
0 1px 0 rgba(255, 46, 166, 0.15) inset;
backdrop-filter: blur(12px);
}
body.exec-theme-matrix .exec-terminal-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: 8px clamp(14px, 3vw, 28px);
border-bottom: 1px solid rgba(0, 255, 234, 0.2);
background: rgba(0, 0, 0, 0.55);
}
body.exec-theme-matrix .exec-tx-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(126, 232, 234, 0.75);
border: 1px solid rgba(0, 255, 234, 0.35);
border-radius: 2px;
background: rgba(0, 20, 22, 0.9);
box-shadow: 0 0 12px rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-tx-chip strong,
body.exec-theme-matrix .exec-tx-chip .exec-tx-mono {
color: #bfffff;
font-weight: 600;
letter-spacing: 0.06em;
}
body.exec-theme-matrix .exec-tx-chip--live {
border-color: rgba(0, 255, 234, 0.55);
color: #7ee8ea;
text-shadow: 0 0 12px rgba(0, 255, 234, 0.45);
}
body.exec-theme-matrix .exec-tx-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00ffe0;
box-shadow: 0 0 10px #00ffe0;
animation: mtx-pulse 1.8s ease-in-out infinite;
}
@keyframes mtx-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
body.exec-theme-matrix .exec-tx-chip--time .exec-tx-mono {
font-variant-numeric: tabular-nums;
}
body.exec-theme-matrix .exec-terminal-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px 20px;
padding: 14px clamp(14px, 3vw, 28px) 16px;
}
body.exec-theme-matrix .exec-terminal-logo {
flex-shrink: 0;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #000;
background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%);
border: 1px solid rgba(0, 255, 234, 0.8);
border-radius: 2px;
box-shadow: 0 0 20px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix .exec-terminal-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
}
body.exec-theme-matrix .exec-title-terminal {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px 14px;
font-family: "Share Tech Mono", "JetBrains Mono", ui-monospace, monospace;
font-size: clamp(1.35rem, 4.2vw, 2.15rem);
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
line-height: 1.1;
color: #9ff;
text-shadow:
0 0 20px rgba(0, 255, 234, 0.55),
0 0 40px rgba(0, 255, 234, 0.2);
}
body.exec-theme-matrix .exec-tt-part {
white-space: nowrap;
}
body.exec-theme-matrix .exec-title-slash {
margin: 0 0.02em;
opacity: 0.55;
font-weight: 400;
letter-spacing: 0.2em;
}
/* 雷达装饰 */
body.exec-theme-matrix .exec-radar {
position: relative;
width: 46px;
height: 46px;
flex-shrink: 0;
}
body.exec-theme-matrix .exec-radar__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(0, 255, 234, 0.5);
box-shadow:
0 0 14px rgba(0, 255, 234, 0.25),
inset 0 0 18px rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-radar__sweep {
position: absolute;
inset: 3px;
border-radius: 50%;
background: conic-gradient(from -36deg, transparent 0deg, rgba(0, 255, 234, 0.22) 52deg, transparent 52deg);
animation: mtx-radar 2.8s linear infinite;
}
@keyframes mtx-radar {
to {
transform: rotate(360deg);
}
}
body.exec-theme-matrix .exec-radar__blip {
position: absolute;
width: 7px;
height: 7px;
top: 32%;
right: 18%;
border-radius: 50%;
background: #ff2ea6;
box-shadow: 0 0 12px #ff2ea6;
}
body.exec-theme-matrix .exec-tagline-terminal {
margin: 0;
display: inline-block;
max-width: min(100%, 720px);
padding: 6px 14px;
font-size: 0.62rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 180, 220, 0.95);
border: 1px solid rgba(255, 46, 166, 0.55);
border-radius: 2px;
background: rgba(40, 0, 28, 0.45);
box-shadow: 0 0 18px rgba(255, 46, 166, 0.12);
}
body.exec-theme-matrix .exec-terminal-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* —— Tab —— */
body.exec-theme-matrix .exec-tabs {
gap: 4px;
padding: 5px 6px;
margin-bottom: 18px;
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.28);
background: rgba(0, 12, 14, 0.75);
box-shadow: inset 0 0 24px rgba(0, 255, 234, 0.04);
}
body.exec-theme-matrix .exec-tab {
border-radius: 2px;
padding: 9px 14px;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(126, 232, 234, 0.55);
}
body.exec-theme-matrix .exec-tab:hover {
color: #bfffff;
background: rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-tab--active {
color: #000;
background: linear-gradient(180deg, #00ffe0 0%, #00c9b0 100%);
border-color: rgba(0, 255, 234, 0.7);
box-shadow: 0 0 22px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix .exec-tab:focus-visible {
outline: 2px solid #ff2ea6;
outline-offset: 2px;
}
/* —— 分区标签 —— */
body.exec-theme-matrix .exec-section-label {
letter-spacing: 0.22em;
color: rgba(0, 255, 234, 0.45);
text-shadow: 0 0 8px rgba(0, 255, 234, 0.25);
}
/* —— 卡片 —— */
body.exec-theme-matrix .exec-module {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.38);
background: linear-gradient(165deg, rgba(0, 18, 20, 0.92) 0%, rgba(0, 0, 0, 0.94) 100%);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.5),
0 0 28px rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-module:hover {
border-color: rgba(0, 255, 234, 0.55);
box-shadow:
0 0 0 1px rgba(255, 46, 166, 0.12),
0 0 36px rgba(0, 255, 234, 0.12);
transform: none;
}
body.exec-theme-matrix .exec-module-head {
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 255, 234, 0.22);
background: linear-gradient(90deg, rgba(0, 255, 234, 0.08) 0%, transparent 55%);
}
body.exec-theme-matrix .exec-module-title {
font-size: 0.78rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #bfffff;
}
body.exec-theme-matrix .exec-module-meta {
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(126, 232, 234, 0.45);
}
body.exec-theme-matrix .exec-module-body {
padding: 14px 16px 16px;
}
/* —— 指标格 —— */
body.exec-theme-matrix .exec-metric {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.22);
background: rgba(0, 0, 0, 0.45);
box-shadow: inset 0 0 20px rgba(0, 255, 234, 0.03);
}
body.exec-theme-matrix .exec-metric-label {
font-size: 0.58rem;
letter-spacing: 0.14em;
color: rgba(126, 232, 234, 0.5);
}
body.exec-theme-matrix .exec-metric-value {
font-size: 0.84rem;
color: #dff;
}
body.exec-theme-matrix .exec-metric-value--mono {
font-size: 0.78rem;
color: rgba(191, 255, 255, 0.88);
}
/* —— 芯片 / 方向 / 盈亏色(保持语义,套霓虹) —— */
body.exec-theme-matrix .exec-chip--neutral {
border-color: rgba(0, 255, 234, 0.25);
color: rgba(126, 232, 234, 0.75);
background: rgba(0, 0, 0, 0.5);
}
body.exec-theme-matrix .exec-chip--ok {
color: #5fffd0;
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 40, 32, 0.5);
text-shadow: 0 0 10px rgba(0, 255, 200, 0.35);
}
body.exec-theme-matrix .exec-chip--warn {
color: #ffe066;
border-color: rgba(255, 200, 80, 0.45);
background: rgba(40, 28, 0, 0.45);
}
body.exec-theme-matrix .exec-chip--live {
color: #ff7ab8;
border-color: rgba(255, 46, 166, 0.5);
background: rgba(36, 0, 24, 0.45);
text-shadow: 0 0 10px rgba(255, 46, 166, 0.35);
}
body.exec-theme-matrix .exec-dir--long {
color: #5fffd0;
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 32, 28, 0.55);
text-shadow: 0 0 8px rgba(0, 255, 200, 0.3);
}
body.exec-theme-matrix .exec-dir--short {
color: #ff7ab8;
border-color: rgba(255, 46, 166, 0.45);
background: rgba(36, 0, 22, 0.55);
text-shadow: 0 0 8px rgba(255, 46, 166, 0.3);
}
body.exec-theme-matrix .exec-pnl--profit {
color: #5fffd0;
text-shadow: 0 0 12px rgba(0, 255, 200, 0.35);
}
body.exec-theme-matrix .exec-pnl--loss {
color: #ff6eb0;
text-shadow: 0 0 12px rgba(255, 46, 166, 0.35);
}
body.exec-theme-matrix .exec-pnl--flat {
color: rgba(126, 232, 234, 0.55);
}
/* —— 表格 —— */
body.exec-theme-matrix .exec-table-scroll {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.28);
background: rgba(0, 0, 0, 0.55);
box-shadow: inset 0 0 32px rgba(0, 255, 234, 0.04);
}
body.exec-theme-matrix .exec-table th {
font-size: 0.58rem;
letter-spacing: 0.14em;
color: rgba(0, 255, 234, 0.55);
border-bottom: 1px solid rgba(0, 255, 234, 0.25);
background: rgba(0, 20, 22, 0.85);
}
body.exec-theme-matrix .exec-table td {
color: rgba(191, 255, 255, 0.78);
border-bottom: 1px solid rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-table tbody tr:hover {
background: rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-table .exec-mono {
color: #dff;
}
body.exec-theme-matrix .exec-muted {
color: rgba(126, 232, 234, 0.35);
}
body.exec-theme-matrix .exec-cell-status--ok {
color: #5fffd0;
text-shadow: 0 0 8px rgba(0, 255, 200, 0.3);
}
body.exec-theme-matrix .exec-cell-status--skip {
color: rgba(126, 232, 234, 0.45);
}
body.exec-theme-matrix .exec-cell-status--err {
color: #ff6eb0;
text-shadow: 0 0 8px rgba(255, 46, 166, 0.35);
}
/* —— 按钮 —— */
body.exec-theme-matrix .exec-btn {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.45);
background: rgba(0, 24, 26, 0.85);
color: #bfffff;
box-shadow: 0 0 14px rgba(0, 255, 234, 0.12);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.68rem;
}
body.exec-theme-matrix .exec-btn:hover {
border-color: rgba(255, 46, 166, 0.55);
color: #ffd0ec;
background: rgba(32, 0, 22, 0.75);
box-shadow: 0 0 20px rgba(255, 46, 166, 0.2);
transform: none;
}
body.exec-theme-matrix .exec-btn--primary {
border-color: rgba(255, 46, 166, 0.55);
background: linear-gradient(180deg, #ff5ec8 0%, #c21e6e 100%);
color: #fff;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
box-shadow: 0 0 24px rgba(255, 46, 166, 0.25);
}
body.exec-theme-matrix .exec-btn--primary:hover {
background: linear-gradient(180deg, #ff7ad4 0%, #d02878 100%);
border-color: rgba(255, 46, 166, 0.75);
}
body.exec-theme-matrix .exec-btn--sm {
border-radius: 2px;
font-size: 0.62rem;
padding: 4px 9px;
}
/* —— 说明文字 —— */
body.exec-theme-matrix .exec-prose {
color: rgba(126, 232, 234, 0.5);
}
body.exec-theme-matrix .exec-prose code {
border-color: rgba(0, 255, 234, 0.25);
background: rgba(0, 0, 0, 0.5);
color: rgba(191, 255, 255, 0.85);
}
body.exec-theme-matrix .exec-table-hint {
color: rgba(126, 232, 234, 0.45);
line-height: 1.55;
}
body.exec-theme-matrix .exec-table-hint code {
font-family: inherit;
color: #7ee8ea;
border: 1px solid rgba(0, 255, 234, 0.3);
padding: 1px 5px;
border-radius: 2px;
background: rgba(0, 0, 0, 0.4);
}
body.exec-theme-matrix .exec-sig-export-label {
color: rgba(126, 232, 234, 0.65);
}
body.exec-theme-matrix .exec-sig-export-label input {
accent-color: #ff2ea6;
}
/* —— 弹窗 —— */
body.exec-theme-matrix .exec-modal__backdrop {
background: rgba(0, 0, 0, 0.82);
}
body.exec-theme-matrix .exec-modal__card {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.4);
background: linear-gradient(180deg, rgba(0, 22, 24, 0.98) 0%, rgba(0, 0, 0, 0.98) 100%);
box-shadow: 0 0 48px rgba(0, 255, 234, 0.15);
}
body.exec-theme-matrix .exec-modal__title {
color: #bfffff;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.82rem;
}
/* —— 滚动条 —— */
body.exec-theme-matrix .exec-tab-panel {
scrollbar-color: rgba(0, 255, 234, 0.25) transparent;
}
body.exec-theme-matrix .exec-tab-panel::-webkit-scrollbar-thumb {
background: rgba(0, 255, 234, 0.2);
}
body.exec-theme-matrix .exec-shell ::selection {
background: rgba(255, 46, 166, 0.35);
color: #fff;
}
/* —— 登录页 —— */
body.exec-theme-matrix.exec-login-body .exec-mark {
display: grid !important;
width: 44px;
height: 44px;
place-items: center;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #000;
background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%);
border: 1px solid rgba(0, 255, 234, 0.8);
border-radius: 2px;
box-shadow: 0 0 20px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix.exec-login-body .exec-login-card {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.4);
background: linear-gradient(165deg, rgba(0, 18, 20, 0.95) 0%, rgba(0, 0, 0, 0.97) 100%);
box-shadow: 0 0 40px rgba(0, 255, 234, 0.12);
}
body.exec-theme-matrix .exec-login-card::before {
height: 3px;
background: linear-gradient(90deg, transparent, #00ffe0, #ff2ea6, #00ffe0, transparent);
}
body.exec-theme-matrix .exec-login-kicker {
color: rgba(0, 255, 234, 0.55);
letter-spacing: 0.24em;
}
body.exec-theme-matrix .exec-login-title {
font-family: "Share Tech Mono", "JetBrains Mono", monospace;
color: #9ff;
text-shadow: 0 0 20px rgba(0, 255, 234, 0.45);
letter-spacing: 0.12em;
text-transform: uppercase;
}
body.exec-theme-matrix .exec-login-desc {
color: rgba(126, 232, 234, 0.55);
}
body.exec-theme-matrix .exec-label {
color: rgba(0, 255, 234, 0.45);
}
body.exec-theme-matrix .exec-input {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.35);
background: #000;
color: #dff;
font-family: inherit;
}
body.exec-theme-matrix .exec-input:focus {
border-color: rgba(255, 46, 166, 0.55);
box-shadow: 0 0 0 2px rgba(255, 46, 166, 0.15);
}
body.exec-theme-matrix .exec-error {
color: #ff8ec4;
text-shadow: 0 0 10px rgba(255, 46, 166, 0.3);
}
body.exec-theme-matrix .exec-sig-persist {
border-radius: 2px;
border-color: rgba(0, 255, 234, 0.25);
background: rgba(0, 0, 0, 0.45);
color: rgba(191, 255, 255, 0.75);
}
body.exec-theme-matrix .exec-sig-persist--ok {
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 32, 28, 0.55);
color: #5fffd0;
text-shadow: 0 0 10px rgba(0, 255, 200, 0.2);
}
body.exec-theme-matrix .exec-sig-persist--warn {
border-color: rgba(255, 46, 166, 0.5);
background: rgba(40, 0, 28, 0.5);
color: #ffb8e0;
}
@media (prefers-reduced-motion: reduce) {
body.exec-theme-matrix .exec-radar__sweep,
body.exec-theme-matrix .exec-tx-dot {
animation: none !important;
}
}
@media (max-width: 720px) {
body.exec-theme-matrix .exec-terminal-head {
flex-direction: column;
align-items: stretch;
}
body.exec-theme-matrix .exec-terminal-actions {
justify-content: flex-end;
}
body.exec-theme-matrix .exec-title-terminal {
font-size: clamp(1.05rem, 6vw, 1.65rem);
}
}
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<!-- 勿用「文件」方式双击打开本模板:请启动服务后访问 http://127.0.0.1:8090/dashboard(端口见 config.yaml -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<title>GATE // EXECUTOR</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/theme-matrix-terminal.css?v={{ asset_version }}" />
</head>
<body class="exec-shell exec-dashboard exec-theme-matrix">
<div
id="exec-local-file-overlay"
style="display:none;position:fixed;inset:0;z-index:2147483647;background:#050508;color:#e4e4e7;font-family:system-ui,sans-serif;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:32px;gap:14px;"
>
<p style="margin:0;font-size:1.05rem;font-weight:600;">检测到正在用本地文件(file://)打开</p>
<p style="margin:0;font-size:0.9rem;opacity:0.88;max-width:460px;line-height:1.65;">
此方式不会经过 FastAPI,模板变量不会渲染,<code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">/static/style.css</code>
也无法加载,所以页面是白底「裸 HTML」。
</p>
<p style="margin:0;font-size:0.9rem;line-height:1.65;">
请在项目根目录执行 <code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">python run.py</code>(或 PM2),然后访问:<br />
<strong style="color:#fafafa;font-size:1rem;">http://127.0.0.1:8090/dashboard</strong><br />
<span style="opacity:0.75;font-size:0.82rem;">端口以 config.yaml 里 app.port 为准</span>
</p>
</div>
<script>
(function () {
if (location.protocol === "file:") {
var o = document.getElementById("exec-local-file-overlay");
if (o) o.style.display = "flex";
}
})();
</script>
<div class="exec-ambient" aria-hidden="true"></div>
<div class="exec-noise" aria-hidden="true"></div>
<div class="exec-grid-faint" aria-hidden="true"></div>
<header class="exec-header exec-header--terminal">
<div class="exec-terminal-bar">
<span class="exec-tx-chip exec-tx-chip--live"><span class="exec-tx-dot" aria-hidden="true"></span>LINK ONLINE</span>
<span class="exec-tx-chip">CYCLE <strong>OK</strong></span>
<span class="exec-tx-chip">CHANNEL <strong id="execTxChannel"></strong></span>
<span class="exec-tx-chip exec-tx-chip--time">SYNC <span class="exec-tx-mono" id="execClock">--:--:--</span></span>
</div>
<div class="exec-terminal-head">
<div class="exec-terminal-logo" aria-hidden="true">GE</div>
<div class="exec-terminal-center">
<h1 class="exec-title-terminal">
<span class="exec-tt-part">GATE</span>
<span class="exec-title-slash">//</span>
<span class="exec-radar" aria-hidden="true">
<span class="exec-radar__ring"></span>
<span class="exec-radar__sweep"></span>
<span class="exec-radar__blip"></span>
</span>
<span class="exec-title-slash">//</span>
<span class="exec-tt-part">EXECUTOR</span>
</h1>
<p class="exec-tagline-terminal">Gate USDT PERP · SCHEME-A · MATRIX SCAN ISOLATED</p>
</div>
<div class="exec-terminal-actions">
<span class="exec-tx-chip">OP <strong id="execUser">{{ username }}</strong></span>
<a class="exec-btn exec-btn--sm" href="/logout">LOGOUT</a>
</div>
</div>
</header>
<main class="exec-main exec-main--tabbed">
<nav class="exec-tabs" id="execTabs" role="tablist" aria-label="面板分区">
<button type="button" class="exec-tab exec-tab--active" role="tab" id="tab-overview" data-exec-tab="overview" aria-selected="true">概览</button>
<button type="button" class="exec-tab" role="tab" id="tab-positions" data-exec-tab="positions" aria-selected="false">持仓与计划</button>
<button type="button" class="exec-tab" role="tab" id="tab-history" data-exec-tab="history" aria-selected="false">成交与委托</button>
<button type="button" class="exec-tab" role="tab" id="tab-stats" data-exec-tab="stats" aria-selected="false">统计</button>
<button type="button" class="exec-tab" role="tab" id="tab-signals" data-exec-tab="signals" aria-selected="false">信号流</button>
</nav>
<div class="exec-tab-panels">
<div class="exec-tab-panel exec-tab-panel--active" data-exec-panel="overview" role="tabpanel" aria-labelledby="tab-overview">
<p class="exec-section-label">Overview</p>
<div class="exec-module-row exec-module-row--3">
<!-- 模块:运行态 -->
<section class="exec-module" aria-labelledby="mod-runtime-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-runtime-title">运行态</h2>
<span class="exec-module-meta">Runtime</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">模式</span>
<div class="exec-metric-value" id="stDryRunWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">Gate 密钥</span>
<div class="exec-metric-value" id="stKeysWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">占位仓位</span>
<span class="exec-metric-value exec-metric-value--mono" id="stSlots"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">单笔风险 / 方案</span>
<span class="exec-metric-value exec-metric-value--mono"><span id="stRisk"></span> · <span id="stScheme"></span></span>
</div>
</div>
</div>
</section>
<!-- 模块:网络 -->
<section class="exec-module" aria-labelledby="mod-net-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-net-title">网络与代理</h2>
<span class="exec-module-meta">Egress</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">配置启用</span>
<div class="exec-metric-value" id="pxEnabledWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">实际走代理</span>
<div class="exec-metric-value" id="pxEffectiveWrap"></div>
</div>
</div>
<div class="exec-metric" style="margin-top: 12px">
<span class="exec-metric-label">代理地址</span>
<span class="exec-metric-value exec-metric-value--mono" id="pxUrl"></span>
</div>
<p class="exec-prose"><code>onchain_scout_gate</code><code>proxy</code> 配置一致;访问 Gate 私有 API 时使用 <code>httpx_client_kwargs</code><code>app/proxy_util.py</code>)。</p>
</div>
</section>
<!-- 模块:风险摘要 -->
<section class="exec-module" aria-labelledby="mod-risk-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-risk-title">风险参数</h2>
<span class="exec-module-meta">Risk</span>
</div>
<div class="exec-module-body">
<div class="exec-metric">
<span class="exec-metric-label">以损订仓(目标)</span>
<span class="exec-metric-value exec-metric-value--mono" id="stRiskLarge"></span>
</div>
<div class="exec-metric" style="margin-top: 12px">
<span class="exec-metric-label">最大同时标的</span>
<span class="exec-metric-value exec-metric-value--mono" id="stMaxPos"></span>
</div>
<p class="exec-prose"><code>gate.dry_run: false</code> 且配置 API 密钥时,按上述参数与止损距离计算张数并下市价单 + 计划止盈/止损;<code>dry_run: true</code> 时仅校验占位与日志。</p>
</div>
</section>
</div>
<p class="exec-section-label">Account</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-acct-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-acct-title">合约账户</h2>
<span class="exec-module-meta">与 Overview 同步刷新(联调请用 curl,见部署/使用说明)</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">权益 total</span>
<span class="exec-metric-value exec-metric-value--mono" id="faTotal"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">可用 available</span>
<span class="exec-metric-value exec-metric-value--mono" id="faAvail"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">未实现盈亏</span>
<span class="exec-metric-value exec-metric-value--mono" id="faUpnl"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">币种</span>
<span class="exec-metric-value" id="faCur"></span>
</div>
</div>
<p class="exec-prose" id="faErr" style="display:none;color:#fca5a5;margin-top:12px"></p>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="positions" role="tabpanel" aria-labelledby="tab-positions">
<p class="exec-section-label">Positions</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-pos-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-pos-title">当前持仓(Gate</h2>
<span class="exec-module-meta">与计划委托同一轮询 · 便于对账</span>
</div>
<div class="exec-module-body">
<div class="exec-sig-rr-toolbar" style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:14px">
<label class="exec-sig-export-label"><input type="checkbox" id="beGlobalEnable" checked /> 全局移动保本(达 1R 拉至保本+0.2%)</label>
<button type="button" class="exec-btn exec-btn--sm" id="beGlobalSave">保存全局</button>
<span class="exec-muted" id="beGlobalMsg" role="status" aria-live="polite"></span>
</div>
<p class="exec-prose" id="posErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-prose" id="posPlanHint" style="display:none;margin:0 0 12px;font-size:0.88rem;color:#fbbf24;line-height:1.55"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="posTable">
<thead>
<tr>
<th>合约</th>
<th>张数</th>
<th>开仓价</th>
<th>标记价</th>
<th>未实现盈亏</th>
<th>杠杆</th>
<th>open 计划单</th>
<th>移动保本</th>
<th>保本状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="posBody">
<tr><td colspan="10" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<div id="posPlanModal" class="exec-modal" aria-hidden="true">
<div class="exec-modal__backdrop" id="posPlanModalBackdrop"></div>
<div class="exec-modal__card" role="dialog" aria-labelledby="posPlanModalTitle">
<h3 class="exec-modal__title" id="posPlanModalTitle">增加计划委托(市价全平)</h3>
<p class="exec-prose" style="margin:0 0 12px;font-size:0.85rem;opacity:0.88">合约 <span class="exec-mono" id="posPlanModalContract"></span> · 触发后市价 IOC、reduce_only 全平(与信号止盈止损同一套 <code>price_orders</code>)。</p>
<div class="exec-field" style="margin-bottom:12px">
<label class="exec-label" for="posPlanTrigger">触发价</label>
<input class="exec-input" id="posPlanTrigger" type="text" inputmode="decimal" placeholder="如 95000.5" autocomplete="off" />
</div>
<div class="exec-field" style="margin-bottom:16px">
<label class="exec-label" for="posPlanRule">触发规则</label>
<select class="exec-input" id="posPlanRule" style="cursor:pointer;width:100%">
<option value="1">最新价 &gt;= 触发价(rule=1</option>
<option value="2">最新价 &lt;= 触发价(rule=2</option>
</select>
</div>
<p class="exec-prose" id="posPlanModalErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap">
<button type="button" class="exec-btn" id="posPlanModalCancel">取消</button>
<button type="button" class="exec-btn exec-btn--primary" id="posPlanModalSubmit">提交</button>
</div>
</div>
</div>
<p class="exec-section-label">Plan orders</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-plan-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-plan-title">计划委托(price_orders</h2>
<span class="exec-module-meta">与概览同步轮询 · 可手动撤单</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" id="planHint" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
与上方<strong>持仓表按合约对账</strong>:有仓但「open 计划单」为 0 时多为止盈止损已触发/已撤或 OCO 清理延迟;无仓但有计划单时可能为挂单待触发或他端下的条件单。
</p>
<p class="exec-prose" id="planErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="planTable">
<thead>
<tr>
<th>合约</th>
<th>状态</th>
<th>触发价</th>
<th>rule</th>
<th>张数</th>
<th>类型</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="planBody">
<tr><td colspan="8" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="history" role="tabpanel" aria-labelledby="tab-history">
<p class="exec-section-label">Gate history</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-gh-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-gh-title">成交与委托(Gate 官方接口)</h2>
<span class="exec-module-meta">查询 / 下载 CSV · 与信号流无关</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
数据以 <strong>Gate 合约私有 API</strong> 为准:<code>my_trades_timerange</code>(成交)、<code>orders</code>(委托)。
未填「from / to」时按服务端默认最近 7 天(Unix 秒)。导出最多 5000 行(分页拼接)。
</p>
<div class="exec-test-bar" style="margin-bottom:14px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end">
<div class="exec-field" style="margin:0;min-width:140px">
<label class="exec-label" for="ghContract">合约(可选)</label>
<input class="exec-input" id="ghContract" type="text" placeholder="留空=全部" autocomplete="off" />
</div>
<div class="exec-field" style="margin:0;min-width:120px">
<label class="exec-label" for="ghFrom">fromUnix 秒)</label>
<input class="exec-input" id="ghFrom" type="text" inputmode="numeric" placeholder="默认 7 天前" autocomplete="off" />
</div>
<div class="exec-field" style="margin:0;min-width:120px">
<label class="exec-label" for="ghTo">toUnix 秒)</label>
<input class="exec-input" id="ghTo" type="text" inputmode="numeric" placeholder="默认此刻" autocomplete="off" />
</div>
<button type="button" class="exec-btn" id="btnGhTrades">查询成交</button>
<button type="button" class="exec-btn" id="btnGhTradesCsv">下载成交 CSV</button>
<div class="exec-field" style="margin:0;min-width:110px">
<label class="exec-label" for="ghOrdStatus">委托 status</label>
<select class="exec-input" id="ghOrdStatus" style="cursor:pointer">
<option value="finished">finished</option>
<option value="open">open</option>
</select>
</div>
<button type="button" class="exec-btn" id="btnGhOrders">查询委托</button>
<button type="button" class="exec-btn" id="btnGhOrdersCsv">下载委托 CSV</button>
</div>
<p class="exec-prose" id="ghErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-section-label" style="margin-top:4px">成交(最近一页,limit=80</p>
<div class="exec-table-scroll">
<table class="exec-table" id="ghTradesTable">
<thead>
<tr>
<th>时间</th>
<th>合约</th>
<th>张数</th>
<th>价格</th>
<th>手续费</th>
<th>角色</th>
<th>trade_id</th>
</tr>
</thead>
<tbody id="ghTradesBody">
<tr><td colspan="7" class="exec-muted">点击「查询成交」</td></tr>
</tbody>
</table>
</div>
<p class="exec-section-label" style="margin-top:18px">委托(最近一页,limit=80</p>
<div class="exec-table-scroll">
<table class="exec-table" id="ghOrdersTable">
<thead>
<tr>
<th>创建</th>
<th>合约</th>
<th>状态</th>
<th>张数</th>
<th>价 / 成交价</th>
<th>id</th>
</tr>
</thead>
<tbody id="ghOrdersBody">
<tr><td colspan="6" class="exec-muted">点击「查询委托」</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="stats" role="tabpanel" aria-labelledby="tab-stats">
<p class="exec-section-label">Formal stats</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-st-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-st-title">正式统计</h2>
<span class="exec-module-meta">GET /api/stats/summary · 手动刷新</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
口径:<strong>上海时区</strong>、统计日 <strong>[D 08:00, D+1 08:00)</strong>、自然周 <strong>周一至周日</strong>、自然月 <strong>[1日08:00, 次月1日08:00)</strong>;数据来自 Gate <code>GET /futures/&lt;settle&gt;/position_close</code>(与 App「历史仓位」同类),仅统计 <code>stats.official_start</code> 之后<strong>平仓时间</strong>且能解析 <code>pnl</code> 的记录。详见 <code>docs/使用说明.md</code> §3.6。
</p>
<div class="exec-test-bar" style="margin-bottom:14px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end">
<div class="exec-field" style="margin:0;min-width:140px">
<label class="exec-label" for="stContract">合约(可选)</label>
<input class="exec-input" id="stContract" type="text" placeholder="留空=全部" autocomplete="off" />
</div>
<button type="button" class="exec-btn exec-btn--primary" id="btnStatsRefresh">刷新统计</button>
</div>
<p class="exec-prose" id="statsErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-prose" id="statsWarn" style="display:none;color:#fbbf24;margin-bottom:12px;font-size:0.88rem"></p>
<p class="exec-prose exec-muted" id="statsIdle" style="margin:0 0 16px;font-size:0.88rem">尚未加载;请点击「刷新统计」(会请求 Gate 分页拉成交,请勿频繁点击)。</p>
<div class="exec-module-row exec-module-row--3" id="statsCards" style="display:none">
<section class="exec-module" aria-labelledby="st-day-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-day-title" style="font-size:1rem">本统计日</h3>
<span class="exec-module-meta" id="stDayMeta"></span>
</div>
<div class="exec-module-body" id="stDayBody"></div>
</section>
<section class="exec-module" aria-labelledby="st-week-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-week-title" style="font-size:1rem">本周</h3>
<span class="exec-module-meta" id="stWeekMeta"></span>
</div>
<div class="exec-module-body" id="stWeekBody"></div>
</section>
<section class="exec-module" aria-labelledby="st-month-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-month-title" style="font-size:1rem">本月</h3>
<span class="exec-module-meta" id="stMonthMeta"></span>
</div>
<div class="exec-module-body" id="stMonthBody"></div>
</section>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="signals" role="tabpanel" aria-labelledby="tab-signals">
<p class="exec-section-label">Signal stream</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-sig-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-sig-title">信号流</h2>
<span class="exec-module-meta">POST /v1/signal</span>
</div>
<div class="exec-module-body">
<div class="exec-table-toolbar">
<p class="exec-table-hint">每次 <code>POST /v1/signal</code> 的处理结果<strong>写入本地 SQLite</strong>,本页「信号流」从该库读取最近记录,<strong>进程重启后仍在</strong>(与当前是否持仓无关)。止盈/止损展示价为按合约 tick 对齐;现价优先 <code>reference_price</code>,否则取推送时行情 last。</p>
<div class="exec-sig-export" aria-label="信号流导出">
<label class="exec-sig-export-label"><input type="checkbox" id="sigExportEnable" /> 允许下载 CSV</label>
<button type="button" class="exec-btn exec-btn--sm" id="sigExportBtn" disabled>下载</button>
</div>
<div class="exec-sig-rr-toolbar" style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px">
<label class="exec-sig-export-label" for="sigMinRrInput">最低盈亏比(无法计算或低于此值则拒单)</label>
<input type="number" class="exec-input" id="sigMinRrInput" min="0.1" max="50" step="0.05" style="width:6.5rem" autocomplete="off" />
<button type="button" class="exec-btn exec-btn--sm" id="sigMinRrSave">保存到服务端</button>
<span class="exec-muted" id="sigMinRrMsg" role="status" aria-live="polite"></span>
</div>
</div>
<p id="sigPersistBanner" class="exec-sig-persist" role="status" aria-live="polite"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="sigTable">
<thead>
<tr>
<th>时间</th>
<th>合约</th>
<th>方向</th>
<th>现价</th>
<th>止盈</th>
<th>止损</th>
<th>盈亏比</th>
<th>结果</th>
</tr>
</thead>
<tbody id="sigBody">
<tr><td colspan="8" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</div>
</main>
<script src="/static/exec.js?v={{ asset_version }}"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<!-- 勿双击本地打开:请启动服务后访问 http://127.0.0.1:8090/login -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>GATE // EXECUTOR · 接入</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/theme-matrix-terminal.css?v={{ asset_version }}" />
</head>
<body class="exec-shell exec-login-body exec-theme-matrix">
<div
id="exec-local-file-overlay"
style="display:none;position:fixed;inset:0;z-index:2147483647;background:#050508;color:#e4e4e7;font-family:system-ui,sans-serif;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:32px;gap:14px;"
>
<p style="margin:0;font-size:1.05rem;font-weight:600;">请通过运行中的服务打开登录页</p>
<p style="margin:0;font-size:0.9rem;opacity:0.88;max-width:440px;line-height:1.65;">
本地 file:// 打开无法加载 <code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">/static/style.css</code>,因此不是设计稿效果。
</p>
<p style="margin:0;font-size:0.9rem;">
启动后访问:<strong style="color:#fafafa;">http://127.0.0.1:8090/login</strong>
</p>
</div>
<script>
(function () {
if (location.protocol === "file:") {
var o = document.getElementById("exec-local-file-overlay");
if (o) o.style.display = "flex";
}
})();
</script>
<div class="exec-ambient" aria-hidden="true"></div>
<div class="exec-noise" aria-hidden="true"></div>
<div class="exec-grid-faint" aria-hidden="true"></div>
<div class="exec-login-stage">
<div class="exec-login-card">
<div class="exec-login-inner">
<div class="exec-header__brand" style="margin-bottom: 24px">
<div class="exec-mark" aria-hidden="true">GE</div>
<div class="exec-header__text">
<p class="exec-login-kicker">GATE // EXECUTOR</p>
<h1 class="exec-login-title">安全接入</h1>
</div>
</div>
<p class="exec-login-desc">独立执行进程 · 与 MATRIX 扫描解耦 · 未授权禁止访问控制台</p>
<form id="execLoginForm" class="exec-login-form" action="#" method="post">
<div class="exec-field">
<label class="exec-label" for="f-user">操作员</label>
<input class="exec-input" id="f-user" type="text" name="username" required autocomplete="username" />
</div>
<div class="exec-field">
<label class="exec-label" for="f-pass">密码</label>
<input class="exec-input" id="f-pass" type="password" name="password" required autocomplete="current-password" />
</div>
<button type="submit" class="exec-btn exec-btn--primary">进入控制台</button>
</form>
<div class="exec-error" id="execLoginError"></div>
</div>
</div>
</div>
<script>
(function () {
var form = document.getElementById("execLoginForm");
var errEl = document.getElementById("execLoginError");
form.addEventListener("submit", function (e) {
e.preventDefault();
errEl.textContent = "";
var fd = new FormData(form);
var username = fd.get("username");
var password = fd.get("password");
fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ username: username, password: password }),
})
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, status: r.status, body: j };
});
})
.then(function (x) {
if (x.ok && x.body && x.body.redirect) {
window.location.href = x.body.redirect;
return;
}
errEl.textContent = (x.body && x.body.detail) || "登录失败";
})
.catch(function () {
errEl.textContent = "网络错误";
});
});
})();
</script>
</body>
</html>
@@ -0,0 +1,57 @@
"""移动保本逻辑单元测试。"""
from __future__ import annotations
import unittest
from app.breakeven_logic import (
breakeven_sl_price,
find_sl_plan,
is_1r_reached,
risk_distance,
sl_already_at_or_better,
)
class TestBreakevenLogic(unittest.TestCase):
def test_risk_distance_long(self) -> None:
self.assertAlmostEqual(risk_distance("long", 100.0, 95.0), 5.0)
def test_is_1r_long(self) -> None:
self.assertFalse(is_1r_reached("long", 104.0, 100.0, 95.0, trigger_r=1.0))
self.assertTrue(is_1r_reached("long", 105.0, 100.0, 95.0, trigger_r=1.0))
def test_is_1r_short(self) -> None:
self.assertFalse(is_1r_reached("short", 96.0, 100.0, 105.0, trigger_r=1.0))
self.assertTrue(is_1r_reached("short", 95.0, 100.0, 105.0, trigger_r=1.0))
def test_breakeven_sl_price(self) -> None:
self.assertAlmostEqual(breakeven_sl_price("long", 1000.0, 0.002), 1002.0)
self.assertAlmostEqual(breakeven_sl_price("short", 1000.0, 0.002), 998.0)
def test_sl_already_at_or_better(self) -> None:
self.assertTrue(sl_already_at_or_better("long", 1003.0, 1002.0))
self.assertFalse(sl_already_at_or_better("long", 1001.0, 1002.0))
self.assertTrue(sl_already_at_or_better("short", 997.0, 998.0))
def test_find_sl_plan_long(self) -> None:
plans = [
{
"contract": "BTC_USDT",
"order_id": "tp1",
"rule": 1,
"trigger_price": "110000",
},
{
"contract": "BTC_USDT",
"order_id": "sl1",
"rule": 2,
"trigger_price": "95000",
},
]
oid, px = find_sl_plan("long", "BTC_USDT", plans)
self.assertEqual(oid, "sl1")
self.assertAlmostEqual(px or 0, 95000.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
"""离线测试:TP/SL 触发价按 Gate 合约 tick 对齐(无需 API 密钥)。"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.gate_price_rounding import ( # noqa: E402
_format_trigger_price,
_trigger_price_tick,
)
def test_format_xaut_like_float_garbage() -> None:
assert _format_trigger_price(4752.700000000001, Decimal("0.1")) == "4752.7"
assert _format_trigger_price(4691.7976, Decimal("0.1")) == "4691.8"
def test_format_half_tick() -> None:
assert _format_trigger_price(100.25, Decimal("0.5")) == "100.5"
# 100.24 落在 0.5 网格上为 100.0,去尾零后为 "100"
assert _format_trigger_price(100.24, Decimal("0.5")) == "100"
def test_format_small_coin() -> None:
tick = Decimal("0.00001")
assert _format_trigger_price(0.000123456, tick) == "0.00012"
assert _format_trigger_price(1.23456789e-5, tick) == "0.00001"
def test_trigger_price_tick_from_cdata() -> None:
assert _trigger_price_tick({"order_price_round": "0.1"}) == Decimal("0.1")
assert _trigger_price_tick({"order_price_round": "", "mark_price_round": "0.0001"}) == Decimal("0.0001")
assert _trigger_price_tick({"mark_price_round": "0.01"}) == Decimal("0.01")
assert _trigger_price_tick({}) is None
def test_fallback_no_tick_coarse() -> None:
s = _format_trigger_price(4752.700000000001, None)
assert "00000000000" not in s
assert s.replace(".", "").isdigit() or s.replace(".", "", 1).replace("-", "", 1).isdigit()
def main() -> None:
test_format_xaut_like_float_garbage()
test_format_half_tick()
test_format_small_coin()
test_trigger_price_tick_from_cdata()
test_fallback_no_tick_coarse()
print("test_price_rounding: all passed")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
# Gate.io USDT Perpetual Monitor (Exchange-Only)
> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。
Python service for 7x24 monitoring of **Gate.io USDT-settled linear perpetual futures** using **public REST v4** market data (no on-chain dependency).
## Policy(主线)
- Only **live Gate USDT linear perpetual** contracts (`BASE_USDT`, non-delisting) are eligible.
- **`monitor.universe: all_swaps` (default)**:自动扫 Gate 上架中的线性合约,用 `min_24h_quote_volume_usdt` 按 24h **计价货币(USDT)成交额**过滤(默认 **≥ 1 百万 USDT**,可在 `config.yaml` 调整);**不要**混用 `watch_symbols`。成交额优先读 ticker 的 `volume_24h_quote`
- **`monitor.universe: watchlist`**:只监控 `watch_symbols` 里列出的 base 合约(须为 Gate 上架 USDT 线性)。
- `all_swaps` 模式下 **`min_24h_quote_volume_usdt` 必须大于 0**,否则会扫全市场(极低流动性)。
- 监控主周期 **5m**,规则与触发逻辑见 `app/exchange_rules.py`
### 附加(非产品主线说明)
- **`monitor.btc_daily_gate_enabled`(默认关闭)**:可选的 **BTC 日线横盘过滤**——在判定为日线横盘 regime 下叠加 **K 线形态**等附加条件;实验性/非必选风控,**不作为对外产品主线说明**。实现见 `app/btc_regime.py`,可在 `config.yaml` 关闭。
### 自动下单(gate_order_executor
- **下单执行器**:在 Web 面板 **「下单执行器 · 转发链」** 维护列表(`runtime/order_executors.json`),支持运行中增删;首次启动可从 `config.yaml``order_executor` 导入一条。
- 仅在 **企业微信突破推送成功之后**,向列表中已启用的执行器 **广播** 同一 `POST /v1/signal`;价位与企微 **方案 A** 一致。详见 [`docs/多执行器与信号转发归档.md`](docs/多执行器与信号转发归档.md)。
- 该 HTTP 请求 **不走** `proxy.url`,便于同机访问执行器。
全市场模式下扫描量较大,建议把 `poll_interval_seconds` 调到 **300 秒或更长**,并遵守 Gate 公开频率限制。
## Web Panel
- Login required for all `/api/*` and dashboard (unless `auth.enabled: false`).
- **MATRIX 主视图**:表格列为 **Gemma 漏斗评分**`/api/funnel`),按 `composite_score` 排序。
- **K 线周期**:监控主周期 **5m**;其它周期沿用内部命名(如 `4H``1D`),在 `app/gate.py` 映射为 Gate 的 `interval`
## Gemma 漏斗(本地 Ollama
1. 安装并启动 [Ollama](https://ollama.com)`ollama pull` 你的 Gemma 模型(如 `gemma2:2b`);多模态需带 vision 的 tag 才能送 K 线图。
2. 在 `config.yaml``gemma.enabled: true`,按需改 `model``ollama_base_url`
3. 每个 **5m 扫描** 产生 `WATCH` / `TRIGGER` 后,按 **24h 成交额**`max_funnel_per_cycle` 上限,取 **日线 OHLCV** + 可选图表;按 `vision_top_n`**matplotlib 生成的 PNG 截图**base64)给 Ollama(纯文本模型设 `send_chart_image: false`)。
4. 模型必须返回 JSON 结构(见 `app/gemma_client.py`)。合并 `composite_score` 后写入 `alerts``details.source === gemma_funnel`);若 `priority >= gemma_push_priority_min` **或** `composite_score >= composite_push_min`,则 **企业微信** 推送一条「MATRIX · 漏斗优先」提醒。
5. 若 Ollama 对 `format: json` 不稳,可将 `gemma.json_mode` 设为 `false`
## Project Layout
```text
onchain_scout_gate/
app/
main.py
web.py
monitor.py
gate.py
exchange_rules.py
btc_regime.py
daily_features.py
chart_candles.py
gemma_client.py
notifier.py
order_executor_forward.py
storage.py
models.py
config.py
templates/
static/
deploy/
ecosystem.config.cjs # PM2: python -m app.main
onchain-scout.service # 可选:systemd + pm2-runtime
config.example.yaml
requirements.txt
```
## 本地 / 服务器部署(PM2,推荐)
1. Python **3.10+**`python -m venv .venv``pip install -r requirements.txt`
2. 复制 `config.example.yaml``config.yaml`,填写 `auth``wecom``monitor` 等。
3. 安装 [Node.js](https://nodejs.org/) 后:`npm install -g pm2`
4. 在项目根:**`pm2 start deploy/ecosystem.config.cjs`**
5. 常用:`pm2 logs onchain-scout``pm2 restart onchain-scout``pm2 save`(配合 `pm2 startup` 开机)。
监听地址与端口以 **`config.yaml`** 的 `app.host` / `app.port`(与 `app.main` 起 uvicorn 一致)。
## SOCKS5 代理
- 代理写在 **`config.yaml``proxy`**`enabled` / `url`),仅用于访问 **Gate** 的 httpx;**当前实现**里企业微信等为直连 `proxy_url=None`**本地 Ollama 不走代理**。
- 详见 **[docs/本地部署-SOCKS5代理.md](docs/本地部署-SOCKS5代理.md)**。
## Linux Quick Startvenv + PM2
```bash
cd /opt/onchain_scout_gate
chmod +x deploy/bootstrap.sh
./deploy/bootstrap.sh /opt/onchain_scout_gate
# 编辑 config.yaml
source .venv/bin/activate
npm install -g pm2
pm2 start deploy/ecosystem.config.cjs
```
也可用当前脚本快速试:**`deploy/start.sh`**uvicorn,非 PM2)。
## systemd(可选)
长期用 systemd 托管 **PM2**(进程用 `pm2-runtime`),可参考 `deploy/onchain-scout.service`(需已安装 `pm2`,并按环境改 `WorkingDirectory` / `ExecStart`)。
## Config Keys
- `gate.api_base`:默认 `https://api.gateio.ws/api/v4`;亦可按官方文档使用 `https://fx-api.gateio.ws/api/v4`
- `gate.settle`USDT 线性填 `usdt`
- `gate.quote_currency`:计价货币,默认 `USDT`
- `monitor.universe`: `all_swaps` | `watchlist`.
- `watch_symbols`: 仅在 `watchlist` 模式下使用。
- `monitor.min_24h_quote_volume_usdt`: 24h 成交额(USDT)下限;`all_swaps` 须填 `>0`。默认 **1 百万**
- `monitor.btc_daily_gate_enabled` / `btc_sideways_*`: **可选** BTC 日线横盘过滤(**非主线说明**);默认关闭,见 `config.yaml`
- `gemma.*``proxy.*``app.poll_interval_seconds`: 见 `config.example.yaml`
+2
View File
@@ -0,0 +1,2 @@
"""On-chain first-mover monitoring system package."""
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass, field
from statistics import mean
@dataclass
class BtcDailyGateResult:
"""BTC 日线辅助门控(非产品主线):下跌不扫山寨,其它仍扫 —— 仅 downtrend 时关闭本轮 alt K 线请求。"""
allow_alt_scan: bool
regime: str # sideways | downtrend | neutral_or_up | unknown
reason: str
metrics: dict = field(default_factory=dict)
def _rows_to_hlc(rows: list[list[str]]) -> tuple[list[float], list[float], list[float]]:
"""与行情 K 线行对齐:h, l, cts,o,h,l,c,...)。"""
h, l_, c = [], [], []
for item in rows:
if len(item) < 6:
continue
h.append(float(item[2]))
l_.append(float(item[3]))
c.append(float(item[4]))
return h, l_, c
def evaluate_btc_daily_gate(
btc_1d_rows: list[list[str]],
*,
sideways_lookback_days: int = 14,
sideways_max_range_pct: float = 10.0,
min_bars: int = 30,
) -> BtcDailyGateResult:
"""
原则下跌不扫其它都扫
- 下跌唯一不扫非横盘且收盘低于近 20 日收盘均线且该均线相对前一段走低
- 其余横盘上涨宽幅震荡数据不足 unknown 一律允许扫山寨
"""
ah, al, ac = _rows_to_hlc(btc_1d_rows)
if len(ac) < min_bars:
return BtcDailyGateResult(
allow_alt_scan=True,
regime="unknown",
reason=f"insufficient_1d_bars have={len(ac)} need>={min_bars}, gate skipped",
metrics={"have": len(ac), "min_bars": min_bars},
)
lb = max(5, min(sideways_lookback_days, len(ah) - 1))
window_h = ah[-lb:]
window_l = al[-lb:]
range_high = max(window_h)
range_low = min(window_l)
mid = (range_high + range_low) / 2 if range_high > range_low else 0.0
range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0
sma_curr = mean(ac[-20:])
sma_prev = mean(ac[-26:-6]) if len(ac) >= 26 else sma_curr
last_close = ac[-1]
is_sideways = range_pct <= sideways_max_range_pct
if is_sideways:
return BtcDailyGateResult(
allow_alt_scan=True,
regime="sideways",
reason="btc_daily_sideways",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"sideways_max_range_pct": sideways_max_range_pct,
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
is_downtrend = last_close < sma_curr and sma_curr < sma_prev
if is_downtrend:
return BtcDailyGateResult(
allow_alt_scan=False,
regime="downtrend",
reason="btc_daily_downtrend_below_falling_sma20",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"sideways_max_range_pct": sideways_max_range_pct,
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
return BtcDailyGateResult(
allow_alt_scan=True,
regime="neutral_or_up",
reason="btc_not_sideways_not_downtrend_gate_open",
metrics={
"range_lookback_days": lb,
"range_pct": round(range_pct, 4),
"last_close": last_close,
"sma20": round(sma_curr, 6),
"sma20_prev_block": round(sma_prev, 6),
},
)
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import base64
import io
import logging
LOGGER = logging.getLogger("onchain_scout.chart_candles")
def daily_candles_png_base64(rows_1d: list[list[str]], symbol: str, max_bars: int = 48) -> str | None:
"""
生成简易日线蜡烛图 PNGbase64 data URL 前缀 Ollama 多模态
matplotlib 不可用或失败则返回 None
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
except ImportError:
LOGGER.warning("matplotlib not installed, skip chart image")
return None
o, h, l, c, _ = [], [], [], [], []
for item in rows_1d:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
n = len(c)
if n < 5:
return None
start = max(0, n - max_bars)
o, h, l, c = o[start:], h[start:], l[start:], c[start:]
x = list(range(len(c)))
fig, ax = plt.subplots(figsize=(7, 3), facecolor="#030308")
ax.set_facecolor("#050510")
for i in x:
up = c[i] >= o[i]
col = "#00f5d4" if up else "#ff006e"
ax.plot([i, i], [l[i], h[i]], color=col, linewidth=0.9, alpha=0.9)
body_low = min(o[i], c[i])
body_h = abs(c[i] - o[i])
if body_h < 1e-12:
body_h = (h[i] - l[i]) * 0.08 or 1e-8
ax.add_patch(
Rectangle(
(i - 0.35, body_low),
0.7,
body_h,
facecolor=col,
edgecolor=col,
linewidth=0.4,
alpha=0.85,
)
)
ax.set_title(f"{symbol} 1D", color="#00fff7", fontsize=11, fontfamily="monospace")
ax.tick_params(colors="#7dffb3", labelsize=7)
for spine in ax.spines.values():
spine.set_color("#1b3d2f")
ax.grid(True, alpha=0.12, color="#00fff7")
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=100, facecolor=fig.get_facecolor())
plt.close(fig)
buf.seek(0)
return base64.b64encode(buf.read()).decode("ascii")
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field, ValidationError
class AppConfig(BaseModel):
host: str = "0.0.0.0"
port: int = 8088
poll_interval_seconds: int = 120
log_file: str = "./runtime/system.log"
database_url: str = "sqlite+aiosqlite:///./runtime/alerts.db"
session_secret: str = "please-change-me"
class AuthConfig(BaseModel):
"""
enabled: false 时跳过登录仅建议纯局域网无外网暴露时使用
"""
enabled: bool = True
username: str
password: str
class WeComConfig(BaseModel):
webhook: str
mentioned_mobile_list: list[str] = Field(default_factory=list)
class GateConfig(BaseModel):
"""Gate.io 公共 REST v4USDT 永续 settle=usdt)。"""
api_base: str = "https://api.gateio.ws/api/v4"
settle: str = "usdt"
quote_currency: str = "USDT"
class ProxyConfig(BaseModel):
"""
出站 HTTP 客户端代理httpx用于访问 Gate 等外网
企业微信与本机/局域网 OllamaGemma默认直连不使用此配置
可写 socks5h://程序在交给 httpx 时会自动改为 socks5://避免 Unknown scheme
"""
enabled: bool = False
url: str = "socks5h://127.0.0.1:1080"
class OrderExecutorConfig(BaseModel):
"""
gate_order_executor 联动企微突破推送 **成功之后**向执行器 POST /v1/signal
请求不走 proxy.url直连 base_url便于同机 127.0.0.1
webhook_secret 须与执行器 config.yaml security.webhook_secret 一致
"""
enabled: bool = False
base_url: str = "http://127.0.0.1:8090"
webhook_secret: str = ""
timeout_seconds: float = Field(15.0, ge=3.0, le=120.0)
class WatchSymbol(BaseModel):
"""Gate USDT 永续 base 资产符号,如 BTC、ORDI、1000PEPE(与合约名 BTC_USDT 的左侧一致)。"""
symbol: str
class MonitorConfig(BaseModel):
"""
监控侧过滤
universe:
- all_swaps: 监控 Gate 全部 USDT 本位线性永续中24h 成交额达标的合约不依赖 watch_symbols
- watchlist: 仅监控 watch_symbols 中列出且满足成交额阈值的标的
min_24h_quote_volume_usdt: 24h 成交额下限USDT优先使用 Gate ticker volume_24h_quote
all_swaps 模式下若设为 0 或负数将拒绝整轮扫描避免无阈值拉全市场
watchlist 模式下 0 表示关闭成交额过滤
btc_daily_gate_enabled: 可选true 时仍计算 BTC 日线 regime 供面板/日志参考不再拦截山寨扫描
btc_sideways_lookback_days / btc_sideways_max_range_pct: 与上述辅助门控配套的横盘区分参数
"""
universe: Literal["all_swaps", "watchlist"] = "all_swaps"
min_24h_quote_volume_usdt: float = 10_000_000
# 可选:BTC 日线 regime 仅展示/记录;推送门控用「近8h×15m BTC 环境(横盘则多空均可;否则涨→LONG、跌→SHORT)+ 本币4h同向」
btc_daily_gate_enabled: bool = True
btc_sideways_lookback_days: int = 14
btc_sideways_max_range_pct: float = 10.0
# 同一币种在 N 小时内对同一条「链路」只落库一条告警、只推送一次(0 表示关闭去重)
# 链路含:GATE-USDT 5m WATCH / GATE-USDT 5m TRIGGER(分级)与 FUNNEL-GEMMA(漏斗)
symbol_signal_dedupe_hours: float = 4.0
# 企业微信主推送(突破预警):仅对本轮监控池内 24h 成交额排名前 N 的合约推送;0 表示不限制
wecom_push_max_volume_rank: int = 30
class GemmaConfig(BaseModel):
"""
本地 Ollama Gemma或其它模型做漏斗二次分拣
需在机器上自行启动 ollama 并拉取模型开启后仅对本轮 5m 扫描命中的 WATCH/TRIGGER 按成交额取前 N 再请求
"""
enabled: bool = False
ollama_base_url: str = "http://127.0.0.1:11434"
model: str = "gemma2:2b"
timeout_seconds: float = 180.0
temperature: float = 0.15
json_mode: bool = True
send_chart_image: bool = True
max_funnel_per_cycle: int = 12
vision_top_n: int = 4
gemma_push_priority_min: float = 7.0
composite_push_min: float = 72.0
class DailyReportConfig(BaseModel):
"""每日晨报:北京时间定时生成昨天复盘,并可推送企业微信。"""
enabled: bool = True
run_time_cn: str = "08:30"
push_wecom: bool = True
run_on_startup: bool = False
class Settings(BaseModel):
app: AppConfig
auth: AuthConfig
wecom: WeComConfig
gate: GateConfig
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
order_executor: OrderExecutorConfig = Field(default_factory=OrderExecutorConfig)
monitor: MonitorConfig = Field(default_factory=MonitorConfig)
gemma: GemmaConfig = Field(default_factory=GemmaConfig)
daily_report: DailyReportConfig = Field(default_factory=DailyReportConfig)
watch_symbols: list[WatchSymbol] = Field(default_factory=list)
def load_settings(config_path: str = "config.yaml") -> Settings:
path = Path(config_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {path}. 请先复制 config.example.yaml 为 config.yaml 并填写密钥。"
)
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
try:
return Settings.model_validate(raw)
except ValidationError as exc:
raise ValueError(f"配置文件校验失败: {exc}") from exc
# 兼容原 OKX 风格 bar 字符串(映射见 app.gate._to_gate_interval
GATE_BAR_CHOICES: tuple[str, ...] = (
"1m",
"3m",
"5m",
"15m",
"30m",
"1H",
"2H",
"4H",
"6H",
"12H",
"1D",
"1W",
"1M",
)
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import math
from statistics import mean
def rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]:
o, h, l, c, v = [], [], [], [], []
for item in rows:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
v.append(float(item[5]))
return o, h, l, c, v
def build_daily_programmatic(rows_1d: list[list[str]], est_quote_vol_24h_usdt: float) -> dict:
"""
日线程序化特征上方空间距阶段高成交活跃度简单阻力代理现价与区间高之间局部高点数量
"""
_, high, low, close, vol = rows_to_ohlcv(rows_1d)
if len(close) < 10:
return {"error": "insufficient_daily", "have": len(close)}
last = close[-1]
look = min(60, len(close))
hi = max(high[-look:])
lo = min(low[-look:])
mid = (hi + lo) / 2 if hi > lo else last
range_pct = ((hi - lo) / mid) * 100 if mid > 0 else 0.0
upside_pct = ((hi - last) / last) * 100 if last > 0 else 0.0
# 现价上方到区间高:统计「局部高点」数量作为中间阻力代理(越多越密)
seg_h = high[-look:]
seg_l = low[-look:]
local_peaks = 0
for i in range(1, len(seg_h) - 1):
if seg_h[i] >= seg_h[i - 1] and seg_h[i] >= seg_h[i + 1]:
if seg_h[i] > last * 1.002 and seg_h[i] < hi * 0.998:
local_peaks += 1
vol_tail = vol[-20:] if len(vol) >= 20 else vol
vol_mean = mean(vol_tail[:-1]) if len(vol_tail) > 1 else (vol_tail[0] if vol_tail else 1.0)
vol_ratio = (vol_tail[-1] / vol_mean) if vol_mean > 0 else 0.0
sma20 = mean(close[-20:]) if len(close) >= 20 else mean(close)
structure_hint = "price_above_sma20" if last >= sma20 else "price_below_sma20"
return {
"last_close": round(last, 8),
"range_60d_high": round(hi, 8),
"range_60d_low": round(lo, 8),
"range_pct_lookback": round(range_pct, 4),
"upside_to_range_high_pct": round(max(0.0, upside_pct), 4),
"mid_resistance_proxy_peaks": local_peaks,
"volume_last_vs_20d_mean": round(vol_ratio, 4),
"est_quote_vol_24h_usdt": round(est_quote_vol_24h_usdt, 2),
"structure_hint": structure_hint,
"sma20": round(sma20, 8),
}
def programmatic_scores(prog: dict) -> dict:
"""归一化子分数 0100,供合成 composite。"""
if prog.get("error"):
return {"vol": 0.0, "upside": 0.0, "liquidity": 0.0, "mid_clear": 0.0}
est = float(prog.get("est_quote_vol_24h_usdt") or 0.0)
# 成交额:10M≈35100M≈70
vol_score = min(100.0, max(0.0, math.log10(est / 1e6 + 1) * 32.0))
upside = float(prog.get("upside_to_range_high_pct") or 0.0)
upside_score = min(100.0, upside * 4.0)
vr = float(prog.get("volume_last_vs_20d_mean") or 0.0)
liquidity_score = min(100.0, max(0.0, (vr - 1.0) * 35.0 + 40.0))
peaks = int(prog.get("mid_resistance_proxy_peaks") or 0)
mid_clear_score = max(0.0, 100.0 - peaks * 12.0)
return {
"vol": round(vol_score, 2),
"upside": round(upside_score, 2),
"liquidity": round(liquidity_score, 2),
"mid_clear": round(mid_clear_score, 2),
}
def composite_score(gemma_priority: float, sub: dict) -> float:
"""gemma_priority 110;与程序化子分合成 0–100。"""
g = max(1.0, min(10.0, gemma_priority)) * 10.0
p = 0.35 * g
p += 0.2 * sub.get("vol", 0.0)
p += 0.2 * sub.get("upside", 0.0)
p += 0.15 * sub.get("liquidity", 0.0)
p += 0.1 * sub.get("mid_clear", 0.0)
return round(min(100.0, max(0.0, p)), 2)
def daily_ohlc_text_block(rows_1d: list[list[str]], max_lines: int = 24) -> str:
"""给 LLM 的紧凑 OHLCV 文本(时间正序:旧→新,最后一行为最新)。"""
rows = rows_1d[-max_lines:] if len(rows_1d) > max_lines else rows_1d
lines = ["ts,o,h,l,c,vol"]
for item in rows:
if len(item) < 6:
continue
ts, o, h, l, c, v = item[0], item[1], item[2], item[3], item[4], item[5]
lines.append(f"{ts},{o},{h},{l},{c},{v}")
return "\n".join(lines)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import json
from collections import Counter
from datetime import date, datetime, timedelta, timezone
from statistics import mean
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from .config import Settings
from .notifier import WeComNotifier
from .gate import GateClient
from .storage import Storage
from .time_cn import format_beijing_wall, utc_now
if TYPE_CHECKING:
from .gemma_client import OllamaGemmaClient
CN_TZ = ZoneInfo("Asia/Shanghai")
BTC_INST = "BTC_USDT"
def _rows_to_close(rows: list[list[str]]) -> list[float]:
out: list[float] = []
for r in rows:
if len(r) < 5:
continue
out.append(float(r[4]))
return out
def _sma(values: list[float], n: int) -> float:
if not values:
return 0.0
if len(values) < n:
return mean(values)
return mean(values[-n:])
def _btc_direction(close: float, prev: float, sma20: float, sma60: float) -> tuple[str, str]:
up = close >= prev
if close >= sma20 >= sma60 and up:
return "偏多上行", "收盘位于 SMA20/SMA60 上方,且日内延续上涨。"
if close < sma20 <= sma60 and not up:
return "偏空下行", "收盘位于 SMA20 下方且动能走弱。"
return "震荡中性", "价格位于均线附近,趋势延续性一般。"
def _cn_day_range(target_day: date) -> tuple[datetime, datetime]:
day_start_cn = datetime(target_day.year, target_day.month, target_day.day, tzinfo=CN_TZ)
day_end_cn = day_start_cn + timedelta(days=1)
start_utc = day_start_cn.astimezone(timezone.utc).replace(tzinfo=None)
end_utc = day_end_cn.astimezone(timezone.utc).replace(tzinfo=None)
return start_utc, end_utc
def _default_report_text(snapshot: dict, stats: dict, report_day_cn: str) -> dict:
top_symbols = stats.get("top_trigger_symbols", [])
top_line = "".join(top_symbols[:5]) if top_symbols else ""
risk = "若 BTC 回落并失守日内关键位,山寨延续将明显减弱。"
action = "优先跟踪成交额靠前且 5m 不创新低的标的,确认后再加仓。"
return {
"headline": f"{report_day_cn} 复盘:BTC {snapshot['direction']},触发层共 {stats['trigger_count']}",
"btc_explain": snapshot["direction_reason"],
"summary": (
f"昨日 WATCH {stats['watch_count']} 条、TRIGGER {stats['trigger_count']} 条、"
f"漏斗优先推送 {stats['funnel_push_count']} 条。"
f"触发活跃币种:{top_line}"
),
"risk_points": [risk],
"action_hint": action,
}
class DailyReportService:
def __init__(
self,
settings: Settings,
storage: Storage,
gate_client: GateClient,
notifier: WeComNotifier,
gemma_client: OllamaGemmaClient | None,
) -> None:
self.settings = settings
self.storage = storage
self.gate = gate_client
self.notifier = notifier
self.gemma_client = gemma_client
async def _push_wecom_enabled(self) -> bool:
raw = await self.storage.get_kv("daily_report_push_wecom")
if raw is None:
return self.settings.daily_report.push_wecom
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
async def run_once(self) -> dict:
now_utc = utc_now()
now_cn = now_utc.astimezone(CN_TZ)
report_day = now_cn.date() - timedelta(days=1)
start_utc, end_utc = _cn_day_range(report_day)
report_day_cn = report_day.strftime("%Y-%m-%d")
alerts = await self.storage.get_alerts_between(start_utc, end_utc, limit=3000)
watch_count = 0
trigger_count = 0
funnel_push_count = 0
trigger_symbols: Counter[str] = Counter()
for a in alerts:
d = a.get("details") or {}
lvl = str(d.get("signal_level") or "")
src = str(d.get("source") or "")
if lvl == "WATCH":
watch_count += 1
elif lvl == "TRIGGER":
trigger_count += 1
trigger_symbols[str(a.get("symbol") or "").upper()] += 1
if src == "gemma_funnel" and bool(d.get("priority_push")):
funnel_push_count += 1
btc_rows = await self.gate.get_candles(BTC_INST, "1D", limit=100)
closes = _rows_to_close(btc_rows)
last_close = closes[-1] if closes else 0.0
prev_close = closes[-2] if len(closes) >= 2 else last_close
day_change_pct = ((last_close - prev_close) / prev_close * 100.0) if prev_close else 0.0
sma20 = _sma(closes, 20)
sma60 = _sma(closes, 60)
direction, direction_reason = _btc_direction(last_close, prev_close, sma20, sma60)
snapshot = {
"symbol": "BTC",
"last_close": round(last_close, 4),
"prev_close": round(prev_close, 4),
"day_change_pct": round(day_change_pct, 2),
"sma20": round(sma20, 4),
"sma60": round(sma60, 4),
"direction": direction,
"direction_reason": direction_reason,
}
stats = {
"watch_count": watch_count,
"trigger_count": trigger_count,
"funnel_push_count": funnel_push_count,
"top_trigger_symbols": [s for s, _ in trigger_symbols.most_common(10)],
}
ai_used = False
text_block = _default_report_text(snapshot, stats, report_day_cn)
if self.gemma_client and self.settings.gemma.enabled:
ai = await self.gemma_client.generate_daily_report(report_day_cn, snapshot, stats)
if ai and not ai.get("error"):
ai_used = True
text_block = {
"headline": ai.get("headline") or text_block["headline"],
"btc_explain": ai.get("btc_explain") or text_block["btc_explain"],
"summary": ai.get("summary") or text_block["summary"],
"risk_points": ai.get("risk_points") or text_block["risk_points"],
"action_hint": ai.get("action_hint") or text_block["action_hint"],
}
report = {
"report_day_cn": report_day_cn,
"generated_at_utc": now_utc.isoformat(),
"generated_at_cn": format_beijing_wall(now_utc),
"ai_used": ai_used,
"btc": snapshot,
"stats": stats,
"text": text_block,
}
await self.storage.set_kv("daily_report_latest", json.dumps(report, ensure_ascii=False))
await self.storage.add_log(
"INFO",
f"daily_report_generated day={report_day_cn} ai={'on' if ai_used else 'off'} trigger={trigger_count}",
)
if await self._push_wecom_enabled():
await self.notifier.send_daily_report(report)
return report
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from dataclasses import dataclass, field
from statistics import mean
# 以下换算仅针对 5m K(与是否单独拉 4h 图无关):
# 每小时 60/5 = 12 根;一根「4 小时」大周期对应 4×12 = 48 根 5m。
BARS_5M_PER_HOUR = 12
BARS_5M_PER_4H = BARS_5M_PER_HOUR * 4 # 48
# 箱体回看最短不少于一根 4h 等价的 5m 长度,避免用不足一个 4h 的窗去定义箱体
MIN_BOX_LOOKBACK_BARS_5M = BARS_5M_PER_4H
@dataclass
class IntradayRuleParams:
range_hours: float = 8.0
range_max_pct: float = 1.5
volume_spike_mult: float = 1.6
volume_lookback_bars: int = 20
breakout_buffer_pct: float = 0.05
@dataclass
class ExchangeRuleResult:
signal_level: str = "NONE" # NONE | WATCH | TRIGGER
signal_side: str = "NONE" # NONE | LONG | SHORT
trigger_types: list[str] = field(default_factory=list)
score: float = 0.0
metrics: dict = field(default_factory=dict)
def _rows_to_ohlcv(rows: list[list[str]]) -> tuple[list[float], list[float], list[float], list[float], list[float]]:
o, h, l, c, v = [], [], [], [], []
for item in rows:
if len(item) < 6:
continue
o.append(float(item[1]))
h.append(float(item[2]))
l.append(float(item[3]))
c.append(float(item[4]))
v.append(float(item[5]))
return o, h, l, c, v
def evaluate_exchange(
symbol: str,
alt_rows: list[list[str]],
btc_rows: list[list[str]],
params: IntradayRuleParams,
) -> ExchangeRuleResult:
"""
5m 日内结构规则中文分级
- range_hours 墙钟小时换成 5m 根数×12/小时48 5m = 4 墙钟小时
- 观察箱体回看窗口内不含突破/确认 K用最高/最低算振幅不超过 range_max_pct
- 触发突破 K 在有效带内确认 K 收在箱体外并满足放量等条件
"""
breakout_max_pct = 0.5
result = ExchangeRuleResult()
_, ah, al, ac, av = _rows_to_ohlcv(alt_rows)
bars_for_range = max(
MIN_BOX_LOOKBACK_BARS_5M,
int(params.range_hours * BARS_5M_PER_HOUR),
)
vol_lb = max(5, int(params.volume_lookback_bars))
min_need = bars_for_range + vol_lb + 3
if len(ac) < min_need:
result.metrics = {"error": "insufficient_candles", "need": min_need, "have": len(ac)}
return result
# 区间边界:前 N 根(不含倒数第 1 确认 K、倒数第 2 突破 K),用区间内的 highest/lowest
seg_h = ah[-bars_for_range - 2 : -2]
seg_l = al[-bars_for_range - 2 : -2]
range_high = max(seg_h)
range_low = min(seg_l)
mid = (range_high + range_low) / 2 if range_high > range_low else 0
range_pct = ((range_high - range_low) / mid) * 100 if mid > 0 else 999.0
breakout_close = ac[-2]
confirm_close = ac[-1]
breakout_high = ah[-2]
breakout_low = al[-2]
confirm_high = ah[-1]
confirm_low = al[-1]
last_volume = av[-1]
vol_base = mean(av[-vol_lb - 1 : -1]) if len(av) > vol_lb else mean(av)
vol_ratio = (last_volume / vol_base) if vol_base > 0 else 0.0
breakout_min_line = range_high * (1 + params.breakout_buffer_pct / 100)
breakout_max_line = range_high * (1 + breakout_max_pct / 100)
breakdown_min_line = range_low * (1 - params.breakout_buffer_pct / 100)
breakdown_max_line = range_low * (1 - breakout_max_pct / 100)
is_sideways = range_pct <= params.range_max_pct
is_volume_spike = vol_ratio >= params.volume_spike_mult
breakout_long_ok = breakout_close > breakout_min_line and breakout_close < breakout_max_line
breakout_short_ok = breakout_close < breakdown_min_line and breakout_close > breakdown_max_line
confirm_long_ok = confirm_close > range_high
confirm_short_ok = confirm_close < range_low
if is_sideways:
result.signal_level = "WATCH"
result.trigger_types = ["横盘结构成立"]
result.score = 1.0
if is_sideways and breakout_long_ok and confirm_long_ok and is_volume_spike:
result.signal_level = "TRIGGER"
result.signal_side = "LONG"
result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量突破"]
result.score = 3.4
elif is_sideways and breakout_short_ok and confirm_short_ok and is_volume_spike:
result.signal_level = "TRIGGER"
result.signal_side = "SHORT"
result.trigger_types = ["横盘结构成立", "突破K在有效区间", "第二根K确认未回箱体", "放量破位"]
result.score = 3.4
result.metrics = {
"symbol": symbol.upper(),
"bar": "5m",
"range_hours": params.range_hours,
"range_bars": bars_for_range,
"range_max_pct": params.range_max_pct,
"range_pct": round(range_pct, 4),
"range_high": range_high,
"range_low": range_low,
"breakout_min_pct": params.breakout_buffer_pct,
"breakout_max_pct": breakout_max_pct,
"breakout_min_line": breakout_min_line,
"breakout_max_line": breakout_max_line,
"breakdown_min_line": breakdown_min_line,
"breakdown_max_line": breakdown_max_line,
"breakout_close": breakout_close,
"confirm_close": confirm_close,
"breakout_high": breakout_high,
"breakout_low": breakout_low,
"confirm_high": confirm_high,
"confirm_low": confirm_low,
"volume_lookback_bars": vol_lb,
"volume_spike_mult": params.volume_spike_mult,
"last_volume": last_volume,
"volume_base": round(vol_base, 8),
"volume_ratio": round(vol_ratio, 4),
"signal_side": result.signal_side,
}
return result
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from .config import GateConfig
from .proxy_util import httpx_proxy_url
def _to_gate_interval(bar: str) -> str:
b = (bar or "").strip()
mapping = {
"1m": "1m",
"3m": "3m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1H": "1h",
"2H": "2h",
"4H": "4h",
"6H": "6h",
"8H": "8h",
"12H": "12h",
"1D": "1d",
"1W": "7d",
"1M": "1M",
}
if b in mapping:
return mapping[b]
if len(b) >= 2 and b.endswith("H") and b[:-1].isdigit():
return f"{b[:-1]}h"
if len(b) >= 2 and b.endswith("D"):
return b[:-1] + "d"
return b.lower()
def _candle_row(obj: dict[str, Any]) -> list[str]:
ts_ms = str(int(float(obj["t"])) * 1000)
o = str(obj.get("o") or "")
h = str(obj.get("h") or "")
l = str(obj.get("l") or "")
c = str(obj.get("c") or "")
v = str(obj.get("v") or "")
sum_q = str(obj.get("sum") or "")
return [ts_ms, o, h, l, c, v, v, sum_q, "1"]
def _is_linear_usdt_perp_contract(item: dict[str, Any]) -> bool:
name = str(item.get("name") or "")
parts = name.split("_")
if len(parts) != 2 or parts[1].upper() != "USDT":
return False
if item.get("in_delisting") is True:
return False
return True
class GateClient:
"""Gate.io USDT 结算永续合约公共行情(REST v4)。"""
def __init__(self, conf: GateConfig, proxy_url: str | None = None) -> None:
self.conf = conf
self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None)
self.timeout = httpx.Timeout(10.0, read=16.0)
self._candle_sem = asyncio.Semaphore(3)
def _base_url(self) -> str:
return str(self.conf.api_base).rstrip("/")
def _futures_prefix(self) -> str:
return f"{self._base_url()}/futures/{self.conf.settle.strip().lower()}"
def _client_kwargs(self, timeout: httpx.Timeout) -> dict:
if self._proxy:
return {"timeout": timeout, "proxy": self._proxy, "trust_env": False}
return {"timeout": timeout, "trust_env": True}
def symbol_to_swap_inst_id(self, symbol: str) -> str:
base = symbol.strip().upper()
return f"{base}_{self.conf.quote_currency.upper()}"
def inst_id_to_base_symbol(self, inst_id: str) -> str:
inst = inst_id.strip().upper()
suf = f"_{self.conf.quote_currency.upper()}"
if inst.endswith(suf):
return inst[: -len(suf)]
return inst.split("_")[0].upper() if "_" in inst else inst
async def _fetch_contracts(self) -> list[dict[str, Any]]:
url = f"{self._futures_prefix()}/contracts"
async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
raise RuntimeError(f"Gate contracts unexpected payload: {type(data)}")
return data
async def list_live_usdt_swap_inst_ids(self) -> list[str]:
"""全部 USDT 本位线性永续合约名(如 BTC_USDT),剔除交割/下架中的条目。"""
data = await self._fetch_contracts()
out: list[str] = []
for item in data:
if not isinstance(item, dict):
continue
if not _is_linear_usdt_perp_contract(item):
continue
name = str(item.get("name") or "").strip()
if name:
out.append(name)
return sorted(set(out))
async def get_perpetual_symbols(self) -> set[str]:
ids = await self.list_live_usdt_swap_inst_ids()
return {self.inst_id_to_base_symbol(i) for i in ids}
async def get_candles(self, inst_id: str, bar: str, limit: int = 120) -> list[list[str]]:
"""
返回按时间正序排列的 K 线列表与旧 OKX 行格式对齐便于下游逻辑
[ts_ms, o, h, l, c, vol, vol_dup, sum_quote, confirm]
"""
interval = _to_gate_interval(bar)
lim = max(1, min(int(limit), 2000))
url = f"{self._futures_prefix()}/candlesticks"
params = {"contract": inst_id, "interval": interval, "limit": str(lim)}
async with self._candle_sem:
await asyncio.sleep(0.12)
async with httpx.AsyncClient(**self._client_kwargs(self.timeout)) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
raise RuntimeError(f"Gate candlesticks error: {payload}")
rows: list[list[str]] = []
for item in payload:
if isinstance(item, dict) and "t" in item:
rows.append(_candle_row(item))
rows.sort(key=lambda r: int(r[0]) if r and r[0].isdigit() else 0)
return rows
async def get_usdt_swap_est_quote_volume_map(self) -> dict[str, float]:
"""
合约名 -> 24h 计价币种成交额USDT
优先使用 ticker volume_24h_quote缺失时再尝试简单估算
"""
url = f"{self._futures_prefix()}/tickers"
tick_timeout = httpx.Timeout(15.0, read=90.0)
async with httpx.AsyncClient(**self._client_kwargs(tick_timeout)) as client:
resp = await client.get(url)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
raise RuntimeError(f"Gate tickers error: {type(payload)}")
out: dict[str, float] = {}
for item in payload:
if not isinstance(item, dict):
continue
contract = str(item.get("contract") or "").strip()
if not contract.endswith("_USDT"):
continue
vol_quote = item.get("volume_24h_quote") or item.get("volume_24h_usd")
try:
if vol_quote is not None and str(vol_quote).strip():
out[contract] = max(0.0, float(vol_quote))
continue
last = float(item.get("last") or 0)
vol_base = float(item.get("volume_24h_base") or item.get("volume_24h") or 0)
out[contract] = max(0.0, vol_base * last)
except (TypeError, ValueError):
continue
return out
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any
import httpx
from .config import GemmaConfig
LOGGER = logging.getLogger("onchain_scout.gemma_client")
def _extract_json_object(text: str) -> dict[str, Any] | None:
text = text.strip()
m = re.search(r"\{[\s\S]*\}", text)
if not m:
return None
raw = m.group(0)
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
class OllamaGemmaClient:
def __init__(self, conf: GemmaConfig) -> None:
self.conf = conf
self.timeout = httpx.Timeout(conf.timeout_seconds, read=conf.timeout_seconds + 30.0)
async def rank_funnel(
self,
symbol: str,
programmatic_text: str,
ohlc_csv_block: str,
image_base64: str | None,
) -> dict[str, Any]:
"""
调用本地 Ollama Gemma 按漏斗标准 JSON 回复
"""
system = (
"你是加密货币永续合约的日线结构分析师。只输出一个 JSON 对象,不要 Markdown,不要代码围栏。"
"字段必须全部存在且为英文枚举/数字:"
'{"daily_structure":"strong|ok|weak",'
'"volume_view":"high|mid|low",'
'"upside_space":"high|mid|low",'
'"mid_resistance":"low|mid|high",'
'"priority":1-10整数,'
'"one_liner":"中文一句"}。'
"priority 越高越值得优先关注:成交大、日线结构好、上方空间大、中间阻力小则给高分。"
)
user_body = (
f"标的 {symbol} USDT 永续。\n"
f"程序化摘要:\n{programmatic_text}\n\n"
f"最近日线 OHLCV(时间正序最后一行为最新):\n{ohlc_csv_block}\n"
)
url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat"
message: dict[str, Any] = {"role": "user", "content": user_body}
if image_base64 and self.conf.send_chart_image:
message["images"] = [image_base64]
payload: dict[str, Any] = {
"model": self.conf.model,
"messages": [{"role": "system", "content": system}, message],
"stream": False,
"options": {"temperature": self.conf.temperature},
}
if self.conf.json_mode:
payload["format"] = "json"
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
parsed = _extract_json_object(msg) if msg else None
if parsed is None and isinstance(data.get("message"), dict):
parsed = _extract_json_object(str(data["message"]))
if parsed is None:
LOGGER.warning("gemma_parse_failed symbol=%s raw_len=%s", symbol, len(msg))
return {
"error": "parse_failed",
"raw": msg[:2000],
"daily_structure": "weak",
"volume_view": "low",
"upside_space": "low",
"mid_resistance": "high",
"priority": 1,
"one_liner": "模型输出无法解析为 JSON",
}
return _normalize_gemma_dict(parsed)
async def generate_daily_report(self, report_day_cn: str, btc_snapshot: dict, stats: dict) -> dict[str, Any]:
system = (
"你是加密交易复盘助手。输出严格 JSON 对象,不要 Markdown。字段必须存在:"
'{"headline":"...","btc_explain":"...","summary":"...","risk_points":["..."],"action_hint":"..."}。'
"用中文,简洁专业,不写投资建议免责声明。"
)
user_body = (
f"请生成 {report_day_cn} 的晨报。\n"
f"BTC 快照: {json.dumps(btc_snapshot, ensure_ascii=False)}\n"
f"昨日统计: {json.dumps(stats, ensure_ascii=False)}\n"
"要求:1) headline 一句话;2) btc_explain 解释方向;"
"3) summary 覆盖 WATCH/TRIGGER/漏斗;4) risk_points 给1-3条;5) action_hint 给执行提示。"
)
url = f"{self.conf.ollama_base_url.rstrip('/')}/api/chat"
payload: dict[str, Any] = {
"model": self.conf.model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user_body}],
"stream": False,
"options": {"temperature": 0.1},
"format": "json",
}
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
parsed = _extract_json_object(msg) if msg else None
if parsed is None:
return {"error": "parse_failed", "raw": msg[:1200]}
risk = parsed.get("risk_points")
if not isinstance(risk, list):
risk = [str(risk or "")]
risk = [str(x)[:120] for x in risk if str(x or "").strip()][:3] or ["注意高波动时的回撤风险。"]
return {
"headline": str(parsed.get("headline") or "")[:120],
"btc_explain": str(parsed.get("btc_explain") or "")[:220],
"summary": str(parsed.get("summary") or "")[:360],
"risk_points": risk,
"action_hint": str(parsed.get("action_hint") or "")[:220],
}
def _normalize_gemma_dict(d: dict[str, Any]) -> dict[str, Any]:
def _enum(v: Any, choices: set[str], default: str) -> str:
s = str(v or "").strip().lower()
return s if s in choices else default
try:
pr = int(float(d.get("priority", 1)))
except (TypeError, ValueError):
pr = 1
pr = max(1, min(10, pr))
return {
"daily_structure": _enum(d.get("daily_structure"), {"strong", "ok", "weak"}, "weak"),
"volume_view": _enum(d.get("volume_view"), {"high", "mid", "low"}, "low"),
"upside_space": _enum(d.get("upside_space"), {"high", "mid", "low"}, "low"),
"mid_resistance": _enum(d.get("mid_resistance"), {"low", "mid", "high"}, "high"),
"priority": pr,
"one_liner": str(d.get("one_liner") or "")[:280],
}
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import uvicorn
from .config import load_settings
from .web import create_app
def setup_logging(log_file: str) -> None:
path = Path(log_file).resolve()
path.parent.mkdir(parents=True, exist_ok=True)
fmt = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers.clear()
fh = RotatingFileHandler(path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
fh.setFormatter(fmt)
sh = logging.StreamHandler()
sh.setFormatter(fmt)
root.addHandler(fh)
root.addHandler(sh)
def build_app(config_path: str = "config.yaml"):
settings = load_settings(config_path)
setup_logging(settings.app.log_file)
return create_app(settings)
app = build_app()
if __name__ == "__main__":
settings = load_settings("config.yaml")
setup_logging(settings.app.log_file)
uvicorn.run(
"app.main:app",
host=settings.app.host,
port=settings.app.port,
workers=1,
log_level="info",
)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class AlertRecord(Base):
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(32), index=True)
chain: Mapped[str] = mapped_column(String(32), index=True)
trigger_types: Mapped[str] = mapped_column(String(255))
score: Mapped[float] = mapped_column(Float)
details_json: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class RuntimeLog(Base):
__tablename__ = "runtime_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
level: Mapped[str] = mapped_column(String(12), index=True)
message: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class KvStore(Base):
"""Simple key-value settings persisted in SQLite (e.g. chart bar from web UI)."""
__tablename__ = "kv_store"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+771
View File
@@ -0,0 +1,771 @@
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from .btc_regime import evaluate_btc_daily_gate
from .chart_candles import daily_candles_png_base64
from .config import Settings, WatchSymbol
from .daily_features import (
build_daily_programmatic,
composite_score,
daily_ohlc_text_block,
programmatic_scores,
)
from .exchange_rules import IntradayRuleParams, evaluate_exchange
from .notifier import WeComNotifier
from .order_executor_forward import build_order_executor_payload, forward_signal_to_executors
from .order_executors_store import read_forward_config, record_last_forward
from .gate import GateClient
from .storage import Storage
if TYPE_CHECKING:
from .gemma_client import OllamaGemmaClient
LOGGER = logging.getLogger("onchain_scout.monitor")
FIXED_BAR = "5m"
# 最近 8 墙钟小时 ≈ 32 根 15m K
BTC_15M_BARS_PER_8H = 32
@dataclass
class RuntimeState:
started_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
last_cycle_at: str = ""
last_cycle_status: str = "INIT"
last_cycle_msg: str = ""
chart_bar: str = FIXED_BAR
universe: str = "all_swaps"
intraday_params: dict = field(default_factory=dict)
monitoring_pool: list[dict] = field(default_factory=list)
perpetual_symbols_count: int = 0
monitored_inst_count: int = 0
pushed_alerts_count: int = 0
btc_gate_allow: bool = True
btc_gate_regime: str = ""
btc_gate_reason: str = ""
btc_gate_metrics: dict = field(default_factory=dict)
btc_env_8h_15m: str = ""
symbol_blocklist_count: int = 0
symbol_blocklist_removed: int = 0
last_funnel: list[dict] = field(default_factory=list)
last_funnel_at: str = ""
gemma_cycle_msg: str = ""
class MonitorService:
def __init__(
self,
settings: Settings,
storage: Storage,
gate_client: GateClient,
notifier: WeComNotifier,
gemma_client: OllamaGemmaClient | None = None,
) -> None:
self.settings = settings
self.storage = storage
self.gate = gate_client
self.notifier = notifier
self.gemma_client = gemma_client
self.state = RuntimeState()
self._lock = asyncio.Lock()
self._funnel_bg_task: asyncio.Task[None] | None = None
@staticmethod
def _symbol_blocklist_from_kv(raw: str | None) -> frozenset[str]:
if not raw or not str(raw).strip():
return frozenset()
try:
data = json.loads(raw)
except json.JSONDecodeError:
return frozenset()
if not isinstance(data, list):
return frozenset()
out: set[str] = set()
for x in data:
s = str(x).strip().upper()
if s:
out.add(s)
return frozenset(out)
async def _maybe_forward_order_executor(self, sym: str, inst: str, push_metrics: dict) -> None:
fwd = read_forward_config(self.settings)
if not fwd.get("enabled"):
return
secret = str(fwd.get("webhook_secret") or "").strip()
if not secret:
await self.storage.add_log(
"WARN",
"order_executor.enabled=true but webhook_secret is empty; skip POST /v1/signal",
)
return
targets = list(fwd.get("executors") or [])
if not targets:
await self.storage.add_log(
"WARN",
"order_executor.enabled=true but no enabled executor in panel list; skip POST /v1/signal",
)
return
payload = build_order_executor_payload(inst_id=inst, metrics=push_metrics)
if not payload:
await self.storage.add_log(
"WARN",
f"order_executor_build_payload_failed sym={sym} inst={inst}",
)
return
try:
results = await forward_signal_to_executors(
self.settings,
executors=targets,
webhook_secret=secret,
timeout_seconds=float(fwd.get("timeout_seconds") or 15.0),
payload=payload,
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log(
"ERROR",
f"order_executor_forward_exception sym={sym} inst={inst}: {exc}",
)
return
for out in results:
name = out.get("name") or "?"
eid = str(out.get("executor_id") or "")
status = out.get("http_status")
ok = out.get("ok")
body = out.get("body") or {}
st = body.get("status") if isinstance(body, dict) else None
detail = out.get("error")
if not detail and isinstance(body, dict):
detail = body.get("reason") or body.get("detail")
try:
record_last_forward(
self.settings,
eid,
http_status=int(status or 0),
ok=bool(ok),
exec_status=str(st) if st is not None else None,
detail=str(detail) if detail is not None else None,
)
except Exception: # noqa: BLE001
pass
if ok:
await self.storage.add_log(
"INFO",
f"order_executor_ok name={name} sym={sym} inst={inst} http={status} exec_status={st}",
)
else:
await self.storage.add_log(
"ERROR",
f"order_executor_failed name={name} sym={sym} inst={inst} http={status} body={body!r} err={detail}",
)
@staticmethod
def _btc_intraday_bias(btc_rows: list[list[str]]) -> str:
closes: list[float] = []
for row in btc_rows:
if len(row) < 5:
continue
try:
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
if len(closes) < 2:
return "NEUTRAL"
if closes[-1] > closes[-2]:
return "BULL"
if closes[-1] < closes[-2]:
return "BEAR"
return "NEUTRAL"
@staticmethod
def _ema(values: list[float], period: int) -> float:
if not values:
return 0.0
p = max(1, period)
alpha = 2.0 / (p + 1.0)
ema = values[0]
for v in values[1:]:
ema = alpha * v + (1 - alpha) * ema
return ema
@staticmethod
def _status_by_ema55(rows: list[list[str]]) -> str:
closes: list[float] = []
highs: list[float] = []
lows: list[float] = []
for row in rows:
if len(row) < 5:
continue
try:
highs.append(float(row[2]))
lows.append(float(row[3]))
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
if len(closes) < 55:
return "横盘"
ema55 = MonitorService._ema(closes[-120:], 55)
last = closes[-1]
lb = min(21, len(closes))
h = max(highs[-lb:])
l = min(lows[-lb:])
mid = (h + l) / 2 if h > l else 0.0
range_pct = ((h - l) / mid * 100.0) if mid > 0 else 999.0
if range_pct <= 2.0:
return "横盘"
if last >= ema55:
return "多头"
return "空头"
@staticmethod
def _btc_env_15m_last_8h(rows_15m: list[list[str]]) -> str:
"""最近 8 小时内 BTC 15m 走势:多头 / 空头 / 横盘(窄幅视为横盘)。"""
closes: list[float] = []
highs: list[float] = []
lows: list[float] = []
for row in rows_15m:
if len(row) < 5:
continue
try:
highs.append(float(row[2]))
lows.append(float(row[3]))
closes.append(float(row[4]))
except (TypeError, ValueError):
continue
need = BTC_15M_BARS_PER_8H
if len(closes) < need:
return "横盘"
h_win = max(highs[-need:])
l_win = min(lows[-need:])
mid = (h_win + l_win) / 2 if h_win > l_win else 0.0
range_pct = ((h_win - l_win) / mid * 100.0) if mid > 0 else 999.0
if range_pct <= 1.8:
return "横盘"
warmup = min(len(closes), 96)
seq = closes[-warmup:]
if len(seq) < 21:
return "横盘"
ema21 = MonitorService._ema(seq, 21)
last = closes[-1]
if last >= ema21:
return "多头"
return "空头"
@staticmethod
def _push_matches_btc_env(btc_env: str, signal_side: str) -> bool:
if signal_side not in {"LONG", "SHORT"}:
return False
if btc_env == "横盘":
return True
if btc_env == "多头":
return signal_side == "LONG"
if btc_env == "空头":
return signal_side == "SHORT"
return False
@staticmethod
def _within_push_window_utc8(enabled: bool) -> bool:
if not enabled:
return True
now_utc = datetime.now(timezone.utc)
bj_hour = (now_utc.hour + 8) % 24
return 9 <= bj_hour < 23
async def run_cycle(self) -> None:
funnel_candidates: list[dict] = []
async with self._lock:
try:
funnel_candidates = await self._run_cycle_inner()
self.state.last_cycle_status = "OK"
self.state.last_cycle_msg = "cycle_completed"
except Exception as exc: # noqa: BLE001
msg = f"cycle_failed: {exc}"
self.state.last_cycle_status = "ERROR"
self.state.last_cycle_msg = msg
LOGGER.exception(msg)
await self.storage.add_log("ERROR", msg)
funnel_candidates = []
finally:
# 本轮扫描结束后刷新,避免 HUD「LAST」与墙钟脱节(原先在周期开始时写入)
self.state.last_cycle_at = datetime.now(timezone.utc).isoformat()
if self.gemma_client and self.settings.gemma.enabled and funnel_candidates:
if self._funnel_bg_task is not None and not self._funnel_bg_task.done():
await self.storage.add_log(
"WARN",
f"funnel_skipped_previous_still_running candidates={len(funnel_candidates)}",
)
else:
self._funnel_bg_task = asyncio.create_task(
self._run_gemma_funnel_safe(funnel_candidates),
name="gemma_funnel",
)
async def _run_gemma_funnel_safe(self, candidates: list[dict]) -> None:
try:
await self._run_gemma_funnel(candidates)
except Exception as exc: # noqa: BLE001
msg = f"funnel_failed: {exc}"
LOGGER.exception(msg)
await self.storage.add_log("ERROR", msg)
async with self._lock:
self.state.gemma_cycle_msg = f"funnel_failed: {exc!s}"[:500]
async def _run_cycle_inner(self) -> list[dict]:
bar = FIXED_BAR
self.state.chart_bar = bar
universe = self.settings.monitor.universe
self.state.universe = universe
rule_params = await self._load_intraday_params()
stop_buffer_pct = _as_float(await self.storage.get_kv("intraday_stop_buffer_pct"), 0.2)
stop_buffer_pct = max(0.0, min(stop_buffer_pct, 10.0))
self.state.intraday_params = {
"range_hours": rule_params.range_hours,
"range_max_pct": rule_params.range_max_pct,
"volume_spike_mult": rule_params.volume_spike_mult,
"volume_lookback_bars": rule_params.volume_lookback_bars,
"breakout_buffer_pct": rule_params.breakout_buffer_pct,
"stop_buffer_pct": stop_buffer_pct,
}
blocklist = self._symbol_blocklist_from_kv(await self.storage.get_kv("monitor_symbol_blocklist"))
self.state.symbol_blocklist_count = len(blocklist)
self.state.symbol_blocklist_removed = 0
listed_bases = await self.gate.get_perpetual_symbols()
self.state.perpetual_symbols_count = len(listed_bases)
min_vol = float(self.settings.monitor.min_24h_quote_volume_usdt)
vol_map: dict[str, float] = {}
watch_insts: list[str] = []
if universe == "all_swaps":
if min_vol <= 0:
await self.storage.add_log(
"ERROR",
"all_swaps requires monitor.min_24h_quote_volume_usdt > 0; skipping cycle",
)
self.state.monitoring_pool = []
self.state.monitored_inst_count = 0
return []
vol_map = await self.gate.get_usdt_swap_est_quote_volume_map()
all_ids = await self.gate.list_live_usdt_swap_inst_ids()
watch_insts = [i for i in all_ids if vol_map.get(i, 0.0) >= min_vol]
await self.storage.add_log(
"INFO",
f"universe=all_swaps bar={bar} min_usdt={min_vol:.0f} pool={len(watch_insts)}/{len(all_ids)}",
)
else:
watchlist = [w for w in self.settings.watch_symbols if w.symbol.upper() in listed_bases]
if min_vol > 0:
vol_map = await self.gate.get_usdt_swap_est_quote_volume_map()
before = len(watchlist)
kept: list[WatchSymbol] = []
for w in watchlist:
inst = self.gate.symbol_to_swap_inst_id(w.symbol)
est = vol_map.get(inst, 0.0)
if est >= min_vol:
kept.append(w)
else:
await self.storage.add_log(
"INFO",
f"{w.symbol.upper()} skipped_24h_vol est_usdt={est:.0f} < min={min_vol:.0f}",
)
watchlist = kept
await self.storage.add_log(
"INFO",
f"universe=watchlist volume_filter min_usdt={min_vol:.0f} kept={len(watchlist)}/{before}",
)
watch_insts = [self.gate.symbol_to_swap_inst_id(w.symbol) for w in watchlist]
await self.storage.add_log(
"INFO",
f"universe=watchlist bar={bar} pool={len(watch_insts)} gate_bases={len(listed_bases)}",
)
if blocklist:
before_bl = len(watch_insts)
watch_insts = [
i for i in watch_insts if self.gate.inst_id_to_base_symbol(i) not in blocklist
]
removed = before_bl - len(watch_insts)
self.state.symbol_blocklist_removed = removed
if removed:
await self.storage.add_log(
"INFO",
f"symbol_blocklist removed={removed} pool_now={len(watch_insts)} rules={len(blocklist)}",
)
self.state.monitored_inst_count = len(watch_insts)
push_window_enabled = _as_bool(await self.storage.get_kv("intraday_push_time_window_enabled"), True)
vol_rank_map: dict[str, int] = {}
vol_rank_total = len(watch_insts)
if vol_map and watch_insts:
sorted_insts = sorted(watch_insts, key=lambda x: float(vol_map.get(x, 0.0)), reverse=True)
vol_rank_map = {inst_id: idx + 1 for idx, inst_id in enumerate(sorted_insts)}
self.state.monitoring_pool = []
for inst in watch_insts:
sym = self.gate.inst_id_to_base_symbol(inst)
entry: dict = {"symbol": sym, "instId": inst}
if vol_map:
entry["est_quote_vol_24h_usdt"] = round(vol_map.get(inst, 0.0), 2)
self.state.monitoring_pool.append(entry)
btc_inst = self.gate.symbol_to_swap_inst_id("BTC")
if self.settings.monitor.btc_daily_gate_enabled:
btc_1d = await self.gate.get_candles(btc_inst, "1D", limit=60)
gate = evaluate_btc_daily_gate(
btc_1d,
sideways_lookback_days=self.settings.monitor.btc_sideways_lookback_days,
sideways_max_range_pct=self.settings.monitor.btc_sideways_max_range_pct,
)
self.state.btc_gate_allow = gate.allow_alt_scan
self.state.btc_gate_regime = gate.regime
self.state.btc_gate_reason = gate.reason
self.state.btc_gate_metrics = dict(gate.metrics)
if not gate.allow_alt_scan:
await self.storage.add_log(
"INFO",
(
f"btc_daily_gate regime={gate.regime} reason={gate.reason} "
f"(informational only; scan continues) metrics={gate.metrics}"
),
)
else:
self.state.btc_gate_allow = True
self.state.btc_gate_regime = "disabled"
self.state.btc_gate_reason = "btc_daily_gate_enabled=false"
self.state.btc_gate_metrics = {}
btc_rows = await self.gate.get_candles(btc_inst, bar, limit=120)
btc_bias_5m = self._btc_intraday_bias(btc_rows)
btc_15m_rows = await self.gate.get_candles(btc_inst, "15m", limit=120)
btc_env_8h_15m = self._btc_env_15m_last_8h(btc_15m_rows)
self.state.btc_env_8h_15m = btc_env_8h_15m
await self.storage.add_log(
"INFO",
f"btc_intraday_bias_5m={btc_bias_5m} btc_env_8h_15m={btc_env_8h_15m}",
)
funnel_candidates: list[dict] = []
for inst in watch_insts:
sym = self.gate.inst_id_to_base_symbol(inst)
try:
alt_rows = await self.gate.get_candles(inst, bar, limit=120)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"{sym} candles_failed: {exc}")
continue
result = evaluate_exchange(sym, alt_rows, btc_rows, rule_params)
if result.signal_level in {"WATCH", "TRIGGER"}:
est_vol = float(vol_map.get(inst, 0.0)) if vol_map else 0.0
signal_side = str((result.metrics or {}).get("signal_side") or result.signal_side or "NONE")
push_allowed = result.signal_level == "TRIGGER"
funnel_candidates.append(
{
"symbol": sym,
"inst": inst,
"est_vol": est_vol,
"est_vol_rank": int(vol_rank_map.get(inst, 0)) if vol_rank_map else 0,
"est_vol_rank_total": int(vol_rank_total),
"signal_level": result.signal_level,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"push_allowed": push_allowed,
"btc_env_8h_15m": btc_env_8h_15m,
"intraday_metrics": dict(result.metrics),
}
)
dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours)
chain_suffix = signal_side if signal_side in {"LONG", "SHORT"} else "NONE"
surface_chain = f"GATE-USDT {bar} {result.signal_level} {chain_suffix}"
skip_surface_alert = dedupe_h > 0 and await self.storage.has_recent_alert(
sym, chain=surface_chain, within_hours=dedupe_h
)
if not skip_surface_alert:
symbol_4h_status = "横盘"
symbol_side_ok = False
push_time_ok = True
vol_rank_ok = True
rank_max = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0)
if result.signal_level == "TRIGGER":
try:
sym_4h_rows = await self.gate.get_candles(inst, "4H", limit=120)
symbol_4h_status = self._status_by_ema55(sym_4h_rows)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"{sym} 4h_status_failed: {exc}")
symbol_side_ok = (signal_side == "LONG" and symbol_4h_status == "多头") or (
signal_side == "SHORT" and symbol_4h_status == "空头"
)
push_time_ok = self._within_push_window_utc8(push_window_enabled)
if rank_max > 0:
if vol_rank_map:
rnk = int(vol_rank_map.get(inst, 999))
vol_rank_ok = 1 <= rnk <= rank_max
else:
vol_rank_ok = False
btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side)
strict_push_ok = bool(
push_allowed and symbol_side_ok and push_time_ok and vol_rank_ok and btc_env_ok
)
push_reason = "trigger_pushed"
if result.signal_level == "TRIGGER" and not strict_push_ok:
reasons: list[str] = []
if not btc_env_ok:
reasons.append("btc_env_8h_15m_direction_mismatch")
if not symbol_side_ok:
reasons.append("symbol_4h_not_aligned")
if not push_time_ok:
reasons.append("outside_push_time_window")
if not vol_rank_ok:
reasons.append("volume_rank_outside_top_n")
push_reason = ",".join(reasons) if reasons else "filtered_by_rules"
await self.storage.add_alert(
symbol=sym,
venue=surface_chain,
trigger_types=result.trigger_types,
score=result.score,
details={
"metrics": result.metrics,
"instId": inst,
"signal_level": result.signal_level,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"push_allowed": push_allowed,
"btc_env_8h_15m": btc_env_8h_15m,
"btc_env_ok": btc_env_ok,
"symbol_4h_status": symbol_4h_status,
"push_time_ok": push_time_ok,
"vol_rank_ok": vol_rank_ok,
"strict_push_ok": strict_push_ok,
"push_block_reason": push_reason,
},
)
if result.signal_level == "TRIGGER":
if strict_push_ok:
push_metrics = dict(result.metrics)
push_metrics["signal_side"] = signal_side
push_metrics["btc_bias"] = btc_bias_5m
push_metrics["btc_env_8h_15m"] = btc_env_8h_15m
push_metrics["symbol_4h_status"] = symbol_4h_status
push_metrics["est_quote_vol_24h_usdt"] = est_vol
push_metrics["est_quote_vol_rank"] = int(vol_rank_map.get(inst, 0))
push_metrics["est_quote_vol_rank_total"] = int(vol_rank_total)
push_metrics["stop_buffer_pct"] = stop_buffer_pct
try:
await self.notifier.send_breakout_alert(
symbol=sym,
bar=bar,
inst_id=inst,
trigger_types=result.trigger_types,
metrics=push_metrics,
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"wecom_push_failed {sym}: {exc}")
else:
self.state.pushed_alerts_count += 1
await self._maybe_forward_order_executor(sym, inst, push_metrics)
else:
await self.storage.add_log(
"INFO",
(
f"signal_blocked sym={sym} side={signal_side} btc_bias={btc_bias_5m} "
f"btc_env_8h_15m={btc_env_8h_15m} btc_env_ok={btc_env_ok} "
f"sym_4h={symbol_4h_status} symbol_side_ok={symbol_side_ok} "
f"push_time_ok={push_time_ok} vol_rank_ok={vol_rank_ok} "
f"rank={vol_rank_map.get(inst, 0)}/{rank_max}"
),
)
await self.storage.add_log(
"WARN",
(
f"signal={result.signal_level} side={signal_side} {sym} bar={bar} "
f"push_allowed={push_allowed} triggers={','.join(result.trigger_types)}"
),
)
else:
await self.storage.add_log(
"INFO",
f"signal_dedupe_skip sym={sym} chain={surface_chain} within_h={dedupe_h}",
)
await asyncio.sleep(0.08)
if not self.settings.gemma.enabled:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "gemma_disabled"
elif not self.gemma_client:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "gemma_client_none"
elif not funnel_candidates:
self.state.last_funnel = []
self.state.gemma_cycle_msg = "no_funnel_candidates"
else:
self.state.gemma_cycle_msg = "funnel_pending"
await self.storage.add_log(
"INFO",
f"cycle_scan_done monitored={len(watch_insts)} funnel_candidates={len(funnel_candidates)}",
)
return funnel_candidates
async def _run_gemma_funnel(self, candidates: list[dict]) -> None:
assert self.gemma_client is not None
cfg = self.settings.gemma
candidates.sort(key=lambda x: float(x.get("est_vol") or 0.0), reverse=True)
take = candidates[: max(1, cfg.max_funnel_per_cycle)]
out: list[dict] = []
dedupe_h = float(self.settings.monitor.symbol_signal_dedupe_hours)
for i, c in enumerate(take):
sym = str(c["symbol"])
inst = str(c["inst"])
est = float(c.get("est_vol") or 0.0)
if dedupe_h > 0 and await self.storage.has_recent_alert(
sym, chain="FUNNEL-GEMMA", within_hours=dedupe_h
):
await self.storage.add_log("INFO", f"funnel_dedupe_skip sym={sym} within_h={dedupe_h}")
continue
try:
rows_1d = await self.gate.get_candles(inst, "1D", limit=80)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("WARN", f"funnel {sym} 1d_failed: {exc}")
continue
prog = build_daily_programmatic(rows_1d, est)
subs = programmatic_scores(prog)
prog_text = json.dumps({**prog, **subs}, ensure_ascii=False)
ohlc_block = daily_ohlc_text_block(rows_1d)
img_b64: str | None = None
if cfg.send_chart_image and i < max(0, cfg.vision_top_n):
img_b64 = daily_candles_png_base64(rows_1d, sym)
try:
gemma_out = await self.gemma_client.rank_funnel(sym, prog_text, ohlc_block, img_b64)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"gemma_ollama_failed {sym}: {exc}")
gemma_out = {
"daily_structure": "weak",
"volume_view": "low",
"upside_space": "low",
"mid_resistance": "high",
"priority": 1,
"one_liner": f"Ollama 调用失败: {exc}",
"error": str(exc),
}
pri = float(gemma_out.get("priority", 1))
comp = composite_score(pri, subs)
signal_side = str(c.get("signal_side") or "NONE")
btc_bias_5m = str(c.get("btc_bias_5m") or "NEUTRAL")
btc_env_8h_15m = str(c.get("btc_env_8h_15m") or "横盘")
btc_env_ok = self._push_matches_btc_env(btc_env_8h_15m, signal_side)
threshold_ok = pri >= cfg.gemma_push_priority_min or comp >= cfg.composite_push_min
rank_max_f = int(getattr(self.settings.monitor, "wecom_push_max_volume_rank", 0) or 0)
vol_rank_ok_f = True
if rank_max_f > 0:
rnk_f = int(c.get("est_vol_rank") or 999)
vol_rank_ok_f = 1 <= rnk_f <= rank_max_f
should_push = btc_env_ok and threshold_ok and vol_rank_ok_f
gemma_clean = {k: v for k, v in gemma_out.items() if k not in {"raw", "error"}}
details: dict = {
"source": "gemma_funnel",
"underlying_signal": c.get("signal_level"),
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"btc_env_8h_15m": btc_env_8h_15m,
"gemma": gemma_clean,
"programmatic": prog,
"programmatic_subscores": subs,
"composite_score": comp,
"priority_push": should_push,
"priority_threshold_ok": threshold_ok,
"btc_env_ok": btc_env_ok,
"volume_rank_ok": vol_rank_ok_f,
"instId": inst,
"image_sent": bool(img_b64),
"intraday_signal_metrics": c.get("intraday_metrics"),
}
if gemma_out.get("error"):
details["gemma_error"] = str(gemma_out.get("error"))[:500]
raw_snip = gemma_out.get("raw")
if isinstance(raw_snip, str) and raw_snip:
details["gemma_raw_snip"] = raw_snip[:800]
await self.storage.add_alert(
symbol=sym,
venue="FUNNEL-GEMMA",
trigger_types=["漏斗", f"P{int(pri)}", str(gemma_out.get("daily_structure", "?"))],
score=comp,
details=details,
)
if should_push:
try:
await self.notifier.send_funnel_priority(
symbol=sym,
inst_id=inst,
composite_score=comp,
gemma=gemma_clean,
programmatic=prog,
)
self.state.pushed_alerts_count += 1
await self.storage.add_log(
"WARN",
f"funnel_priority_push {sym} composite={comp} priority={pri}",
)
except Exception as exc: # noqa: BLE001
await self.storage.add_log("ERROR", f"funnel_wecom_push_failed {sym}: {exc}")
out.append(
{
"symbol": sym,
"composite_score": comp,
"gemma_priority": pri,
"signal_side": signal_side,
"btc_bias_5m": btc_bias_5m,
"pushed": should_push,
"one_liner": gemma_clean.get("one_liner", ""),
}
)
await asyncio.sleep(0.35)
out.sort(key=lambda x: float(x.get("composite_score") or 0.0), reverse=True)
msg = f"funnel_ranked={len(out)}"
async with self._lock:
self.state.last_funnel = out[:40]
self.state.last_funnel_at = datetime.now(timezone.utc).isoformat()
self.state.gemma_cycle_msg = msg
await self.storage.add_log("INFO", msg)
async def _load_intraday_params(self) -> IntradayRuleParams:
range_hours = _as_float(await self.storage.get_kv("intraday_range_hours"), 24.0)
range_max_pct = _as_float(await self.storage.get_kv("intraday_range_max_pct"), 1.5)
volume_spike_mult = _as_float(await self.storage.get_kv("intraday_volume_spike_mult"), 1.6)
volume_lookback_bars = int(_as_float(await self.storage.get_kv("intraday_volume_lookback_bars"), 20))
breakout_buffer_pct = _as_float(await self.storage.get_kv("intraday_breakout_buffer_pct"), 0.05)
return IntradayRuleParams(
range_hours=max(1.0, range_hours),
range_max_pct=max(0.1, range_max_pct),
volume_spike_mult=max(1.0, volume_spike_mult),
volume_lookback_bars=max(5, volume_lookback_bars),
breakout_buffer_pct=max(0.0, breakout_buffer_pct),
)
def _as_float(raw: str | None, default: float) -> float:
try:
return float(raw) if raw is not None else default
except (TypeError, ValueError):
return default
def _as_bool(raw: str | None, default: bool) -> bool:
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import httpx
from .config import WeComConfig
from .proxy_util import httpx_proxy_url
from .time_cn import format_beijing_wall, utc_now
class WeComNotifier:
def __init__(self, conf: WeComConfig, proxy_url: str | None = None) -> None:
self.conf = conf
self._proxy = httpx_proxy_url(proxy_url.strip() if proxy_url and str(proxy_url).strip() else None)
self.timeout = httpx.Timeout(8.0, read=10.0)
def _client_kwargs(self) -> dict:
if self._proxy:
return {"timeout": self.timeout, "proxy": self._proxy, "trust_env": False}
return {"timeout": self.timeout, "trust_env": True}
async def send_breakout_alert(
self,
symbol: str,
bar: str,
inst_id: str,
trigger_types: list[str],
metrics: dict,
) -> None:
sym_u = symbol.strip().upper()
pair_line = f"{sym_u}-USDT 永续"
bar_cn = "5分钟" if bar == "5m" else f"{bar}"
range_h = float(metrics.get("range_hours") or 8)
range_pct = float(metrics.get("range_pct") or 0)
vol_ratio = float(metrics.get("volume_ratio") or 0)
range_high = float(metrics.get("range_high") or 0.0)
range_low = float(metrics.get("range_low") or 0.0)
confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0)
breakout_high = float(metrics.get("breakout_high") or 0.0)
breakout_low = float(metrics.get("breakout_low") or 0.0)
est_vol = float(metrics.get("est_quote_vol_24h_usdt") or 0.0)
est_vol_rank = int(metrics.get("est_quote_vol_rank") or 0)
est_vol_rank_total = int(metrics.get("est_quote_vol_rank_total") or 0)
btc_env_8h_15m = str(metrics.get("btc_env_8h_15m") or metrics.get("btc_8h_status") or "横盘")
symbol_4h_status = str(metrics.get("symbol_4h_status") or "横盘")
def _px(x: float) -> str:
s = f"{x:.8f}".rstrip("0").rstrip(".")
return s or "0"
signal_side = str(metrics.get("signal_side") or "NONE")
signal_cn = "多头突破" if signal_side == "LONG" else ("空头破位" if signal_side == "SHORT" else "方向未定")
dir_line = "做多突破" if signal_side == "LONG" else ("做空破位" if signal_side == "SHORT" else "方向未定")
move_line = "放量上破" if signal_side == "LONG" else ("放量下破" if signal_side == "SHORT" else "等待确认")
state_line = f"{range_h:g}小时横盘箱体 {move_line}"
vol24_line = f"{est_vol:,.0f} USDT" if est_vol > 0 else "未知"
rank_line = (
f"#{est_vol_rank} / {est_vol_rank_total}"
if est_vol_rank > 0 and est_vol_rank_total > 0
else "未知"
)
key_ref = _px(range_low if signal_side == "LONG" else range_high)
stop_pct = float(metrics.get("stop_buffer_pct") or 0.2)
stop_pct = max(0.0, min(stop_pct, 10.0))
long_m = 1.0 - stop_pct / 100.0
short_m = 1.0 + stop_pct / 100.0
stop_a = _px(breakout_low * long_m if signal_side == "LONG" else breakout_high * short_m)
stop_b = _px(range_low * long_m if signal_side == "LONG" else range_high * short_m)
if abs(stop_pct - round(stop_pct)) < 1e-9:
stop_pct_label = str(int(round(stop_pct)))
else:
stop_pct_label = f"{stop_pct:.4f}".rstrip("0").rstrip(".") or "0"
box_size = (range_high - range_low)
tp_a = _px(confirm_close + box_size if signal_side == "LONG" else confirm_close - box_size)
tp_b = _px(confirm_close + box_size * 1.5 if signal_side == "LONG" else confirm_close - box_size * 1.5)
t_cn = format_beijing_wall(utc_now())
content = (
"🚨 Gate 突破预警信号\n"
"━━━━━━━━━━━━━━\n"
f"🔹 交易对:{pair_line}\n"
f"⏱️ K线周期:{bar_cn}\n"
f"📊 行情状态:{state_line}\n"
f"🧭 信号方向:{dir_line}\n"
"✅ 确认条件:\n"
f" 1. 震荡幅度:{range_pct:.2f}%\n"
f" 2. 成交量放大:{vol_ratio:.2f}\n"
f" 3. BTC 近8小时(15m){btc_env_8h_15m}(横盘多空均可推送;涨→仅LONG;跌→仅SHORT)\n"
f" 4. 日成交量:{vol24_line}\n"
f" 5. 当日成交量排名:{rank_line}\n"
f" 6. 本币种4h状态:{symbol_4h_status}(仅同向推送)\n"
"📌 关键价位:\n"
f" {'箱体下沿' if signal_side == 'LONG' else '箱体上沿'}{key_ref}\n"
f" 确认K收盘价:{_px(confirm_close)}\n"
"💡 操作提示:\n"
f" 1. 入场区间A:止盈 {signal_cn} 箱体1.0倍距离({tp_a}),止损 突破K高低点±{stop_pct_label}%{stop_a}\n"
f" 2. 入场区间B:止盈 {signal_cn} 箱体1.5倍距离({tp_b}),止损 箱体边沿±{stop_pct_label}%{stop_b}\n"
f"⏰ 触发时间:{t_cn}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_mobile_list": self.conf.mentioned_mobile_list,
},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
async def send_funnel_priority(
self,
symbol: str,
inst_id: str,
composite_score: float,
gemma: dict,
programmatic: dict,
) -> None:
sym_u = symbol.strip().upper()
pair_line = f"{sym_u}-USDT 永续"
pri = gemma.get("priority", "?")
one = str(gemma.get("one_liner", "") or "").strip()
t_cn = format_beijing_wall(utc_now())
def _pg(key: str, default: str = "") -> str:
v = programmatic.get(key)
if v is None:
return default
if isinstance(v, bool):
return "" if v else ""
if isinstance(v, (int, float)):
return f"{float(v):.6f}".rstrip("0").rstrip(".") or "0"
return str(v)
vol24 = programmatic.get("est_quote_vol_24h_usdt")
vol24_s = f"{float(vol24):,.0f}" if isinstance(vol24, (int, float)) else str(vol24 or "")
prog_lines = [
f" · 现价:{_pg('last_close')}",
f" · 24h 估算成交额 USDT{vol24_s}",
f" · 60日区间高 / 低:{_pg('range_60d_high')} / {_pg('range_60d_low')}",
f" · 区间振幅%(回看):{_pg('range_pct_lookback')}",
f" · 距区间上沿空间%{_pg('upside_to_range_high_pct')}",
f" · 结构提示:{_pg('structure_hint')}",
f" · SMA20{_pg('sma20')}",
]
content = (
"🎯 MATRIX · 漏斗优先推送\n"
"━━━━━━━━━━━━━━\n"
f"🔹 交易对:{pair_line}\n"
f"🔗 合约 ID{inst_id}\n"
f"📈 合成评分:{composite_score:.2f}\n"
"🧩 Gemma 分项:\n"
f" 优先级 P{pri}|结构 {gemma.get('daily_structure', '?')}|量 {gemma.get('volume_view', '?')}"
f"上方 {gemma.get('upside_space', '?')}|中间阻力 {gemma.get('mid_resistance', '?')}\n"
"💬 一句话:\n"
f" {one}\n"
"📌 程序化摘录:\n"
+ "\n".join(prog_lines)
+ "\n"
"💡 操作提示:\n"
"仅结构信号,严格执行交易纪律+仓位管理\n"
f"⏰ 触发时间:{t_cn}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {
"content": content,
"mentioned_mobile_list": self.conf.mentioned_mobile_list,
},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
async def send_daily_report(self, report: dict) -> None:
text = report.get("text") or {}
btc = report.get("btc") or {}
stats = report.get("stats") or {}
risk_lines = text.get("risk_points") or []
risk_block = "\n".join([f" - {str(x)}" for x in risk_lines[:3]]) if risk_lines else " - 暂无"
content = (
"🗞️ MATRIX 每日晨报\n"
"━━━━━━━━━━━━━━\n"
f"📅 复盘日期:{report.get('report_day_cn', '')}\n"
f"🤖 AI 生成:{'' if report.get('ai_used') else '否(规则回退)'}\n"
f"📈 BTC 方向:{btc.get('direction', '')} | 日涨跌 {btc.get('day_change_pct', '')}%\n"
f"🧭 方向说明:{text.get('btc_explain', '')}\n"
f"📊 昨日统计:WATCH {stats.get('watch_count', 0)} / TRIGGER {stats.get('trigger_count', 0)} / 漏斗优先 {stats.get('funnel_push_count', 0)}\n"
f"📝 总结:{text.get('summary', '')}\n"
f"⚠️ 风险点:\n{risk_block}\n"
f"🎯 执行提示:{text.get('action_hint', '')}\n"
f"⏰ 生成时间:{report.get('generated_at_cn', format_beijing_wall(utc_now()))}(北京时间 UTC+8"
)
payload = {
"msgtype": "text",
"text": {"content": content, "mentioned_mobile_list": self.conf.mentioned_mobile_list},
}
async with httpx.AsyncClient(**self._client_kwargs()) as client:
resp = await client.post(self.conf.webhook, json=payload)
resp.raise_for_status()
@@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Any
import httpx
from .config import Settings
logger = logging.getLogger(__name__)
def build_order_executor_payload(*, inst_id: str, metrics: dict) -> dict[str, Any] | None:
"""
与企微突破文案方案 A一致止盈 = 确认收盘 ± 1 倍箱宽止损 = 突破 K 高低点外侧 stop_buffer_pct默认 0.2%与面板一致
返回 gate_order_executor POST /v1/signal JSON无法构造则 None
"""
signal_side = str(metrics.get("signal_side") or "NONE")
if signal_side not in ("LONG", "SHORT"):
return None
range_high = float(metrics.get("range_high") or 0.0)
range_low = float(metrics.get("range_low") or 0.0)
confirm_close = float(metrics.get("confirm_close") or metrics.get("last_close") or 0.0)
breakout_high = float(metrics.get("breakout_high") or 0.0)
breakout_low = float(metrics.get("breakout_low") or 0.0)
if confirm_close <= 0 or range_high <= range_low:
return None
box_size = range_high - range_low
stop_pct = float(metrics.get("stop_buffer_pct") or 0.2)
stop_pct = max(0.0, min(stop_pct, 10.0))
long_m = 1.0 - stop_pct / 100.0
short_m = 1.0 + stop_pct / 100.0
if signal_side == "LONG":
stop_loss = breakout_low * long_m
take_profit = confirm_close + box_size
else:
stop_loss = breakout_high * short_m
take_profit = confirm_close - box_size
if take_profit <= 0 or stop_loss <= 0:
return None
side = "long" if signal_side == "LONG" else "short"
ct = inst_id.strip().upper()
signal_id = f"scout-{ct}-{uuid.uuid4().hex[:12]}"
return {
"signal_id": signal_id,
"contract": ct,
"side": side,
"take_profit": float(take_profit),
"stop_loss": float(stop_loss),
"reference_price": float(confirm_close),
}
async def _post_one_executor(
*,
name: str,
executor_id: str,
base_url: str,
webhook_secret: str,
timeout_seconds: float,
payload: dict[str, Any],
) -> dict[str, Any]:
url = base_url.rstrip("/") + "/v1/signal"
t = float(timeout_seconds)
timeout = httpx.Timeout(t, connect=min(10.0, t), read=t + 5.0)
try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False, proxy=None) as client:
resp = await client.post(
url,
json=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Secret": webhook_secret,
},
)
try:
body: Any = resp.json()
except Exception: # noqa: BLE001
body = {"_raw": (resp.text or "")[:800]}
ok = resp.is_success
if not ok:
logger.warning(
"order_executor_http_error name=%s status=%s body=%s",
name,
resp.status_code,
body,
)
exec_status = body.get("status") if isinstance(body, dict) else None
return {
"executor_id": executor_id,
"name": name,
"base_url": base_url,
"http_status": resp.status_code,
"body": body,
"ok": ok,
"exec_status": exec_status,
"error": None,
}
except Exception as exc: # noqa: BLE001
logger.warning("order_executor_forward_exception name=%s: %s", name, exc)
return {
"executor_id": executor_id,
"name": name,
"base_url": base_url,
"http_status": 0,
"body": None,
"ok": False,
"exec_status": None,
"error": str(exc),
}
async def forward_signal_to_executors(
settings: Settings,
*,
executors: list[dict[str, Any]],
webhook_secret: str,
timeout_seconds: float,
payload: dict[str, Any],
) -> list[dict[str, Any]]:
"""
向多个执行器广播同一 signal直连 base_url不走 proxy
executors 每项需含 idnamebase_url
"""
secret = (webhook_secret or "").strip()
if not secret:
return []
if not executors:
return []
tasks = [
_post_one_executor(
name=str(ex.get("name") or "executor"),
executor_id=str(ex.get("id") or ""),
base_url=str(ex.get("base_url") or ""),
webhook_secret=secret,
timeout_seconds=timeout_seconds,
payload=payload,
)
for ex in executors
if (ex.get("base_url") or "").strip()
]
if not tasks:
return []
results = await asyncio.gather(*tasks)
return list(results)
async def forward_signal_after_wecom(settings: Settings, payload: dict[str, Any]) -> dict[str, Any]:
"""
兼容旧调用单执行器转发读取 runtime 中第一个 enabled 目标
新代码请使用 forward_signal_to_executors + order_executors_store.read_forward_config
"""
from .order_executors_store import read_forward_config
cfg = read_forward_config(settings)
rows = cfg.get("executors") or []
if not cfg.get("enabled") or not rows:
return {"ok": False, "error": "no_active_executor", "results": []}
results = await forward_signal_to_executors(
settings,
executors=rows[:1],
webhook_secret=str(cfg.get("webhook_secret") or ""),
timeout_seconds=float(cfg.get("timeout_seconds") or 15.0),
payload=payload,
)
one = results[0] if results else {}
return {
"http_status": one.get("http_status"),
"body": one.get("body"),
"ok": one.get("ok"),
"results": results,
}
@@ -0,0 +1,272 @@
"""执行器列表与转发全局设置:runtime/order_executors.json(仅扫描端维护,不支持执行器反向注册)。"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_STORE_PATH = _ROOT / "runtime" / "order_executors.json"
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def _read_unlocked() -> dict[str, Any] | None:
if not _STORE_PATH.is_file():
return None
try:
raw = _STORE_PATH.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("order_executors_read_failed: %s", exc)
return None
def _write_unlocked(data: dict[str, Any]) -> None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
tmp = _STORE_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_STORE_PATH)
def _default_from_settings(settings: Settings) -> dict[str, Any]:
oe = settings.order_executor
executors: list[dict[str, Any]] = []
base = (oe.base_url or "").strip()
if base:
executors.append(
{
"id": str(uuid.uuid4()),
"name": "default",
"base_url": base.rstrip("/"),
"enabled": True,
"created_at": _now_iso(),
"updated_at": _now_iso(),
"last_forward": None,
}
)
return {
"enabled": bool(oe.enabled),
"webhook_secret": str(oe.webhook_secret or ""),
"timeout_seconds": float(oe.timeout_seconds),
"executors": executors,
}
def ensure_store_initialized(settings: Settings) -> None:
"""首次启动:从 config.yaml 的 order_executor 段导入;已有文件则不覆盖。"""
with _lock:
if _read_unlocked() is not None:
return
_write_unlocked(_default_from_settings(settings))
logger.info("order_executors_store_initialized path=%s", _STORE_PATH)
def read_snapshot(settings: Settings) -> dict[str, Any]:
with _lock:
data = _read_unlocked()
if data is None:
ensure_store_initialized(settings)
with _lock:
data = _read_unlocked()
if data is None:
data = _default_from_settings(settings)
return _normalize_snapshot(data, settings)
def _normalize_snapshot(data: dict[str, Any], settings: Settings) -> dict[str, Any]:
oe = settings.order_executor
out: dict[str, Any] = {
"enabled": bool(data.get("enabled", oe.enabled)),
"webhook_secret": str(data.get("webhook_secret") if data.get("webhook_secret") is not None else oe.webhook_secret),
"timeout_seconds": float(data.get("timeout_seconds") or oe.timeout_seconds),
"executors": [],
}
raw_list = data.get("executors")
if isinstance(raw_list, list):
for row in raw_list:
if not isinstance(row, dict):
continue
eid = str(row.get("id") or "").strip() or str(uuid.uuid4())
name = str(row.get("name") or "executor").strip() or "executor"
url = str(row.get("base_url") or "").strip().rstrip("/")
if not url:
continue
out["executors"].append(
{
"id": eid,
"name": name,
"base_url": url,
"enabled": bool(row.get("enabled", True)),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"last_forward": row.get("last_forward") if isinstance(row.get("last_forward"), dict) else None,
}
)
return out
def active_executors(settings: Settings) -> list[dict[str, Any]]:
snap = read_snapshot(settings)
if not snap.get("enabled"):
return []
return [e for e in snap.get("executors") or [] if e.get("enabled")]
def read_forward_config(settings: Settings) -> dict[str, Any]:
snap = read_snapshot(settings)
return {
"enabled": bool(snap.get("enabled")),
"webhook_secret": str(snap.get("webhook_secret") or "").strip(),
"timeout_seconds": float(snap.get("timeout_seconds") or settings.order_executor.timeout_seconds),
"executors": active_executors(settings),
}
def write_global_settings(
settings: Settings,
*,
enabled: bool | None = None,
webhook_secret: str | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
if enabled is not None:
snap["enabled"] = bool(enabled)
if webhook_secret is not None:
snap["webhook_secret"] = str(webhook_secret)
if timeout_seconds is not None:
lo, hi = 3.0, 120.0
v = float(timeout_seconds)
if not (lo <= v <= hi):
raise ValueError(f"timeout_seconds must be in [{lo}, {hi}]")
snap["timeout_seconds"] = v
_write_unlocked(snap)
return read_snapshot(settings)
def _validate_base_url(base_url: str) -> str:
u = (base_url or "").strip().rstrip("/")
if not u:
raise ValueError("base_url_required")
p = urlparse(u)
if p.scheme not in ("http", "https") or not p.netloc:
raise ValueError("base_url_must_be_http_or_https")
return u
def add_executor(
settings: Settings,
*,
name: str,
base_url: str,
enabled: bool = True,
) -> dict[str, Any]:
nm = (name or "").strip() or "executor"
url = _validate_base_url(base_url)
row = {
"id": str(uuid.uuid4()),
"name": nm,
"base_url": url,
"enabled": bool(enabled),
"created_at": _now_iso(),
"updated_at": _now_iso(),
"last_forward": None,
}
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
for ex in snap["executors"]:
if str(ex.get("base_url") or "").rstrip("/") == url:
raise ValueError("base_url_already_exists")
snap["executors"].append(row)
_write_unlocked(snap)
return row
def update_executor(
settings: Settings,
executor_id: str,
*,
name: str | None = None,
base_url: str | None = None,
enabled: bool | None = None,
) -> dict[str, Any]:
eid = (executor_id or "").strip()
if not eid:
raise ValueError("executor_id_required")
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
found: dict[str, Any] | None = None
for ex in snap["executors"]:
if str(ex.get("id")) == eid:
found = ex
break
if not found:
raise ValueError("executor_not_found")
if name is not None:
found["name"] = (name or "").strip() or found.get("name") or "executor"
if base_url is not None:
url = _validate_base_url(base_url)
for ex in snap["executors"]:
if str(ex.get("id")) != eid and str(ex.get("base_url") or "").rstrip("/") == url:
raise ValueError("base_url_already_exists")
found["base_url"] = url
if enabled is not None:
found["enabled"] = bool(enabled)
found["updated_at"] = _now_iso()
_write_unlocked(snap)
return dict(found)
def delete_executor(settings: Settings, executor_id: str) -> None:
eid = (executor_id or "").strip()
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
before = len(snap["executors"])
snap["executors"] = [e for e in snap["executors"] if str(e.get("id")) != eid]
if len(snap["executors"]) == before:
raise ValueError("executor_not_found")
_write_unlocked(snap)
def record_last_forward(
settings: Settings,
executor_id: str,
*,
http_status: int,
ok: bool,
exec_status: str | None,
detail: str | None = None,
) -> None:
eid = (executor_id or "").strip()
with _lock:
snap = _normalize_snapshot(_read_unlocked() or _default_from_settings(settings), settings)
for ex in snap["executors"]:
if str(ex.get("id")) == eid:
ex["last_forward"] = {
"at": _now_iso(),
"http_status": int(http_status),
"ok": bool(ok),
"exec_status": exec_status,
"detail": (detail or "")[:500] or None,
}
ex["updated_at"] = _now_iso()
break
_write_unlocked(snap)
+16
View File
@@ -0,0 +1,16 @@
"""代理 URL 与 httpx 的兼容处理。"""
def httpx_proxy_url(proxy_url: str | None) -> str | None:
"""
将配置中的代理地址转为 httpx 可用的形式
部分环境socksio / httpx不支持 ``socks5h://`` scheme会报
``Unknown scheme for proxy URL``此时退化为 ``socks5://``域名在本机解析后再走 SOCKS
"""
if not proxy_url or not str(proxy_url).strip():
return None
u = str(proxy_url).strip()
if u.startswith("socks5h://"):
return "socks5://" + u[len("socks5h://") :]
return u
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from sqlalchemy import desc, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from .models import AlertRecord, Base, KvStore, RuntimeLog
DEFAULT_CHART_BAR = "1D"
class Storage:
def __init__(self, database_url: str) -> None:
self.engine = create_async_engine(database_url, pool_pre_ping=True)
self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
async def init_db(self) -> None:
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await self._ensure_default_kv()
async def _ensure_default_kv(self) -> None:
current = await self.get_kv("chart_bar")
if current is None:
await self.set_kv("chart_bar", DEFAULT_CHART_BAR)
async def get_kv(self, key: str) -> str | None:
async with self.session_factory() as session:
row = await session.get(KvStore, key)
return row.value if row else None
async def set_kv(self, key: str, value: str) -> None:
async with self.session_factory() as session:
await session.execute(
sqlite_insert(KvStore)
.values(key=key, value=value, updated_at=datetime.utcnow())
.on_conflict_do_update(
index_elements=["key"],
set_={"value": value, "updated_at": datetime.utcnow()},
)
)
await session.commit()
async def has_recent_alert(
self,
symbol: str,
*,
chain: str,
within_hours: float,
) -> bool:
"""同一 symbol + chain 在 within_hours 内是否已有告警(用于去重显示与推送)。"""
if within_hours <= 0:
return False
sym = symbol.strip().upper()
cutoff = datetime.utcnow() - timedelta(hours=within_hours)
async with self.session_factory() as session:
stmt = (
select(AlertRecord.id)
.where(
AlertRecord.symbol == sym,
AlertRecord.chain == chain,
AlertRecord.created_at > cutoff,
)
.limit(1)
)
row = (await session.execute(stmt)).scalar_one_or_none()
return row is not None
async def add_alert(
self,
symbol: str,
venue: str,
trigger_types: list[str],
score: float,
details: dict,
) -> None:
async with self.session_factory() as session:
session.add(
AlertRecord(
symbol=symbol.strip().upper(),
chain=venue,
trigger_types=",".join(trigger_types),
score=score,
details_json=json.dumps(details, ensure_ascii=False),
)
)
await session.commit()
async def add_log(self, level: str, message: str) -> None:
async with self.session_factory() as session:
session.add(RuntimeLog(level=level.upper(), message=message))
await session.commit()
async def get_recent_alerts(self, limit: int = 100) -> list[dict]:
async with self.session_factory() as session:
stmt = select(AlertRecord).order_by(desc(AlertRecord.created_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"symbol": row.symbol,
"chain": row.chain,
"trigger_types": row.trigger_types.split(",") if row.trigger_types else [],
"score": row.score,
"details": json.loads(row.details_json),
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def get_recent_logs(self, limit: int = 200) -> list[dict]:
async with self.session_factory() as session:
stmt = select(RuntimeLog).order_by(desc(RuntimeLog.created_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"level": row.level,
"message": row.message,
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def get_alerts_between(
self,
start_utc_naive: datetime,
end_utc_naive: datetime,
limit: int = 2000,
) -> list[dict]:
async with self.session_factory() as session:
stmt = (
select(AlertRecord)
.where(AlertRecord.created_at >= start_utc_naive, AlertRecord.created_at < end_utc_naive)
.order_by(desc(AlertRecord.created_at))
.limit(limit)
)
rows = (await session.execute(stmt)).scalars().all()
return [
{
"id": row.id,
"symbol": row.symbol,
"chain": row.chain,
"trigger_types": row.trigger_types.split(",") if row.trigger_types else [],
"score": row.score,
"details": json.loads(row.details_json),
"created_at": row.created_at.isoformat(),
}
for row in rows
]
async def close(self) -> None:
await self.engine.dispose()
+21
View File
@@ -0,0 +1,21 @@
"""北京时间(Asia/Shanghai)格式化,用于推送与展示。"""
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
_TZ_CN = ZoneInfo("Asia/Shanghai")
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def format_beijing_wall(dt: datetime | None = None) -> str:
"""与微信示例一致:YYYY-MM-DD HH:MM(北京时间,无时区后缀)。"""
if dt is None:
dt = utc_now()
elif dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_TZ_CN).strftime("%Y-%m-%d %H:%M")
+626
View File
@@ -0,0 +1,626 @@
from __future__ import annotations
import hashlib
import json
import logging
from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from .config import Settings
from .daily_report import DailyReportService
from .gemma_client import OllamaGemmaClient
from .monitor import MonitorService
from .notifier import WeComNotifier
from .gate import GateClient
from .order_executors_store import (
add_executor,
delete_executor,
ensure_store_initialized,
read_snapshot,
update_executor,
write_global_settings,
)
from .storage import Storage
LOGGER = logging.getLogger("onchain_scout.web")
FIXED_BAR = "5m"
DAILY_REPORT_JOB_ID = "daily_report_job"
def _hash_password(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _asset_version(root: Path) -> str:
"""静态资源 ?v= 避免浏览器强缓存旧 app.js。"""
mt = 0
for name in ("app.js", "style.css"):
try:
mt = max(mt, int((root / "static" / name).stat().st_mtime))
except OSError:
continue
return str(mt or 1)
def _dedupe_funnel_alerts_by_symbol(alerts: list[dict]) -> list[dict]:
"""同一币种只保留一条漏斗记录:优先保留 created_at 最新的(避免历史轮次堆叠)。"""
by_time = sorted(alerts, key=lambda x: str(x.get("created_at") or ""), reverse=True)
seen: set[str] = set()
out: list[dict] = []
for a in by_time:
sym = (a.get("symbol") or "").strip().upper()
if not sym or sym in seen:
continue
seen.add(sym)
out.append(a)
return out
def _slim_monitor_state(state) -> dict:
"""避免 monitoring_pool 全量下发(可达上千条),局域网面板极慢。"""
raw = dict(state.__dict__)
pool = list(raw.pop("monitoring_pool", []) or [])
raw["monitoring_pool_count"] = len(pool)
raw["monitoring_pool_preview"] = pool[:50]
return raw
def _parse_hhmm(raw: str) -> tuple[int, int]:
s = (raw or "").strip()
if ":" not in s:
return 8, 30
hh, mm = s.split(":", 1)
try:
h = max(0, min(23, int(hh)))
m = max(0, min(59, int(mm)))
return h, m
except ValueError:
return 8, 30
def _to_bool(raw: str | None, default: bool) -> bool:
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
def _normalize_manual_symbols(raw: object) -> list[str]:
if isinstance(raw, list):
text = "\n".join([str(x) for x in raw])
else:
text = str(raw or "")
out: list[str] = []
for token in text.replace(",", "\n").replace(";", "\n").splitlines():
s = token.strip().upper()
if not s:
continue
if "_USDT" in s:
s = s.split("_USDT", 1)[0]
elif "-USDT-SWAP" in s:
s = s.split("-USDT-SWAP", 1)[0]
elif "-USDT" in s:
s = s.split("-USDT", 1)[0]
s = "".join(ch for ch in s if ch.isalnum())
if not s:
continue
if s not in out:
out.append(s)
return out[:200]
def _normalize_symbol_token(raw: object) -> str:
s = str(raw or "").strip().upper()
if not s:
return ""
if "_USDT" in s:
s = s.split("_USDT", 1)[0]
elif "-USDT-SWAP" in s:
s = s.split("-USDT-SWAP", 1)[0]
elif "-USDT" in s:
s = s.split("-USDT", 1)[0]
s = "".join(ch for ch in s if ch.isalnum())
return s
def create_app(settings: Settings) -> FastAPI:
def require_login(request: Request) -> None:
if not settings.auth.enabled:
return
if request.session.get("logged_in") is not True:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
app = FastAPI(title="MATRIX FUNNEL", version="2.1.0")
app.add_middleware(GZipMiddleware, minimum_size=800)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app.session_secret,
max_age=60 * 60 * 24 * 7,
same_site="lax",
https_only=False,
)
root_dir = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(root_dir / "templates"))
app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static")
storage = Storage(settings.app.database_url)
proxy_url = settings.proxy.url if settings.proxy.enabled else None
gate_client = GateClient(settings.gate, proxy_url=proxy_url)
notifier = WeComNotifier(settings.wecom, proxy_url=None)
gemma_client = OllamaGemmaClient(settings.gemma) if settings.gemma.enabled else None
monitor = MonitorService(
settings=settings,
storage=storage,
gate_client=gate_client,
notifier=notifier,
gemma_client=gemma_client,
)
daily_report = DailyReportService(
settings=settings,
storage=storage,
gate_client=gate_client,
notifier=notifier,
gemma_client=gemma_client,
)
scheduler = AsyncIOScheduler(timezone="UTC")
app.state.settings = settings
app.state.storage = storage
app.state.monitor = monitor
app.state.scheduler = scheduler
app.state.auth_user = settings.auth.username
app.state.auth_password_hash = _hash_password(settings.auth.password)
@app.on_event("startup")
async def on_startup() -> None:
runtime_dir = Path(settings.app.log_file).resolve().parent
runtime_dir.mkdir(parents=True, exist_ok=True)
await storage.init_db()
ensure_store_initialized(settings)
await _ensure_runtime_defaults(storage)
monitor.state.chart_bar = FIXED_BAR
scheduler.add_job(monitor.run_cycle, "interval", seconds=settings.app.poll_interval_seconds, max_instances=1)
dr = await _get_daily_report_settings(storage, settings)
if dr["enabled"]:
hh, mm = _parse_hhmm(str(dr["run_time_cn"]))
scheduler.add_job(
daily_report.run_once,
"cron",
hour=hh,
minute=mm,
max_instances=1,
timezone="Asia/Shanghai",
id=DAILY_REPORT_JOB_ID,
replace_existing=True,
)
scheduler.start()
await monitor.run_cycle()
if dr["enabled"] and dr["run_on_startup"]:
await daily_report.run_once()
await storage.add_log(
"INFO",
(
f"service_started_gate_usdt gemma={'on' if settings.gemma.enabled else 'off'} "
f"proxy={'on ' + settings.proxy.url if settings.proxy.enabled else 'off'} "
f"web_login={'on' if settings.auth.enabled else 'off'} "
f"daily_report={'on' if settings.daily_report.enabled else 'off'}"
),
)
LOGGER.info("Service started")
@app.on_event("shutdown")
async def on_shutdown() -> None:
scheduler.shutdown(wait=False)
await storage.add_log("INFO", "service_stopped")
await storage.close()
@app.get("/", response_class=HTMLResponse)
async def root(request: Request) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
if request.session.get("logged_in") is True:
return RedirectResponse("/dashboard", status_code=302)
return RedirectResponse("/login", status_code=302)
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": ""})
@app.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)) -> HTMLResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
ok_user = username == app.state.auth_user
ok_pass = _hash_password(password) == app.state.auth_password_hash
if ok_user and ok_pass:
request.session["logged_in"] = True
request.session["username"] = username
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": "用户名或密码错误"})
@app.get("/logout")
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
return RedirectResponse("/login", status_code=302)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
if settings.auth.enabled and request.session.get("logged_in") is not True:
return RedirectResponse("/login", status_code=302)
display_name = request.session.get("username") or settings.auth.username or "admin"
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"username": display_name,
"asset_version": _asset_version(root_dir),
},
)
@app.get("/api/status")
async def api_status(_: None = Depends(require_login)) -> JSONResponse:
intraday = await _get_intraday_settings(storage)
return JSONResponse(
{
"running": True,
"state": _slim_monitor_state(monitor.state),
"poll_interval_seconds": settings.app.poll_interval_seconds,
"chart_bar": FIXED_BAR,
"mode": "GATE_USDT_PERP",
"universe": settings.monitor.universe,
"intraday_settings": intraday,
"gemma_enabled": settings.gemma.enabled,
"gemma_model": settings.gemma.model,
}
)
@app.get("/api/settings")
async def api_settings_get(_: None = Depends(require_login)) -> JSONResponse:
intraday = await _get_intraday_settings(storage)
daily = await _get_daily_report_settings(storage, settings)
blocklist = await _get_symbol_blocklist_settings(storage)
return JSONResponse(
{
"chart_bar": FIXED_BAR,
"intraday_settings": intraday,
"daily_report_settings": daily,
"symbol_blocklist_settings": blocklist,
"order_executors": read_snapshot(settings),
}
)
@app.get("/api/order-executors")
async def api_order_executors_get(_: None = Depends(require_login)) -> JSONResponse:
return JSONResponse(read_snapshot(settings))
@app.put("/api/order-executors/settings")
async def api_order_executors_settings(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
try:
snap = write_global_settings(
settings,
enabled=body.get("enabled") if "enabled" in body else None,
webhook_secret=body.get("webhook_secret") if "webhook_secret" in body else None,
timeout_seconds=body.get("timeout_seconds") if "timeout_seconds" in body else None,
)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
await storage.add_log(
"INFO",
(
"order_executors_settings_updated "
f"enabled={snap.get('enabled')} timeout={snap.get('timeout_seconds')} "
f"secret_set={bool((snap.get('webhook_secret') or '').strip())}"
),
)
return JSONResponse({"ok": True, "order_executors": snap})
@app.post("/api/order-executors")
async def api_order_executors_add(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
try:
row = add_executor(
settings,
name=str(body.get("name") or ""),
base_url=str(body.get("base_url") or ""),
enabled=bool(body.get("enabled", True)),
)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
await storage.add_log(
"INFO",
f"order_executor_added name={row.get('name')} url={row.get('base_url')}",
)
return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)})
@app.patch("/api/order-executors/{executor_id}")
async def api_order_executors_patch(
executor_id: str, request: Request, _: None = Depends(require_login)
) -> JSONResponse:
body = await request.json()
try:
row = update_executor(
settings,
executor_id,
name=body.get("name") if "name" in body else None,
base_url=body.get("base_url") if "base_url" in body else None,
enabled=body.get("enabled") if "enabled" in body else None,
)
except ValueError as exc:
code = 404 if str(exc) == "executor_not_found" else 400
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=code)
await storage.add_log("INFO", f"order_executor_updated id={executor_id} name={row.get('name')}")
return JSONResponse({"ok": True, "executor": row, "order_executors": read_snapshot(settings)})
@app.delete("/api/order-executors/{executor_id}")
async def api_order_executors_delete(executor_id: str, _: None = Depends(require_login)) -> JSONResponse:
try:
delete_executor(settings, executor_id)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=404)
await storage.add_log("INFO", f"order_executor_deleted id={executor_id}")
return JSONResponse({"ok": True, "order_executors": read_snapshot(settings)})
@app.post("/api/settings/intraday")
async def api_settings_intraday(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
range_hours = _must_float(body.get("range_hours"), "range_hours")
range_max_pct = _must_float(body.get("range_max_pct"), "range_max_pct")
volume_spike_mult = _must_float(body.get("volume_spike_mult"), "volume_spike_mult")
volume_lookback_bars = int(_must_float(body.get("volume_lookback_bars"), "volume_lookback_bars"))
breakout_buffer_pct = _must_float(body.get("breakout_buffer_pct"), "breakout_buffer_pct")
stop_buffer_pct = _must_float(body.get("stop_buffer_pct"), "stop_buffer_pct")
push_time_window_enabled = _to_bool(body.get("push_time_window_enabled"), True)
if range_hours < 1:
raise HTTPException(status_code=400, detail="range_hours must be >= 1")
if range_max_pct <= 0:
raise HTTPException(status_code=400, detail="range_max_pct must be > 0")
if volume_spike_mult < 1:
raise HTTPException(status_code=400, detail="volume_spike_mult must be >= 1")
if volume_lookback_bars < 5:
raise HTTPException(status_code=400, detail="volume_lookback_bars must be >= 5")
if breakout_buffer_pct < 0:
raise HTTPException(status_code=400, detail="breakout_buffer_pct must be >= 0")
if stop_buffer_pct < 0 or stop_buffer_pct > 10:
raise HTTPException(status_code=400, detail="stop_buffer_pct must be between 0 and 10")
await storage.set_kv("intraday_range_hours", str(range_hours))
await storage.set_kv("intraday_range_max_pct", str(range_max_pct))
await storage.set_kv("intraday_volume_spike_mult", str(volume_spike_mult))
await storage.set_kv("intraday_volume_lookback_bars", str(volume_lookback_bars))
await storage.set_kv("intraday_breakout_buffer_pct", str(breakout_buffer_pct))
await storage.set_kv("intraday_stop_buffer_pct", str(stop_buffer_pct))
await storage.set_kv("intraday_push_time_window_enabled", "1" if push_time_window_enabled else "0")
await storage.add_log(
"INFO",
(
"intraday_settings_updated "
f"range_hours={range_hours} range_max_pct={range_max_pct} "
f"volume_spike_mult={volume_spike_mult} volume_lookback_bars={volume_lookback_bars} "
f"breakout_buffer_pct={breakout_buffer_pct} stop_buffer_pct={stop_buffer_pct} "
f"push_time_window_enabled={push_time_window_enabled}"
),
)
return JSONResponse({"ok": True, "intraday_settings": await _get_intraday_settings(storage)})
@app.post("/api/settings/daily-report")
async def api_settings_daily_report(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
enabled = bool(body.get("enabled", True))
run_time_cn = str(body.get("run_time_cn") or "08:30").strip()
push_wecom = bool(body.get("push_wecom", True))
run_on_startup = bool(body.get("run_on_startup", False))
hh, mm = _parse_hhmm(run_time_cn)
run_time_cn = f"{hh:02d}:{mm:02d}"
await storage.set_kv("daily_report_enabled", "1" if enabled else "0")
await storage.set_kv("daily_report_run_time_cn", run_time_cn)
await storage.set_kv("daily_report_push_wecom", "1" if push_wecom else "0")
await storage.set_kv("daily_report_run_on_startup", "1" if run_on_startup else "0")
if scheduler.get_job(DAILY_REPORT_JOB_ID):
scheduler.remove_job(DAILY_REPORT_JOB_ID)
if enabled:
scheduler.add_job(
daily_report.run_once,
"cron",
hour=hh,
minute=mm,
max_instances=1,
timezone="Asia/Shanghai",
id=DAILY_REPORT_JOB_ID,
replace_existing=True,
)
daily = await _get_daily_report_settings(storage, settings)
await storage.add_log(
"INFO",
(
"daily_report_settings_updated "
f"enabled={daily['enabled']} run_time_cn={daily['run_time_cn']} "
f"push_wecom={daily['push_wecom']} run_on_startup={daily['run_on_startup']}"
),
)
return JSONResponse({"ok": True, "daily_report_settings": daily})
@app.post("/api/settings/symbol-blocklist")
async def api_settings_symbol_blocklist(request: Request, _: None = Depends(require_login)) -> JSONResponse:
body = await request.json()
symbols = _normalize_manual_symbols(body.get("symbols_text", ""))
await storage.set_kv("monitor_symbol_blocklist", json.dumps(symbols, ensure_ascii=False))
await storage.add_log(
"INFO",
f"symbol_blocklist_updated count={len(symbols)} symbols={','.join(symbols[:30])}{'' if len(symbols) > 30 else ''}",
)
return JSONResponse({"ok": True, "symbol_blocklist_settings": await _get_symbol_blocklist_settings(storage)})
@app.get("/api/alerts")
async def api_alerts(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=120)
return JSONResponse({"items": alerts})
@app.get("/api/logs")
async def api_logs(_: None = Depends(require_login)) -> JSONResponse:
logs = await storage.get_recent_logs(limit=120)
return JSONResponse({"items": logs})
@app.get("/api/config")
async def api_config(_: None = Depends(require_login)) -> JSONResponse:
symbols = [{"symbol": w.symbol.upper()} for w in settings.watch_symbols]
g = settings.gemma
dr = await _get_daily_report_settings(storage, settings)
return JSONResponse(
{
"auth_enabled": settings.auth.enabled,
"host": settings.app.host,
"port": settings.app.port,
"poll_interval_seconds": settings.app.poll_interval_seconds,
"universe": settings.monitor.universe,
"min_24h_quote_volume_usdt": settings.monitor.min_24h_quote_volume_usdt,
"btc_daily_gate_enabled": settings.monitor.btc_daily_gate_enabled,
"btc_sideways_lookback_days": settings.monitor.btc_sideways_lookback_days,
"btc_sideways_max_range_pct": settings.monitor.btc_sideways_max_range_pct,
"symbol_signal_dedupe_hours": settings.monitor.symbol_signal_dedupe_hours,
"wecom_push_max_volume_rank": settings.monitor.wecom_push_max_volume_rank,
"gemma": {
"enabled": g.enabled,
"ollama_base_url": g.ollama_base_url,
"model": g.model,
"max_funnel_per_cycle": g.max_funnel_per_cycle,
"vision_top_n": g.vision_top_n,
"gemma_push_priority_min": g.gemma_push_priority_min,
"composite_push_min": g.composite_push_min,
},
"daily_report": dr,
"proxy": {
"enabled": settings.proxy.enabled,
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"order_executor": read_snapshot(settings),
"watch_symbols": symbols,
}
)
@app.get("/api/funnel")
async def api_funnel(_: None = Depends(require_login)) -> JSONResponse:
alerts = await storage.get_recent_alerts(limit=500)
items = [a for a in alerts if (a.get("details") or {}).get("source") == "gemma_funnel"]
items = _dedupe_funnel_alerts_by_symbol(items)
items.sort(
key=lambda x: float((x.get("details") or {}).get("composite_score") or 0.0),
reverse=True,
)
return JSONResponse({"items": items[:100]})
@app.get("/api/daily-report")
async def api_daily_report(_: None = Depends(require_login)) -> JSONResponse:
raw = await storage.get_kv("daily_report_latest")
if not raw:
return JSONResponse(
{
"ready": False,
"message": "晨报尚未生成。请等待定时任务,或开启 daily_report.run_on_startup。",
}
)
try:
obj = json.loads(raw)
except json.JSONDecodeError:
return JSONResponse({"ready": False, "message": "晨报解析失败"}, status_code=500)
return JSONResponse({"ready": True, "report": obj})
@app.post("/api/daily-report/run")
async def api_daily_report_run(_: None = Depends(require_login)) -> JSONResponse:
dr = await _get_daily_report_settings(storage, settings)
if not dr["enabled"]:
return JSONResponse({"ok": False, "message": "daily_report.enabled=false"}, status_code=400)
report = await daily_report.run_once()
return JSONResponse({"ok": True, "report": report})
return app
async def _ensure_runtime_defaults(storage: Storage) -> None:
defaults = {
"intraday_range_hours": "12",
"intraday_range_max_pct": "2.0",
"intraday_volume_spike_mult": "1.6",
"intraday_volume_lookback_bars": "18",
"intraday_breakout_buffer_pct": "0.03",
"intraday_push_time_window_enabled": "1",
"intraday_stop_buffer_pct": "0.2",
"daily_report_enabled": "1",
"daily_report_run_time_cn": "08:30",
"daily_report_push_wecom": "1",
"daily_report_run_on_startup": "0",
}
for key, value in defaults.items():
if await storage.get_kv(key) is None:
await storage.set_kv(key, value)
async def _get_intraday_settings(storage: Storage) -> dict:
return {
"range_hours": _to_float(await storage.get_kv("intraday_range_hours"), 24.0),
"range_max_pct": _to_float(await storage.get_kv("intraday_range_max_pct"), 1.5),
"volume_spike_mult": _to_float(await storage.get_kv("intraday_volume_spike_mult"), 1.6),
"volume_lookback_bars": int(_to_float(await storage.get_kv("intraday_volume_lookback_bars"), 20.0)),
"breakout_buffer_pct": _to_float(await storage.get_kv("intraday_breakout_buffer_pct"), 0.05),
"push_time_window_enabled": _to_bool(await storage.get_kv("intraday_push_time_window_enabled"), True),
"stop_buffer_pct": _to_float(await storage.get_kv("intraday_stop_buffer_pct"), 0.2),
}
async def _get_daily_report_settings(storage: Storage, settings: Settings) -> dict:
return {
"enabled": _to_bool(await storage.get_kv("daily_report_enabled"), settings.daily_report.enabled),
"run_time_cn": str(await storage.get_kv("daily_report_run_time_cn") or settings.daily_report.run_time_cn),
"push_wecom": _to_bool(await storage.get_kv("daily_report_push_wecom"), settings.daily_report.push_wecom),
"run_on_startup": _to_bool(await storage.get_kv("daily_report_run_on_startup"), settings.daily_report.run_on_startup),
}
async def _get_symbol_blocklist_settings(storage: Storage) -> dict:
raw = await storage.get_kv("monitor_symbol_blocklist")
symbols: list[str] = []
if raw and str(raw).strip():
try:
data = json.loads(raw)
if isinstance(data, list):
seen: set[str] = set()
for x in data:
s = str(x).strip().upper()
if s and s not in seen:
seen.add(s)
symbols.append(s)
except json.JSONDecodeError:
symbols = []
return {
"symbols": symbols,
"symbols_text": "\n".join(symbols),
"count": len(symbols),
}
def _to_float(raw: str | None, default: float) -> float:
try:
return float(raw) if raw is not None else default
except (TypeError, ValueError):
return default
def _must_float(raw: object, name: str) -> float:
try:
return float(raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"{name} must be a number")
+73
View File
@@ -0,0 +1,73 @@
app:
host: "0.0.0.0"
port: 8088
poll_interval_seconds: 300
log_file: "./runtime/system.log"
database_url: "sqlite+aiosqlite:///./runtime/alerts.db"
session_secret: "change-me-to-a-long-random-string"
auth:
# 纯局域网可设 false,打开 / 与 /dashboard 不再要求登录(勿在公网关闭)
enabled: true
username: "admin"
password: "Admin@123456"
wecom:
webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace"
mentioned_mobile_list: []
gate:
api_base: "https://api.gateio.ws/api/v4"
settle: "usdt"
quote_currency: "USDT"
# 出站代理:仅 Gate 等外网 httpx;企业微信与本机/局域网 Ollama 不走代理。需 pip 安装 httpx[socks]
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
# 企微「突破预警」推送成功后,向 gate_order_executor POST /v1/signal(与微信同源条件;方案 A 止盈止损)
# 首次启动时从本段导入 runtime/order_executors.json;之后以 Web 面板「下单执行器」为准(可热增删,无需重启)。
# 请求直连各 base_url,不走 proxy。webhook_secret 须与各执行器 security.webhook_secret 一致。
order_executor:
enabled: false
base_url: "http://127.0.0.1:8090"
webhook_secret: "same-as-gate-order-executor-security-webhook_secret"
timeout_seconds: 15
monitor:
universe: "all_swaps"
# 近 24h 估算成交额(USDT)下限,建议 ≥ 1 千万以缩小扫描面
min_24h_quote_volume_usdt: 10000000
# 可选:记录 BTC 日线 regime 供面板参考;不再拦截山寨扫描(推送门控见 monitor 内 8h×15m + 本币4h
btc_daily_gate_enabled: true
btc_sideways_lookback_days: 14
btc_sideways_max_range_pct: 10.0
# 同一币种对同一类告警链 4 小时内只入库 + 推送一次(WATCH/TRIGGER/漏斗分别计数);0 关闭
symbol_signal_dedupe_hours: 4
# 企业微信主推送:仅成交量排名前 N;0 表示不限制
wecom_push_max_volume_rank: 30
# 仅在 universe=watchlist 时使用;all_swaps 下可留空列表
watch_symbols: []
# 本地 Ollama + Gemma 漏斗(扫描命中 → 日线+图 → JSON 打分 → 高优先级企业微信)
gemma:
enabled: true
ollama_base_url: "http://192.168.8.64:11434"
model: "gemma4:e4b"
timeout_seconds: 180
temperature: 0.15
json_mode: true
send_chart_image: true
max_funnel_per_cycle: 12
vision_top_n: 4
gemma_push_priority_min: 7.0
composite_push_min: 72.0
# 每日晨报:北京时间定时生成“昨天复盘 + BTC 方向”,展示于网页并可推送企业微信
daily_report:
enabled: true
run_time_cn: "08:30"
push_wecom: true
run_on_startup: false
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="${1:-/root/onchain_scout_gate}"
cd "$PROJECT_DIR"
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
if [ ! -f config.yaml ]; then
cp config.example.yaml config.yaml
echo "config.yaml created from template, please edit keys/password before start."
fi
mkdir -p runtime
echo "Bootstrap done."
@@ -0,0 +1,37 @@
/**
* PM2 守护 onchain_scout_gateGate USDT 永续行情扫描
*
* 在项目根目录
* pm2 start deploy/ecosystem.config.cjs
* pm2 logs onchain-scout
*
* 监听地址与端口来自 config.yaml app.host / app.portpython -m app.main uvicorn
*/
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const isWin = process.platform === "win32";
const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python"));
module.exports = {
apps: [
{
name: "onchain-scout",
cwd: ROOT,
script: py,
args: ["-m", "app.main"],
interpreter: "none",
autorestart: true,
watch: false,
max_restarts: 15,
min_uptime: "10s",
exp_backoff_restart_delay: 2000,
error_file: path.join(ROOT, "runtime", "pm2-error.log"),
out_file: path.join(ROOT, "runtime", "pm2-out.log"),
merge_logs: true,
time: true,
env: {
PYTHONUNBUFFERED: "1",
},
},
],
};
@@ -0,0 +1,18 @@
[Unit]
Description=Onchain Scout (Gate) via PM2 runtime
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/onchain_scout_gate
Environment=NODE_ENV=production
Environment=PYTHONUNBUFFERED=1
# 需全局安装 pm2npm install -g pm2
# 路径按实际安装位置调整(which pm2-runtime
ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# 前台调试:直接 uvicorn(无守护)。生产环境请用 PM2pm2 start deploy/ecosystem.config.cjs
set -euo pipefail
PROJECT_DIR="${1:-/root/onchain_scout_gate}"
cd "$PROJECT_DIR"
source .venv/bin/activate
exec uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1
@@ -0,0 +1,209 @@
# 多执行器与信号转发 · 设计归档
本文档归档 2026-05 前后关于 **onchain_scout_gate(扫描端)****gate_order_executor(执行器)** 联动的讨论结论与已实现能力,便于后期检阅。
---
## 1. 背景与目标
| 目标 | 说明 |
|------|------|
| **一套信号** | 扫描端在企微突破推送成功后,构造 **一份** 方案 A 止盈/止损 payload |
| **多套账户** | 可向多个执行器进程广播,各绑不同 Gate API,用于盈亏比等规则的对照实验 |
| **规则在执行器** | 最低盈亏比、仓位、移动保本等 **不在扫描端** 区分,由各执行器自行配置 |
| **统一 Webhook** | 全系统使用 **同一个** `webhook_secret` |
| **仅扫描端登记** | 执行器列表 **只能** 在扫描端 Web 面板(及对应 API)维护,**禁止执行器反向注册** |
---
## 2. 架构
```mermaid
flowchart LR
scout[onchain_scout_gate :8088]
scout -->|1 次| wecom[企业微信]
scout -->|同一 payload| exA[执行器 A]
scout -->|同一 payload| exB[执行器 B]
exA --> gateA[Gate 账户 A]
exB --> gateB[Gate 账户 B]
```
- 转发 **不走** 扫描端 `proxy`,直连各 `base_url`(通常 `http://127.0.0.1:8090` 或内网 IP)。
- 各执行器独立进程、独立 `config.yaml`、独立 SQLite;互不通信。
---
## 3. 扫描端实现要点(已实现)
### 3.1 持久化
| 文件 | 内容 |
|------|------|
| `runtime/order_executors.json` | 总开关、`webhook_secret``timeout_seconds`、执行器列表 |
| `config.yaml` `order_executor` | **仅冷启动**:首次无 json 时从 `base_url` / `enabled` / `secret` 导入一条 |
之后以 **面板修改** 为准;改 `config.yaml` **不会** 覆盖已有 json。
### 3.2 执行器列表字段
| 字段 | 说明 |
|------|------|
| `id` | UUID |
| `name` | 展示名(日志、面板) |
| `base_url` | 如 `http://127.0.0.1:8090` |
| `enabled` | 单条开关 |
| `last_forward` | 最近一次转发结果(HTTP、exec_status |
### 3.3 HTTP API(需登录)
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/api/order-executors` | 读取完整配置 |
| PUT | `/api/order-executors/settings` | 总开关、webhook_secret、timeout |
| POST | `/api/order-executors` | 新增 |
| PATCH | `/api/order-executors/{id}` | 改名称/URL/启用 |
| DELETE | `/api/order-executors/{id}` | 删除 |
### 3.4 转发逻辑
1. `build_order_executor_payload()` 仍只构建 **一次**(与企微方案 A 一致)。
2. 对 `enabled=true` 的列表项 **并行** `POST {base_url}/v1/signal`
3. **同一 `signal_id`** 发往所有目标。
4. 部分失败只记日志,不阻断其他执行器。
### 3.5 Web 面板
路径:Dashboard → **「下单执行器 · 转发链」**
- 总开关、Webhook 密钥(可改)、超时
- 添加 / 启用 / 停用 / 删除
- 展示上次转发状态
### 3.6 代码模块
| 文件 | 职责 |
|------|------|
| `app/order_executors_store.py` | 读写 json、CRUD |
| `app/order_executor_forward.py` | 构建 payload、多路 POST |
| `app/monitor.py` | 企微成功后调用转发 |
| `app/web.py` | API + 启动时 `ensure_store_initialized` |
---
## 4. 执行器侧(gate_order_executor
本次 **未改** 执行器业务代码。多账户 = 多实例部署:
| 实例 | 典型差异 |
|------|----------|
| 目录/PM2 名 | 两份 `gate_order_executor` |
| `app.port` | 8090 / 8091 |
| `gate.api_key/secret` | 不同子账户 |
| `security.webhook_secret` | 与扫描端面板 **相同** |
| `risk.*`、移动保本 | 各实例自行实验 |
另见执行器仓库已实现的 **移动保本**(1R 拉至开仓价±0.2%、面板开关、`breakeven_prefs` 等),与多路转发正交。
---
## 5. 部署套数怎么选
| 场景 | 面板操作 |
|------|----------|
| 单账户 | 列表 **1 条** URL |
| 双账户对照 | **2 条** URL,各指向不同端口/机器 |
| 临时只跑一套 | 另一条 `enabled: false` 或关总开关 |
| 完全停止自动下单 | 总开关 `enabled: false` |
---
## 6. 盈亏比对照实验(用法)
1. 扫描端产生同一 `signal_id`、同一 TP/SL。
2. 执行器 A`min_reward_risk_ratio = 1.3` → 可能 `accepted`
3. 执行器 B`min_reward_risk_ratio = 1.8` → 可能 `skipped` / `reward_risk_below_min`
4. 分别在两个执行器面板「信号流」与 Gate 平仓统计中对比结果。
---
## 7. 云服务器关闭代理
### 7.1 何时关闭
- **本机 + 本地 SOCKS**`proxy.enabled: true`
- **境外云、可直连 Gate**`proxy.enabled: false`
### 7.2 扫描端
```yaml
proxy:
enabled: false
```
仅影响 **Gate 行情**;企微、转发执行器本就直连。
### 7.3 执行器(每个实例)
```yaml
proxy:
enabled: false
```
影响 Gate 下单/查仓及(若开启)企微出站。
### 7.4 自检
```bash
curl -I --max-time 15 https://api.gateio.ws
```
### 7.5 文档索引
- `onchain_scout_gate/交易系统部署说明.md` §7、§8
- `onchain_scout_gate/docs/本地部署-SOCKS5代理.md`(本地 SOCKS
- `gate_order_executor/docs/部署说明.md` §6.1
---
## 8. 日志关键字
扫描端运行日志(面板「运行日志」或 `runtime/system.log`):
| 日志前缀 | 含义 |
|----------|------|
| `order_executor_ok name=...` | 该执行器 HTTP 成功 |
| `order_executor_failed name=...` | HTTP 或业务失败 |
| `order_executor_no_active_targets` | 总开关开但无启用条目 |
| `webhook_secret is empty` | 未配置密钥 |
---
## 9. 安全与约束
- **Webhook 密钥** 在面板修改后,须手动同步到 **每一个** 执行器 `security.webhook_secret`
- **勿** 将执行器 `8090/8091` 对公网裸奔;建议仅本机或内网 + 防火墙。
- 执行器 **不会****不能** 向扫描端注册;避免运维混乱。
---
## 10. 变更记录
| 日期 | 内容 |
|------|------|
| 2026-05 | 多执行器运行时存储、面板 CRUD、并行广播、部署与代理文档 |
---
## 11. 相关路径速查
```text
onchain_scout_gate/
runtime/order_executors.json # 执行器列表(面板写入)
app/order_executors_store.py
app/order_executor_forward.py
templates/dashboard.html # 「下单执行器」区块
static/app.js
gate_order_executor/ # 多实例部署,代码无需为多账户改动
config.yaml # 每实例独立 API / risk / proxy
```
@@ -0,0 +1,233 @@
# 本地部署说明(含 SOCKS5 代理 `socks5h://127.0.0.1:1080`
> **云服务器部署**:若主机在境外且可直连 `api.gateio.ws`,请将 `config.yaml``proxy.enabled` 设为 **`false`**,无需 SOCKS。详见 [`交易系统部署说明.md`](../交易系统部署说明.md) §8 与 [`多执行器与信号转发归档.md`](./多执行器与信号转发归档.md) §7。
本文说明如何在**本机**部署 **onchain_scout_gate**Gate USDT 永续监控 + 可选 Gemma 漏斗 + Web 看板),并在访问 Gate 行情、企业微信等外网接口时使用 **本地 SOCKS5 代理**。环境变量统一使用 `**socks5h://127.0.0.1:1080`**(**h** = 主机名在代理端解析,等同 curl 的 `socks5h`,推荐)。端口 `**1080`** 与 Clash / v2rayN / Sing-box 等本地 SOCKS 入站一致。
---
## 1. 前置条件
### 1.1 系统与软件
| 项目 | 说明 |
| ------ | ---------------------------------------------------------------------------- |
| 操作系统 | Windows 10/11 或 Linux / macOS 均可 |
| Python | **3.10+**(推荐 3.11 / 3.12 |
| 代理客户端 | 本机已运行 **SOCKS5** 监听 `**127.0.0.1:1080`**(常见为 Clash / v2rayN 的「本地 SOCKS5 端口」) |
| 浏览器 | 用于打开 `http://127.0.0.1:8088`(或你在 `config.yaml` 中配置的端口) |
### 1.2 代理必须可用(自检)
在启动本服务前,请先确认 **1080 端口 SOCKS5 已连通外网**(否则 Gate 行情请求会超时或 TLS 失败)。
**Windows PowerShell**(若已安装 `curl` 且 curl 支持 socks5h):
```powershell
curl -x socks5h://127.0.0.1:1080 -I "https://api.gateio.ws" --max-time 15
```
期望看到 HTTP 状态行(如 `HTTP/1.1 200``HTTP/2 302` 等),而不是长时间卡住或 `Connection refused`
**说明**`socks5h` 表示把 **DNS 也走代理**(推荐,避免 DNS 污染)。Python 侧下文使用等价思路。
---
## 2. 获取代码与目录
将仓库(或 `onchain_scout` 目录)放到本机任意路径,例如:
- Windows`C:\opt\onchain_scout`
- Linux`/opt/onchain_scout`
下文以 `**onchain_scout` 为项目根目录**(即包含 `config.yaml``requirements.txt``app/` 的那一层)。
---
## 3. Python 虚拟环境
### 3.1 WindowsPowerShell
```powershell
cd C:\opt\onchain_scout
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -U pip
pip install -r requirements.txt
```
若执行策略禁止激活脚本:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
### 3.2 Linux / macOS
```bash
cd /opt/onchain_scout
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
```
### 3.3 SOCKS5 与 httpx
本项目通过 **httpx** 访问 Gate 行情(及按需的企业微信 Webhook)。走 **SOCKS5** 需要安装带 socks 依赖的 httpx。`requirements.txt` 中已使用:
```text
httpx[socks]==0.27.2
```
若你曾单独安装过无 extras 的 `httpx`,请在本虚拟环境中重新执行:
```bash
pip install -r requirements.txt
```
---
## 4. 配置文件 `config.yaml`
1. 若不存在,从示例复制:
```bash
copy config.example.yaml config.yaml
```
2. 至少修改:
- `auth.username` / `auth.password`:登录 Web 看板用(生产环境请使用强密码)。
- `wecom.webhook`:企业微信群机器人 Webhook(若暂不用推送,可先填占位,但**触发类推送**仍可能失败,可先关相关逻辑或接受报错日志)。
- `monitor.min_24h_quote_volume_usdt`:成交额过滤(默认一千万量级,按 README 说明)。
- `**gemma`**:若本机已装 Ollama 且要跑漏斗,将 `enabled: true` 并设置 `model`;否则保持 `enabled: false`
- `**proxy**`:访问 Gate 行情需走本机 SOCKS 时,设 `proxy.enabled: true``proxy.url` 一般为 `**socks5h://127.0.0.1:1080**`(与 Clash 等本地 SOCKS 端口一致)。**本机 Ollama 不会使用该代理**。
3. **不要将** `config.yaml` **提交到公开仓库**(内含密钥与 Webhook)。
---
## 5. 代理写入 `config.yaml`(推荐,无需环境变量)
`config.yaml` 根级增加或修改 `**proxy`** 段(与 `config.example.yaml` 一致):
```yaml
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
```
说明:
- `**socks5h://**`:配置里可继续写(与 curl 习惯一致)。程序在创建 httpx 客户端时会**自动改成 `socks5://`**,因部分环境下 httpx/socksio 不认 `socks5h` 会报 `Unknown scheme`;改为 `socks5` 后由**本机解析 DNS** 再走 SOCKS。若仍异常,可直接在配置里写 `**socks5://127.0.0.1:1080`**。
- **作用范围****Gate 行情**相关 httpx 请求使用 `config.yaml` 中的 `proxy`;企业微信当前实现为直连。**不会**对 `gemma.ollama_base_url`(本机 Ollama)套代理。
- `**proxy.enabled: false`**:Gate 客户端仍可使用系统环境变量中的 `HTTP_PROXY` / `ALL_PROXY``trust_env=True`);启用配置代理后则**固定走 `proxy.url`**,并 `trust_env=False`,避免与环境变量冲突。
---
## 6. PM2 守护进程(推荐)
### 6.1 安装 PM2
需已安装 **Node.js**,然后全局安装 PM2
```bash
npm install -g pm2
```
### 6.2 准备虚拟环境与配置
1. 项目根目录已创建 `.venv``pip install -r requirements.txt` 完成。
2. `config.yaml` 已按上文填写(含 `proxy``app.port` 等)。
3. 确保存在目录 `**runtime/**`(用于日志与 SQLite;首次启动会自动创建亦可)。
### 6.3 使用仓库内 `ecosystem` 启动
仓库提供 `**deploy/ecosystem.config.cjs**`
- `**cwd**`:自动设为项目根(`deploy` 的上一级)。
- `**script**`:根据操作系统选择 `**.venv/Scripts/python.exe`Windows** 或 `**.venv/bin/python`Linux/macOS**。
- `**args`**`python -m app.main`**监听地址与端口完全由 `config.yaml``app.host` / `app.port` 决定**,无需改 ecosystem 里的端口。
在项目根目录执行:
```bash
cd /opt/onchain_scout # 或你的实际路径
pm2 start deploy/ecosystem.config.cjs
pm2 status
pm2 logs onchain-scout
```
常用维护命令:
| 命令 | 说明 |
| --------------------------- | --------------------------- |
| `pm2 restart onchain-scout` | 热重启(改 `config.yaml` 后需重启生效) |
| `pm2 stop onchain-scout` | 停止 |
| `pm2 delete onchain-scout` | 从进程列表移除 |
| `pm2 save` | 保存当前进程列表 |
| `pm2 startup` | 生成开机自启脚本(按屏幕提示执行一次) |
标准输出与错误会写入项目 `**runtime/pm2-out.log**``**runtime/pm2-error.log**`(见 ecosystem 内配置)。
### 6.4 Windows 说明
**PowerShell****cmd** 中同样可使用 `pm2 start deploy\ecosystem.config.cjs`。若 `python.exe` 路径不对,请确认虚拟环境目录名为 `.venv` 且位于项目根。
---
## 7. 前台启动(调试用)
不经过 PM2、仅本地调试时,**无需**再设置 `ALL_PROXY` 等环境变量(代理已由 `config.yaml``proxy` 段控制):
```powershell
cd C:\opt\onchain_scout
.\.venv\Scripts\Activate.ps1
python -m app.main
```
`app.host` / `app.port``config.yaml` 为准。
---
## 8. 验证
1. **代理**:本机 SOCKS 入站已监听;`config.yaml``proxy.enabled: true`
2. **Web**:浏览器访问 `http://127.0.0.1:<app.port>`,登录后「监控池配置」JSON 中应出现 `**proxy`** 字段(`enabled` / `url`)。
3. **Gate**:等待一个 `poll_interval` 周期,看日志是否仍有 TLS/连接错误;若有,尝试 `socks5` 或检查端口。
4. **Ollama**`gemma.enabled: true` 时,访问 `127.0.0.1:11434` **不**走 `proxy.url`,一般无需 `NO_PROXY`
---
## 9. 常见问题(FAQ
### Q1`Connection refused` 连 Gate / 代理
- 本机代理未开或端口不是 **1080**
- `proxy.enabled` 未设为 `true``proxy.url` 写错。
### Q2:改了 `config.yaml` 不生效
- PM2 下需执行 `**pm2 restart onchain-scout`** 重新加载进程与配置。
### Q3:企业微信推送失败
- 多为 Webhook 无效;若走代理仍失败,检查代理是否允许访问 `qyapi.weixin.qq.com`
### Q4pip / git 走代理
- 与应用程序无关;可在安装依赖的终端自行 `export HTTPS_PROXY=...`**不必**写进应用 `config.yaml`)。
---
## 10. 备忘
```bash
cd /opt/onchain_scout
pm2 start deploy/ecosystem.config.cjs && pm2 save
```
---
文档版本:与仓库 `onchain_scout_gate` 当前结构对应;请以 `README.md``deploy/ecosystem.config.cjs``app/main.py` 为准。
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx[socks]==0.27.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-multipart==0.0.9
itsdangerous==2.2.0
jinja2==3.1.4
apscheduler==3.10.4
sqlalchemy==2.0.35
aiosqlite==0.20.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.1
pyyaml==6.0.2
matplotlib==3.9.2
+2
View File
@@ -0,0 +1,2 @@
from app.main import app # noqa: F401
@@ -0,0 +1,428 @@
from __future__ import annotations
import argparse
import csv
from collections import deque
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
@dataclass
class Bar:
ts: str
open: float
high: float
low: float
close: float
@dataclass
class Trade:
side: str # LONG | SHORT
entry_ts: str
entry_price: float
exit_ts: str
exit_price: float
reason: str
gross_return_pct: float
net_return_pct: float
@dataclass
class BarWithEpoch:
bar: Bar
ts_epoch: int
def _to_float(row: dict[str, str], key: str) -> float:
raw = (row.get(key) or "").strip()
if not raw:
raise ValueError(f"Empty numeric field: {key}")
return float(raw)
def _read_csv(
csv_path: Path,
ts_col: str,
open_col: str,
high_col: str,
low_col: str,
close_col: str,
) -> list[Bar]:
bars: list[Bar] = []
with csv_path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
needed = {ts_col, open_col, high_col, low_col, close_col}
missing = [c for c in needed if c not in (reader.fieldnames or [])]
if missing:
raise ValueError(f"CSV 缺少列: {missing}. 当前列: {reader.fieldnames}")
for row in reader:
bars.append(
Bar(
ts=str(row[ts_col]),
open=_to_float(row, open_col),
high=_to_float(row, high_col),
low=_to_float(row, low_col),
close=_to_float(row, close_col),
)
)
if len(bars) < 200:
raise ValueError(f"数据量过少: {len(bars)} 行,无法可靠回测。")
return bars
def _parse_ts_to_epoch_seconds(ts_raw: str) -> int:
s = str(ts_raw).strip()
if not s:
raise ValueError("timestamp is empty")
if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
n = int(s)
# 13 digits => milliseconds
if abs(n) >= 10_000_000_000:
return int(n / 1000)
return n
s_norm = s.replace("Z", "+00:00")
try:
return int(datetime.fromisoformat(s_norm).timestamp())
except ValueError as exc:
raise ValueError(f"Unsupported timestamp format: {s}") from exc
def _sort_and_attach_epoch(bars: list[Bar]) -> list[BarWithEpoch]:
enriched = [BarWithEpoch(bar=b, ts_epoch=_parse_ts_to_epoch_seconds(b.ts)) for b in bars]
enriched.sort(key=lambda x: x.ts_epoch)
return enriched
def _aggregate_bars(bars_1m: list[Bar], timeframe_minutes: int) -> list[Bar]:
if timeframe_minutes <= 1:
return bars_1m
src = _sort_and_attach_epoch(bars_1m)
if not src:
return []
out: list[Bar] = []
bucket_sec = timeframe_minutes * 60
cur_bucket = None
agg_open = agg_high = agg_low = agg_close = 0.0
agg_ts = ""
for item in src:
b = item.bar
bucket = (item.ts_epoch // bucket_sec) * bucket_sec
if cur_bucket is None or bucket != cur_bucket:
if cur_bucket is not None:
out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close))
cur_bucket = bucket
agg_open = b.open
agg_high = b.high
agg_low = b.low
agg_close = b.close
agg_ts = datetime.utcfromtimestamp(bucket).isoformat() + "Z"
else:
agg_high = max(agg_high, b.high)
agg_low = min(agg_low, b.low)
agg_close = b.close
if cur_bucket is not None:
out.append(Bar(ts=agg_ts, open=agg_open, high=agg_high, low=agg_low, close=agg_close))
return out
def _parse_timeframe_to_minutes(tf: str) -> int:
s = tf.strip().lower()
if s.endswith("m"):
return int(s[:-1])
if s.endswith("h"):
return int(s[:-1]) * 60
raise ValueError(f"Unsupported timeframe: {tf}. Use like 15m,30m,1h")
def _calc_stats(equity_curve: Iterable[float], trades: list[Trade], initial_capital: float) -> dict[str, float]:
curve = list(equity_curve)
if not curve:
return {}
final_capital = curve[-1]
total_return_pct = (final_capital / initial_capital - 1.0) * 100.0
peak = curve[0]
max_dd = 0.0
for eq in curve:
if eq > peak:
peak = eq
dd = (eq / peak - 1.0) * 100.0
if dd < max_dd:
max_dd = dd
wins = [t for t in trades if t.net_return_pct > 0]
losses = [t for t in trades if t.net_return_pct <= 0]
win_rate = (len(wins) / len(trades) * 100.0) if trades else 0.0
avg_win = sum(t.net_return_pct for t in wins) / len(wins) if wins else 0.0
avg_loss = sum(t.net_return_pct for t in losses) / len(losses) if losses else 0.0
profit_factor = (
abs(sum(t.net_return_pct for t in wins) / sum(t.net_return_pct for t in losses))
if losses and sum(t.net_return_pct for t in losses) != 0
else 0.0
)
return {
"initial_capital": initial_capital,
"final_capital": final_capital,
"total_return_pct": total_return_pct,
"max_drawdown_pct": max_dd,
"total_trades": float(len(trades)),
"win_rate_pct": win_rate,
"avg_win_pct": avg_win,
"avg_loss_pct": avg_loss,
"profit_factor": profit_factor,
}
def run_backtest(
bars: list[Bar],
box_len: int,
buf_pct: float,
min_box_pct: float,
sl_pct: float,
tp_pct: float,
commission_pct: float,
initial_capital: float,
) -> tuple[list[Trade], list[float]]:
high_win: deque[float] = deque(maxlen=box_len)
low_win: deque[float] = deque(maxlen=box_len)
close_hist: list[float] = []
trades: list[Trade] = []
equity_curve: list[float] = [initial_capital]
capital = initial_capital
position = 0 # 1 long, -1 short, 0 flat
entry_price = 0.0
entry_ts = ""
for i, bar in enumerate(bars):
# Build history first
close_hist.append(bar.close)
if i == 0:
high_win.append(bar.high)
low_win.append(bar.low)
continue
# Exit check (intrabar, after entry bar)
if position != 0:
if position == 1:
stop = entry_price * (1 - sl_pct / 100.0)
take = entry_price * (1 + tp_pct / 100.0)
exit_price = 0.0
reason = ""
# Conservative tie-break: stop first if both touched same bar
if bar.low <= stop:
exit_price, reason = stop, "SL"
elif bar.high >= take:
exit_price, reason = take, "TP"
if reason:
gross_ret = (exit_price / entry_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(
Trade("LONG", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret)
)
equity_curve.append(capital)
position = 0
elif position == -1:
stop = entry_price * (1 + sl_pct / 100.0)
take = entry_price * (1 - tp_pct / 100.0)
exit_price = 0.0
reason = ""
if bar.high >= stop:
exit_price, reason = stop, "SL"
elif bar.low <= take:
exit_price, reason = take, "TP"
if reason:
gross_ret = (entry_price / exit_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(
Trade("SHORT", entry_ts, entry_price, bar.ts, exit_price, reason, gross_ret, net_ret)
)
equity_curve.append(capital)
position = 0
# Need full lookback and previous close for crossover.
if len(high_win) < box_len or len(low_win) < box_len or i < 2:
high_win.append(bar.high)
low_win.append(bar.low)
continue
box_high = max(high_win)
box_low = min(low_win)
box_mid = (box_high + box_low) / 2.0
box_pct = ((box_high - box_low) / box_mid * 100.0) if box_mid > 0 else 0.0
box_ok = box_pct >= min_box_pct
up_line = box_high * (1 + buf_pct / 100.0)
dn_line = box_low * (1 - buf_pct / 100.0)
prev_close = close_hist[-2]
long_trig = box_ok and prev_close <= up_line and bar.close > up_line
short_trig = box_ok and prev_close >= dn_line and bar.close < dn_line
# Reverse signal close at close price then flip.
if position == 1 and short_trig:
gross_ret = (bar.close / entry_price - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade("LONG", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret))
equity_curve.append(capital)
position = 0
elif position == -1 and long_trig:
gross_ret = (entry_price / bar.close - 1.0) * 100.0
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade("SHORT", entry_ts, entry_price, bar.ts, bar.close, "REVERSE", gross_ret, net_ret))
equity_curve.append(capital)
position = 0
if position == 0:
if long_trig:
position = 1
entry_price = bar.close
entry_ts = bar.ts
elif short_trig:
position = -1
entry_price = bar.close
entry_ts = bar.ts
high_win.append(bar.high)
low_win.append(bar.low)
# Force close at final close
if position != 0:
last = bars[-1]
if position == 1:
gross_ret = (last.close / entry_price - 1.0) * 100.0
side = "LONG"
else:
gross_ret = (entry_price / last.close - 1.0) * 100.0
side = "SHORT"
net_ret = gross_ret - 2 * commission_pct
capital *= 1 + net_ret / 100.0
trades.append(Trade(side, entry_ts, entry_price, last.ts, last.close, "FORCE_CLOSE", gross_ret, net_ret))
equity_curve.append(capital)
return trades, equity_curve
def _save_trades(path: Path, trades: list[Trade]) -> None:
with path.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(
[
"side",
"entry_ts",
"entry_price",
"exit_ts",
"exit_price",
"reason",
"gross_return_pct",
"net_return_pct",
]
)
for t in trades:
w.writerow(
[
t.side,
t.entry_ts,
f"{t.entry_price:.8f}",
t.exit_ts,
f"{t.exit_price:.8f}",
t.reason,
f"{t.gross_return_pct:.6f}",
f"{t.net_return_pct:.6f}",
]
)
def main() -> None:
parser = argparse.ArgumentParser(description="ETH 1m 裸K箱体突破回测")
parser.add_argument("--csv", required=True, help="K线 CSV 路径")
parser.add_argument("--ts-col", default="timestamp", help="时间列名")
parser.add_argument("--open-col", default="open", help="开盘列名")
parser.add_argument("--high-col", default="high", help="最高列名")
parser.add_argument("--low-col", default="low", help="最低列名")
parser.add_argument("--close-col", default="close", help="收盘列名")
parser.add_argument("--box-len", type=int, default=80, help="箱体回看K数")
parser.add_argument("--buf-pct", type=float, default=0.03, help="突破缓冲百分比")
parser.add_argument("--min-box-pct", type=float, default=1.5, help="最小箱体宽度百分比")
parser.add_argument("--sl-pct", type=float, default=0.8, help="止损百分比")
parser.add_argument("--tp-pct", type=float, default=2.4, help="止盈百分比")
parser.add_argument("--commission-pct", type=float, default=0.05, help="单边手续费百分比")
parser.add_argument("--capital", type=float, default=100000.0, help="初始资金")
parser.add_argument("--out", default="runtime/backtest_trades.csv", help="交易明细输出路径")
parser.add_argument(
"--timeframes",
default="15m,30m,1h",
help="回测周期,逗号分隔;会从1m聚合,如: 15m,30m,1h",
)
args = parser.parse_args()
csv_path = Path(args.csv).expanduser().resolve()
if not csv_path.exists():
raise FileNotFoundError(f"CSV 不存在: {csv_path}")
bars = _read_csv(
csv_path,
ts_col=args.ts_col,
open_col=args.open_col,
high_col=args.high_col,
low_col=args.low_col,
close_col=args.close_col,
)
tfs = [x.strip() for x in str(args.timeframes).split(",") if x.strip()]
if not tfs:
raise ValueError("timeframes 不能为空")
base_out = Path(args.out).expanduser().resolve()
base_out.parent.mkdir(parents=True, exist_ok=True)
print("=== Backtest Done (1m聚合多周期) ===")
print(f"source_1m_bars: {len(bars)}")
print(f"source_period: {bars[0].ts} -> {bars[-1].ts}")
print("")
for tf in tfs:
minutes = _parse_timeframe_to_minutes(tf)
agg = _aggregate_bars(bars, minutes)
trades, curve = run_backtest(
bars=agg,
box_len=args.box_len,
buf_pct=args.buf_pct,
min_box_pct=args.min_box_pct,
sl_pct=args.sl_pct,
tp_pct=args.tp_pct,
commission_pct=args.commission_pct,
initial_capital=args.capital,
)
stats = _calc_stats(curve, trades, args.capital)
out_path = base_out.with_name(f"{base_out.stem}_{tf}{base_out.suffix}")
_save_trades(out_path, trades)
print(f"[{tf}] bars={len(agg)} trades={int(stats.get('total_trades', 0))}")
print(f" period: {agg[0].ts} -> {agg[-1].ts}")
print(f" final_capital: {stats.get('final_capital', 0):.2f}")
print(f" total_return: {stats.get('total_return_pct', 0):.2f}%")
print(f" max_drawdown: {stats.get('max_drawdown_pct', 0):.2f}%")
print(f" win_rate: {stats.get('win_rate_pct', 0):.2f}%")
print(f" profit_factor: {stats.get('profit_factor', 0):.3f}")
print(f" trades_csv: {out_path}")
print("")
print(f"generated_at: {datetime.now().isoformat(timespec='seconds')}")
if __name__ == "__main__":
main()
+620
View File
@@ -0,0 +1,620 @@
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
credentials: "same-origin",
cache: "no-store",
...options,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
function pretty(data) {
return JSON.stringify(data, null, 2);
}
function renderItems(containerId, rows, rowRenderer) {
const target = document.getElementById(containerId);
if (!target) return;
target.innerHTML = "";
rows.forEach((row) => {
const el = document.createElement("div");
el.className = "item matrix-list-item";
el.innerHTML = rowRenderer(row);
target.appendChild(el);
});
}
function setInput(id, value) {
const el = document.getElementById(id);
if (el) el.value = value;
}
function setTextareaValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value != null ? String(value) : "";
}
function getTextareaValue(id) {
const el = document.getElementById(id);
return el ? String(el.value || "") : "";
}
function setCheck(id, value) {
const el = document.getElementById(id);
if (el) el.checked = !!value;
}
function getInputNumber(id) {
return Number(document.getElementById(id).value);
}
function getInputText(id) {
const el = document.getElementById(id);
return el ? String(el.value || "").trim() : "";
}
function getInputCheck(id) {
const el = document.getElementById(id);
return !!(el && el.checked);
}
/** SQLite 常为无时区 naive UTC,补 Z 再解析,避免浏览器当成本地时区 */
function normalizeUtcIsoString(iso) {
if (typeof iso !== "string") return iso;
const s = iso.trim();
if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) return `${s}Z`;
return s;
}
/** ISO 8601 → 北京时间展示 */
function formatIsoToBeijing(iso) {
if (!iso || typeof iso !== "string") return "—";
const t = Date.parse(normalizeUtcIsoString(iso));
if (Number.isNaN(t)) return iso;
const s = new Date(t).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
return s.replace("T", " ");
}
function tickClock() {
const el = document.getElementById("liveClock");
if (!el) return;
const s = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
el.textContent = s.replace("T", " ") + " 北京时间 (UTC+8)";
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function updateHud(status) {
const st = (status && status.state) || {};
setText("hudLink", "ONLINE");
setText("hudCycle", st.last_cycle_status || "—");
{
const env = st.btc_env_8h_15m || "—";
const daily = st.btc_gate_regime && st.btc_gate_regime !== "disabled" ? st.btc_gate_regime : "";
setText("hudBtc", daily ? `${env} · ${daily}` : env);
}
const pool = st.monitoring_pool_count;
setText("hudPool", pool != null ? String(pool) : "—");
setText("hudPush", st.pushed_alerts_count != null ? String(st.pushed_alerts_count) : "—");
const blChip = document.getElementById("symbolBlocklistCountChip");
if (blChip && st.symbol_blocklist_count != null) {
blChip.textContent = `${st.symbol_blocklist_count} 条规则`;
}
const lastRaw = st.last_cycle_at || st.last_cycle_msg || "—";
setText("hudLast", st.last_cycle_at ? `${formatIsoToBeijing(st.last_cycle_at)}(北京时间)` : lastRaw);
const gemOn = status && status.gemma_enabled;
const nFun = Array.isArray(st.last_funnel) ? st.last_funnel.length : 0;
const model = (status && status.gemma_model) || "";
let gLine = "—";
if (gemOn === false) {
gLine = "配置未开启";
} else if (gemOn === true) {
const msg = (st.gemma_cycle_msg || "").trim();
gLine = msg ? `${msg} · 记忆体${nFun}` : `${model || "ollama"} · 记忆体${nFun}`;
}
setText("hudGemma", gLine);
}
function renderFunnel(items, funnelCtx) {
const root = document.getElementById("funnelMatrix");
if (!root) return;
root.innerHTML = "";
const ctx = funnelCtx || {};
const gemmaOn = !!ctx.gemmaEnabled;
const cycleMsg = String(ctx.cycleMsg || "").trim();
const lastAt = String(ctx.lastFunnelAt || "").trim();
if (!items.length) {
const empty = document.createElement("div");
empty.className = "matrix-hint matrix-hint-empty";
let why =
"// 暂无漏斗记录:本面板只展示 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。";
if (!gemmaOn) {
why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。";
} else if (cycleMsg === "funnel_pending") {
why += " 状态 <strong>funnel_pending</strong>Gemma 在后台跑,完成后此处会出现卡片。";
} else if (cycleMsg === "no_funnel_candidates") {
why += " 本轮扫描无 WATCH/TRIGGER,无漏斗输入。";
} else if (cycleMsg === "gemma_client_none") {
why += " 服务未挂载 Gemma 客户端(检查配置并重启)。";
} else if (cycleMsg && cycleMsg.startsWith("funnel_failed")) {
why += ` 最近错误:<code>${escapeHtml(cycleMsg)}</code>`;
} else if (cycleMsg) {
why += ` 运行时:<code>${escapeHtml(cycleMsg)}</code>`;
}
if (lastAt) {
why += ` <span class="matrix-dim">last_funnel_at: ${escapeHtml(lastAt)}</span>`;
}
empty.innerHTML = `<span class='matrix-empty-icon'>◇</span> ${why}`;
root.appendChild(empty);
return;
}
items.forEach((a) => {
const d = a.details || {};
const g = d.gemma || {};
const comp = Number(d.composite_score || 0);
const pushed = !!d.priority_push;
const card = document.createElement("article");
card.className = "matrix-card" + (pushed ? " hot" : "");
const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—";
card.innerHTML = `
<div class="matrix-card-title">${a.symbol}</div>
<div class="matrix-card-meta">
COMPOSITE <strong>${comp.toFixed(1)}</strong> · P${g.priority || "?"} ·
结构 ${g.daily_structure || "?"} · ${g.volume_view || "?"} ·
上方 ${g.upside_space || "?"} · 阻力 ${g.mid_resistance || "?"}
</div>
<div class="matrix-bar-wrap"><div class="matrix-bar" style="width:${Math.min(100, comp)}%"></div></div>
<div class="matrix-card-line">${escapeHtml(g.one_liner || "")}</div>
<div class="matrix-card-meta">24h 估算 USDT: ${vol} · : ${d.image_sent ? "Y" : "N"}</div>
<span class="matrix-badge ${pushed ? "push" : ""}">${pushed ? "已优先推送" : "未达推送阈值"}</span>
`;
root.appendChild(card);
});
}
function renderDailyReport(payload) {
const root = document.getElementById("dailyReportBox");
const meta = document.getElementById("dailyReportMeta");
if (!root || !meta) return;
root.innerHTML = "";
if (!payload || !payload.ready || !payload.report) {
meta.textContent = `// ${payload && payload.message ? payload.message : "晨报暂不可用"}`;
root.innerHTML =
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> // 晨报会按北京时间定时生成,也可点“立即生成”。</div>";
return;
}
const r = payload.report;
const t = r.text || {};
const b = r.btc || {};
const s = r.stats || {};
const risks = Array.isArray(t.risk_points) ? t.risk_points : [];
meta.textContent =
`// 复盘日 ${r.report_day_cn || "—"} 生成 ${r.generated_at_cn || "—"} AI ${r.ai_used ? "on" : "fallback"} BTC ${b.direction || "—"}`;
const riskHtml = risks.map((x) => `<div>• ${escapeHtml(String(x))}</div>`).join("");
root.innerHTML = `
<div class="item matrix-list-item">
<div class="matrix-row-title"><strong>${escapeHtml(t.headline || "每日晨报")}</strong></div>
<div>BTC: ${escapeHtml(String(b.direction || "—"))} · 日涨跌 ${escapeHtml(String(b.day_change_pct ?? "—"))}% · SMA20 ${escapeHtml(String(b.sma20 ?? "—"))} · SMA60 ${escapeHtml(String(b.sma60 ?? "—"))}</div>
<div>统计: WATCH ${escapeHtml(String(s.watch_count ?? 0))} / TRIGGER ${escapeHtml(String(s.trigger_count ?? 0))} / 漏斗优先 ${escapeHtml(String(s.funnel_push_count ?? 0))}</div>
<div>方向说明: ${escapeHtml(t.btc_explain || "—")}</div>
<div>总结: ${escapeHtml(t.summary || "—")}</div>
<div>风险点:</div>
<div>${riskHtml || "• —"}</div>
<div>执行提示: ${escapeHtml(t.action_hint || "—")}</div>
<div class="time">${escapeHtml(String(r.generated_at_cn || "—"))}北京时间</div>
</div>
`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadIntradaySettings() {
const data = await fetchJson("/api/settings");
const s = data.intraday_settings || {};
setInput("rangeHoursInput", s.range_hours ?? 24);
setInput("rangeMaxPctInput", s.range_max_pct ?? 1.5);
setInput("volumeSpikeMultInput", s.volume_spike_mult ?? 1.6);
setInput("volumeLookbackInput", s.volume_lookback_bars ?? 20);
setInput("breakoutBufferInput", s.breakout_buffer_pct ?? 0.05);
setInput("stopBufferPctInput", s.stop_buffer_pct ?? 0.2);
setCheck("pushTimeWindowEnabledInput", s.push_time_window_enabled ?? true);
const b = data.symbol_blocklist_settings || {};
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
const chip = document.getElementById("symbolBlocklistCountChip");
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
}
async function loadDailyReportSettings() {
const data = await fetchJson("/api/settings");
const d = data.daily_report_settings || {};
setCheck("dailyReportEnabledInput", d.enabled ?? true);
setInput("dailyReportTimeInput", d.run_time_cn ?? "08:30");
setCheck("dailyReportPushInput", d.push_wecom ?? true);
setCheck("dailyReportStartupInput", d.run_on_startup ?? false);
}
async function saveIntradaySettings() {
const msg = document.getElementById("intradaySaveMsg");
if (!msg) return;
msg.textContent = "写入中…";
try {
const payload = {
range_hours: getInputNumber("rangeHoursInput"),
range_max_pct: getInputNumber("rangeMaxPctInput"),
volume_spike_mult: getInputNumber("volumeSpikeMultInput"),
volume_lookback_bars: getInputNumber("volumeLookbackInput"),
breakout_buffer_pct: getInputNumber("breakoutBufferInput"),
stop_buffer_pct: getInputNumber("stopBufferPctInput"),
push_time_window_enabled: getInputCheck("pushTimeWindowEnabledInput"),
};
await fetchJson("/api/settings/intraday", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
msg.textContent = "// 已写入,下一轮监控生效";
} catch (error) {
msg.textContent = `// 失败 ${error}`;
}
}
async function saveSymbolBlocklistSettings() {
const msg = document.getElementById("symbolBlocklistSaveMsg");
if (msg) msg.textContent = "写入中…";
try {
const payload = { symbols_text: getTextareaValue("symbolBlocklistInput") };
const data = await fetchJson("/api/settings/symbol-blocklist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const b = data.symbol_blocklist_settings || {};
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
const chip = document.getElementById("symbolBlocklistCountChip");
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
if (msg) msg.textContent = "// 已写入,下一轮监控生效";
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function runDailyReportNow() {
const meta = document.getElementById("dailyReportMeta");
if (meta) meta.textContent = "// 手动生成中…";
try {
const data = await fetchJson("/api/daily-report/run", { method: "POST" });
renderDailyReport({ ready: true, report: data.report || null });
} catch (error) {
if (meta) meta.textContent = `// 手动生成失败: ${error}`;
}
}
async function saveDailyReportSettings() {
const msg = document.getElementById("dailyReportSaveMsg");
if (msg) msg.textContent = "写入中…";
try {
const payload = {
enabled: getInputCheck("dailyReportEnabledInput"),
run_time_cn: getInputText("dailyReportTimeInput") || "08:30",
push_wecom: getInputCheck("dailyReportPushInput"),
run_on_startup: getInputCheck("dailyReportStartupInput"),
};
await fetchJson("/api/settings/daily-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (msg) msg.textContent = "// 已写入晨报配置,定时任务已更新";
} catch (error) {
if (msg) msg.textContent = `// 写入失败 ${error}`;
}
}
async function refresh() {
try {
const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([
fetchJson("/api/status"),
fetchJson("/api/alerts"),
fetchJson("/api/logs"),
fetchJson("/api/config"),
fetchJson("/api/funnel"),
fetchJson("/api/daily-report"),
]);
updateHud(status);
const statusPre = document.getElementById("status");
const cf = document.getElementById("config");
if (statusPre) statusPre.textContent = pretty(status);
if (cf) cf.textContent = pretty(config);
const runState = (status && status.state) || {};
renderFunnel(funnel.items || [], {
gemmaEnabled: !!(config.gemma && config.gemma.enabled),
cycleMsg: runState.gemma_cycle_msg || "",
lastFunnelAt: runState.last_funnel_at || "",
});
renderDailyReport(dailyReport);
try {
const oe = await fetchJson("/api/order-executors");
renderOrderExecutors(oe);
if (document.activeElement !== document.getElementById("oeWebhookSecret")) {
setCheck("oeGlobalEnabled", !!oe.enabled);
setInput("oeTimeout", oe.timeout_seconds ?? 15);
const sec = document.getElementById("oeWebhookSecret");
if (sec) sec.value = oe.webhook_secret != null ? String(oe.webhook_secret) : "";
}
} catch (eOe) {
console.warn("order executors refresh", eOe);
}
const poll = status.poll_interval_seconds != null ? String(status.poll_interval_seconds) : "?";
const pullCn = new Date()
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
.replace("T", " ");
const fc = (funnel.items || []).length;
const lfAt = runState.last_funnel_at ? formatIsoToBeijing(runState.last_funnel_at) : "—";
const gmsg = runState.gemma_cycle_msg || "—";
const fm = document.getElementById("funnelMeta");
if (fm) {
let line =
`// 浏览器刚拉完 API${pullCn} HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` +
`矩阵卡片 ${fc} 条:来自告警库「每币最新一条」| 记忆体 last_funnel 更新:${lfAt} 后轮 gemma${gmsg}`;
if (String(gmsg).includes("funnel_ranked=0") && fc > 0) {
line +=
" | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。";
} else {
line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。";
}
fm.textContent = line;
}
const allAlerts = alerts.items || [];
const watchRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "WATCH");
const triggerRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "TRIGGER");
renderItems("watchAlerts", watchRows, (a) => `
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
<div>评分: ${Number(a.score).toFixed(2)}</div>
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
`);
if (!triggerRows.length) {
const trig = document.getElementById("triggerAlerts");
if (trig) {
trig.innerHTML =
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> " +
"// 暂无 TRIGGER:触发层只显示 <code>signal_level=TRIGGER</code> 的告警(通常需横盘后<strong>放量突破</strong>等更严条件)。有 WATCH 不代表已进入 TRIGGER。</div>";
}
} else {
renderItems("triggerAlerts", triggerRows, (a) => `
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
<div>推送状态: ${((a.details || {}).strict_push_ok === true) ? "已推送" : "未推送"}</div>
<div>未推送原因: ${escapeHtml(String(((a.details || {}).push_block_reason || "—")))}</div>
<div>评分: ${Number(a.score).toFixed(2)}</div>
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
`);
}
renderItems("logs", logs.items || [], (l) => `
<div><strong class="matrix-log-lvl-${(l.level || "").toLowerCase()}">[${l.level}]</strong> ${escapeHtml(l.message)}</div>
<div class="time">${formatIsoToBeijing(l.created_at)}</div>
`);
} catch (error) {
console.error("refresh failed", error);
setText("hudLink", "ERR");
const fm = document.getElementById("funnelMeta");
if (fm) fm.textContent = `// 拉取失败(检查登录是否过期、网络): ${error}`;
}
}
/** 轻量 Canvas 代码雨(仅 dashboard 有 canvas */
function initMatrixRain() {
const canvas = document.getElementById("matrixRain");
if (!canvas || !canvas.getContext) return;
const ctx = canvas.getContext("2d");
const chars = "01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロ";
let w = 0;
let h = 0;
let columns = [];
const fontSize = 14;
function resize() {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
const colCount = Math.min(48, Math.ceil(w / fontSize));
columns = Array.from({ length: colCount }, () => ({
y: Math.random() * h,
speed: 0.8 + Math.random() * 2.2,
head: Math.floor(Math.random() * chars.length),
}));
}
window.addEventListener("resize", resize);
resize();
function frame() {
ctx.fillStyle = "rgba(2, 2, 6, 0.12)";
ctx.fillRect(0, 0, w, h);
ctx.font = `${fontSize}px ui-monospace, monospace`;
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const x = i * fontSize;
const ch = chars[(col.head + Math.floor(col.y / fontSize)) % chars.length];
const flicker = 0.35 + Math.random() * 0.45;
ctx.fillStyle = `rgba(0, 255, 200, ${flicker})`;
ctx.fillText(ch, x, col.y % (h + fontSize));
col.y += col.speed;
if (col.y > h + fontSize) col.y = -fontSize * (3 + Math.random() * 8);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function formatOeLastForward(lf) {
if (!lf || typeof lf !== "object") return "—";
const at = lf.at ? formatIsoToBeijing(lf.at) : "—";
const st = lf.exec_status != null ? String(lf.exec_status) : "—";
const http = lf.http_status != null ? String(lf.http_status) : "—";
return `${at} · HTTP ${http} · ${st}`;
}
function renderOrderExecutors(snap) {
const root = document.getElementById("oeList");
if (!root) return;
const rows = (snap && snap.executors) || [];
if (!rows.length) {
root.innerHTML =
"<div class='matrix-hint matrix-hint-empty'>// 尚未添加执行器。单账户填一条 Base URL;多账户对照实验填多条(如 :8090 / :8091)。</div>";
return;
}
root.innerHTML = rows
.map((ex) => {
const id = escapeHtml(String(ex.id || ""));
const en = !!ex.enabled;
const lf = formatOeLastForward(ex.last_forward);
return `
<div class="item matrix-list-item" data-oe-id="${id}">
<div class="matrix-row-title"><strong>${escapeHtml(ex.name || "—")}</strong>
<span class="matrix-chip ${en ? "" : "matrix-dim"}">${en ? "启用" : "停用"}</span></div>
<div class="mono">${escapeHtml(ex.base_url || "—")}</div>
<div>上次转发: ${escapeHtml(lf)}</div>
<div class="matrix-form-row matrix-form-row-tight" style="margin-top:8px">
<button type="button" class="matrix-btn ghost oe-toggle" data-id="${id}" data-enabled="${en ? "0" : "1"}">${en ? "停用" : "启用"}</button>
<button type="button" class="matrix-btn ghost oe-delete" data-id="${id}">删除</button>
</div>
</div>`;
})
.join("");
}
async function loadOrderExecutors() {
const data = await fetchJson("/api/order-executors");
setCheck("oeGlobalEnabled", !!data.enabled);
setInput("oeTimeout", data.timeout_seconds ?? 15);
const sec = document.getElementById("oeWebhookSecret");
if (sec && document.activeElement !== sec) {
sec.value = data.webhook_secret != null ? String(data.webhook_secret) : "";
}
renderOrderExecutors(data);
}
async function saveOrderExecutorsGlobal() {
const msg = document.getElementById("oeGlobalMsg");
if (msg) msg.textContent = "保存中…";
try {
const payload = {
enabled: getInputCheck("oeGlobalEnabled"),
webhook_secret: getInputText("oeWebhookSecret"),
timeout_seconds: Number(getInputText("oeTimeout") || "15"),
};
const data = await fetchJson("/api/order-executors/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (msg) msg.textContent = "// 已保存(改 webhook 后请同步各执行器 config";
renderOrderExecutors(data.order_executors || data);
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function addOrderExecutor() {
const msg = document.getElementById("oeAddMsg");
if (msg) msg.textContent = "提交中…";
try {
const data = await fetchJson("/api/order-executors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: getInputText("oeNewName"),
base_url: getInputText("oeNewUrl"),
enabled: getInputCheck("oeNewEnabled"),
}),
});
if (msg) msg.textContent = "// 已添加";
setInput("oeNewName", "");
setInput("oeNewUrl", "");
setCheck("oeNewEnabled", true);
renderOrderExecutors(data.order_executors || data);
} catch (error) {
if (msg) msg.textContent = `// 失败 ${error}`;
}
}
async function patchOrderExecutor(id, body) {
return fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function wireOrderExecutorsPanel() {
const saveG = document.getElementById("oeSaveGlobalBtn");
if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal);
const addB = document.getElementById("oeAddBtn");
if (addB) addB.addEventListener("click", addOrderExecutor);
const list = document.getElementById("oeList");
if (list) {
list.addEventListener("click", async (ev) => {
const tgl = ev.target.closest && ev.target.closest(".oe-toggle");
const del = ev.target.closest && ev.target.closest(".oe-delete");
const id = (tgl || del) && (tgl || del).getAttribute("data-id");
if (!id) return;
try {
if (tgl) {
const en = tgl.getAttribute("data-enabled") === "1";
const data = await patchOrderExecutor(id, { enabled: en });
renderOrderExecutors(data.order_executors || data);
} else if (del) {
if (!confirm("确认从扫描端移除该执行器?(不会停止执行器进程)")) return;
const data = await fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
method: "DELETE",
});
renderOrderExecutors(data.order_executors || data);
}
} catch (error) {
alert(String(error));
}
});
}
}
const saveBtn = document.getElementById("saveIntradayBtn");
if (saveBtn) saveBtn.addEventListener("click", saveIntradaySettings);
const saveBlocklistBtn = document.getElementById("saveSymbolBlocklistBtn");
if (saveBlocklistBtn) saveBlocklistBtn.addEventListener("click", saveSymbolBlocklistSettings);
const runDailyBtn = document.getElementById("runDailyReportBtn");
if (runDailyBtn) runDailyBtn.addEventListener("click", runDailyReportNow);
const saveDailyBtn = document.getElementById("saveDailyReportBtn");
if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings);
loadIntradaySettings().catch(console.error);
loadDailyReportSettings().catch(console.error);
wireOrderExecutorsPanel();
loadOrderExecutors().catch(console.error);
tickClock();
setInterval(tickClock, 1000);
initMatrixRain();
refresh();
setInterval(refresh, 4000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refresh();
});
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MATRIX · FUNNEL</title>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
</head>
<body class="matrix-theme">
<canvas id="matrixRain" class="matrix-rain-canvas" aria-hidden="true"></canvas>
<div class="matrix-vignette" aria-hidden="true"></div>
<div class="matrix-aurora" aria-hidden="true"></div>
<div class="matrix-scanlines" aria-hidden="true"></div>
<div class="matrix-noise" aria-hidden="true"></div>
<div class="matrix-chrome">
<div class="matrix-hud" id="matrixHud">
<div class="matrix-hud-block matrix-hud-live">
<span class="matrix-hud-dot" aria-hidden="true"></span>
<div class="matrix-hud-stack">
<span class="matrix-hud-label">LINK</span>
<span class="matrix-hud-val" id="hudLink">SYNC…</span>
</div>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">CYCLE</span>
<span class="matrix-hud-val" id="hudCycle"></span>
</div>
<div class="matrix-hud-block" title="BTC:近8h×15m 环境(横盘时多空均可推送;否则涨→LONG、跌→SHORT);BTC·辅=日线 regime(仅参考)">
<span class="matrix-hud-label">BTC·辅</span>
<span class="matrix-hud-val" id="hudBtc"></span>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">POOL</span>
<span class="matrix-hud-val" id="hudPool"></span>
</div>
<div class="matrix-hud-block">
<span class="matrix-hud-label">PUSH</span>
<span class="matrix-hud-val" id="hudPush"></span>
</div>
<div class="matrix-hud-block matrix-hud-wide">
<span class="matrix-hud-label">GEMMA</span>
<span class="matrix-hud-val mono" id="hudGemma"></span>
</div>
<div class="matrix-hud-block matrix-hud-wide">
<span class="matrix-hud-label">LAST</span>
<span class="matrix-hud-val mono" id="hudLast"></span>
</div>
</div>
<header class="matrix-header matrix-header-crt">
<div class="matrix-brand">
<div class="matrix-title-wrap">
<div class="matrix-glitch matrix-glitch-xl" data-text="MATRIX // FUNNEL">
<span class="matrix-glitch-layer matrix-glitch-c">MATRIX // FUNNEL</span>
<span class="matrix-glitch-layer matrix-glitch-m">MATRIX // FUNNEL</span>
<span class="matrix-glitch-base">MATRIX // FUNNEL</span>
</div>
<div class="matrix-subdeck">
<span class="matrix-deco">[</span>
<span id="matrixTagline" class="matrix-tagline matrix-tagline-glow">Gate USDT 永续 · 5m 结构 → 日线漏斗 · Gemma 优先链</span>
<span class="matrix-deco">]</span>
</div>
</div>
</div>
<div class="matrix-radar-header" aria-hidden="true" title="战术雷达 · 装饰">
<div class="matrix-radar-hud">
<div class="matrix-radar-h-graticule"></div>
<div class="matrix-radar-h-cross"></div>
<div class="matrix-radar-h-rings"></div>
<div class="matrix-radar-h-sweep"></div>
<div class="matrix-radar-h-sweep matrix-radar-h-sweep-ghost"></div>
<div class="matrix-radar-h-blip"></div>
</div>
<span class="matrix-radar-h-caption">SCAN</span>
</div>
<div class="matrix-header-actions">
<span class="matrix-pill matrix-pill-clock" id="liveClock">--:--:--</span>
<span class="matrix-pill dim">OP ▸ <span id="opUser">{{ username }}</span></span>
<a class="matrix-btn ghost" href="/logout">断开链路</a>
</div>
</header>
<main class="matrix-main matrix-crt-inner">
<section class="matrix-panel matrix-panel-hero matrix-panel-chrome">
<div class="matrix-hero-radar" aria-hidden="true">
<div class="matrix-hero-radar-grid"></div>
<div class="matrix-hero-radar-sweep"></div>
<div class="matrix-hero-radar-sweep matrix-hero-radar-sweep-trail"></div>
<div class="matrix-hero-radar-ring"></div>
</div>
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// GEMMA 漏斗 · 优先矩阵</h2>
<span class="matrix-chip matrix-chip-magenta">LIVE FEED</span>
</div>
<p class="matrix-hint">合成评分 · 成交量 · 日线结构 · 上方空间 · 中间阻力 → 达标企业微信推送</p>
<p id="funnelMeta" class="matrix-hint matrix-dim">// 数据同步中…</p>
<div id="funnelMatrix" class="matrix-grid"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 每日晨报 · 昨日复盘</h2>
<button type="button" id="runDailyReportBtn" class="matrix-btn ghost">立即生成</button>
</div>
<div class="matrix-form-row">
<label>晨报开关</label>
<input id="dailyReportEnabledInput" type="checkbox" />
<label>北京时间执行</label>
<input id="dailyReportTimeInput" type="time" />
<label>推送企业微信</label>
<input id="dailyReportPushInput" type="checkbox" />
<label>启动即生成</label>
<input id="dailyReportStartupInput" type="checkbox" />
<button type="button" id="saveDailyReportBtn" class="matrix-btn matrix-btn-pulse">保存晨报配置</button>
</div>
<p id="dailyReportSaveMsg" class="matrix-msg"></p>
<p id="dailyReportMeta" class="matrix-hint matrix-dim">// 等待晨报数据…</p>
<div id="dailyReportBox" class="matrix-list"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 监控黑名单 · base</h2>
<span class="matrix-chip matrix-dim" id="symbolBlocklistCountChip">0 条规则</span>
</div>
<p class="matrix-hint">
Gate USDT 永续左侧 base(每行一个或逗号分隔),如 <code>XAU</code> <code>XAUT</code> <code>XAG</code><code>all_swaps</code>
<code>watchlist</code> 均会在入池后剔除。保存后下一轮扫描生效。
</p>
<textarea
id="symbolBlocklistInput"
class="matrix-input matrix-textarea"
rows="5"
spellcheck="false"
autocomplete="off"
placeholder="XAU&#10;XAUT&#10;XAG"
></textarea>
<div class="matrix-form-row matrix-form-row-tight">
<button type="button" id="saveSymbolBlocklistBtn" class="matrix-btn matrix-btn-pulse">保存黑名单</button>
</div>
<p id="symbolBlocklistSaveMsg" class="matrix-msg"></p>
</section>
<section class="matrix-panel matrix-panel-chrome" id="orderExecutorsPanel">
<div class="matrix-panel-head matrix-panel-head-row">
<h2>// 下单执行器 · 转发链</h2>
<span class="matrix-chip matrix-dim">仅扫描端维护 · 同一信号广播</span>
</div>
<p class="matrix-hint">
企微突破推送成功后,向列表中<strong>已启用</strong>的执行器 POST <code>/v1/signal</code>(方案 A 止盈止损)。
各执行器自行配置 Gate API、盈亏比、移动保本等;<strong>不支持执行器反向注册</strong>
修改 webhook 密钥后请同步到各执行器 <code>security.webhook_secret</code>
</p>
<div class="matrix-form-row matrix-form-row-wrap">
<label>总开关</label>
<input id="oeGlobalEnabled" type="checkbox" />
<label>Webhook 密钥</label>
<input id="oeWebhookSecret" class="matrix-input" type="password" style="min-width:14rem" autocomplete="new-password" />
<label>超时(s)</label>
<input id="oeTimeout" class="matrix-input" type="number" min="3" max="120" step="1" style="width:5rem" />
<button type="button" id="oeSaveGlobalBtn" class="matrix-btn matrix-btn-pulse">保存全局</button>
</div>
<p id="oeGlobalMsg" class="matrix-msg"></p>
<div class="matrix-form-row matrix-form-row-wrap" style="margin-top:12px">
<label>名称</label>
<input id="oeNewName" class="matrix-input" type="text" placeholder="如 account_a" style="width:8rem" />
<label>Base URL</label>
<input id="oeNewUrl" class="matrix-input" type="text" placeholder="http://127.0.0.1:8090" style="min-width:16rem" />
<label>启用</label>
<input id="oeNewEnabled" type="checkbox" checked />
<button type="button" id="oeAddBtn" class="matrix-btn ghost">添加执行器</button>
</div>
<p id="oeAddMsg" class="matrix-msg"></p>
<div id="oeList" class="matrix-list" style="margin-top:12px"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 策略寄存器 · 5m</h2></div>
<p class="matrix-hint">横盘 + 5m 收盘上破 + 放量 · 保存后下一轮生效 · 止损缓冲为企微区间A/B共用</p>
<div class="matrix-form-row">
<label>横盘时长(h)</label>
<input id="rangeHoursInput" type="number" step="0.5" min="1" />
<label>横盘振幅上限(%)</label>
<input id="rangeMaxPctInput" type="number" step="0.1" min="0.1" />
<label>放量倍数</label>
<input id="volumeSpikeMultInput" type="number" step="0.1" min="1" />
<label>放量回看根数</label>
<input id="volumeLookbackInput" type="number" step="1" min="5" />
<label>突破缓冲(%)</label>
<input id="breakoutBufferInput" type="number" step="0.01" min="0" />
<label>止损缓冲(%)</label>
<input id="stopBufferPctInput" type="number" step="0.05" min="0" max="10" title="企微文案区间A/B共用:突破K极值与箱体边沿外侧缓冲" />
<label>启用推送时间窗(09:00-23:00)</label>
<input id="pushTimeWindowEnabledInput" type="checkbox" />
<button type="button" id="saveIntradayBtn" class="matrix-btn matrix-btn-pulse">写入寄存器</button>
</div>
<p id="intradaySaveMsg" class="matrix-msg"></p>
</section>
<div class="matrix-two-col">
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 观察层 · WATCH</h2></div>
<div id="watchAlerts" class="matrix-list"></div>
</section>
<section class="matrix-panel matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 触发层 · TRIGGER</h2></div>
<div id="triggerAlerts" class="matrix-list"></div>
</section>
</div>
<section class="matrix-panel matrix-panel-wide matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 系统遥测</h2></div>
<div class="matrix-split">
<pre id="status" class="matrix-pre matrix-pre-glow"></pre>
<pre id="config" class="matrix-pre matrix-pre-glow"></pre>
</div>
</section>
<section class="matrix-panel matrix-panel-wide matrix-panel-chrome">
<div class="matrix-panel-head"><h2>// 运行日志</h2></div>
<div id="logs" class="matrix-list matrix-list-logs"></div>
</section>
</main>
</div>
<script src="/static/app.js?v={{ asset_version }}"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MATRIX · 接入</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body class="matrix-login">
<div class="matrix-aurora" aria-hidden="true"></div>
<div class="matrix-vignette" aria-hidden="true"></div>
<div class="matrix-scanlines matrix-scanlines-strong" aria-hidden="true"></div>
<div class="matrix-noise" aria-hidden="true"></div>
<div class="matrix-grid-bg" aria-hidden="true"></div>
<div class="matrix-login-card matrix-login-card-chrome">
<div class="matrix-login-badge">SECURE CHANNEL</div>
<div class="matrix-login-glitch" data-text="&gt; AUTHENTICATE">&gt; AUTHENTICATE</div>
<p class="matrix-login-sub">MATRIX // GATE USDT PERP FUNNEL · 未授权区域禁止访问</p>
<form method="post" action="/login" class="matrix-login-form">
<label class="matrix-label">操作员 ID</label>
<input class="matrix-input" type="text" name="username" required autocomplete="username" />
<label class="matrix-label">密钥</label>
<input class="matrix-input" type="password" name="password" required autocomplete="current-password" />
<button type="submit" class="matrix-btn matrix-btn-full matrix-btn-pulse">建立会话</button>
</form>
<div class="matrix-error">{{ error }}</div>
</div>
</body>
</html>
@@ -0,0 +1,75 @@
"""执行器列表存储单元测试。"""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.config import OrderExecutorConfig, Settings, AppConfig, AuthConfig, WeComConfig, GateConfig
from app import order_executors_store as store
def _minimal_settings() -> Settings:
return Settings(
app=AppConfig(
host="127.0.0.1",
port=8088,
poll_interval_seconds=60,
log_file="./runtime/system.log",
database_url="sqlite+aiosqlite:///./runtime/t.db",
session_secret="x",
),
auth=AuthConfig(enabled=False, username="a", password="b"),
wecom=WeComConfig(webhook="https://example.com/hook"),
gate=GateConfig(),
order_executor=OrderExecutorConfig(
enabled=True,
base_url="http://127.0.0.1:8090",
webhook_secret="sec",
timeout_seconds=15.0,
),
)
class TestOrderExecutorsStore(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "order_executors.json"
self._patch = patch.object(store, "_STORE_PATH", self.path)
self._patch.start()
def tearDown(self) -> None:
self._patch.stop()
self.tmp.cleanup()
def test_migrate_from_settings(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
snap = store.read_snapshot(s)
self.assertTrue(snap["enabled"])
self.assertEqual(snap["webhook_secret"], "sec")
self.assertEqual(len(snap["executors"]), 1)
self.assertEqual(snap["executors"][0]["base_url"], "http://127.0.0.1:8090")
def test_add_and_active(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
row = store.add_executor(s, name="b", base_url="http://127.0.0.1:8091", enabled=True)
active = store.active_executors(s)
urls = {e["base_url"] for e in active}
self.assertIn("http://127.0.0.1:8090", urls)
self.assertIn("http://127.0.0.1:8091", urls)
self.assertEqual(row["name"], "b")
def test_write_global(self) -> None:
s = _minimal_settings()
store.ensure_store_initialized(s)
snap = store.write_global_settings(s, enabled=False, webhook_secret="new")
self.assertFalse(snap["enabled"])
self.assertEqual(snap["webhook_secret"], "new")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,206 @@
# 交易系统部署说明(Gate USDT 永续 · PM2
## 1. 系统概要
本系统是基于 Python 的云端监控服务,仅使用 **Gate.io** 交易所公共 APIREST v4)。
- 监控市场:Gate **USDT 本位线性永续**(合约名如 `BTC_USDT`
- 方向:策略支持多空信号;企业微信推送文案随信号方向变化
- 周期:监控主循环固定 **5m**
- 信号分级:WATCH / TRIGGER
- 数据:`/futures/usdt/contracts``/tickers``/candlesticks`
## 2. 当前策略(摘要)
- WATCH:横盘结构成立
- TRIGGER:横盘 + 5m 收盘突破边界 + 放量
- 可调参数:横盘时长、振幅、放量倍数、回看根数、缓冲(见 Web 面板 / SQLite `kv_store`
## 3. config.yaml 示例
```yaml
app:
host: 0.0.0.0
port: 8088
poll_interval_seconds: 300
log_file: ./runtime/system.log
database_url: sqlite+aiosqlite:///./runtime/alerts.db
session_secret: please-replace-with-strong-random-value
auth:
username: admin
password: ChangeThisPassword!
wecom:
webhook: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace
mentioned_mobile_list: []
gate:
api_base: https://api.gateio.ws/api/v4
settle: usdt
quote_currency: USDT
monitor:
universe: all_swaps
min_24h_quote_volume_usdt: 10000000
watch_symbols: []
# 可选:与并列项目 gate_order_executor 联动(企微推送成功后再 POST /v1/signal
order_executor:
enabled: false
base_url: "http://127.0.0.1:8090"
webhook_secret: "same-as-executor-security-webhook_secret"
timeout_seconds: 15
```
### 3.1 企微与自动下单
- 默认仅 **企业微信** 文本告警。若部署 **gate_order_executor** 并设置 `order_executor.enabled: true`、**`webhook_secret` 与执行器一致**,则在 **企微推送成功之后** 自动向执行器发结构化信号(方案 A 止盈/止损与企微文案一致)。
### 3.2 执行器联调(curl,无面板按钮)
并列项目 **`gate_order_executor`** 的 Web 面板 **不再提供**「拉取余额 / 测试市价」入口;需在服务器用 **`curl`** 或脚本调用 **`POST /api/test`**、**`POST /v1/test`** 做联调(`micro_market``gate.test_orders_enabled: true`)。**完整命令与鉴权说明**见 **`gate_order_executor/docs/使用说明.md` §4.1** 与 **`gate_order_executor/docs/部署说明.md` §11**。
## 4. 云端部署(Python venv + PM2,推荐)
以下以 Ubuntu/Debian、项目路径 `/root/onchain_scout_gate` 为例:
### 4.1 系统依赖
```bash
apt update && apt install -y python3 python3-pip python3-venv curl
```
安装 Node.js(用于 PM2),参见 NodeSource 或发行版自带 `nodejs` / `npm`
```bash
npm install -g pm2
```
### 4.2 上传项目
将项目放到 `/root/onchain_scout_gate`(包含 `app/``requirements.txt``deploy/ecosystem.config.cjs`)。
### 4.3 虚拟环境与 Python 依赖
```bash
cd /root/onchain_scout_gate && \
python3 -m venv .venv && \
source .venv/bin/activate && \
python -m pip install -U pip && \
pip install -r requirements.txt
```
### 4.4 配置文件
```bash
nano /root/onchain_scout_gate/config.yaml
```
至少修改:`auth``session_secret``wecom.webhook``monitor`。**旧版 `okx:` 配置需改为 `gate:`**(见上方示例)。
### 4.5 PM2 启动与自检
在项目根目录执行:
```bash
cd /root/onchain_scout_gate
pm2 start deploy/ecosystem.config.cjs
pm2 logs onchain-scout
```
验证:浏览器访问 `http://服务器IP:8088`(端口以 `config.yaml` 为准)。
常用运维:
```bash
pm2 restart onchain-scout
pm2 save
pm2 startup # 按提示配置开机自启
```
日志:`runtime/system.log`(应用轮转日志)、`runtime/pm2-out.log` / `runtime/pm2-error.log`PM2)。
## 5. 前台调试(非守护)
便于排查问题时临时使用:
```bash
cd /root/onchain_scout_gate && source .venv/bin/activate && \
python -m app.main
```
或:`uvicorn app.main:app --host 0.0.0.0 --port 8088 --workers 1`
## 6. systemd(可选)
若希望用 systemd 托管 **pm2-runtime**(保持进程在前台供 systemd 监控),可复制并修改 `deploy/onchain-scout.service` 中的路径与 `ExecStart`。**生产环境更常见做法是仅用 PM2 自带的 `pm2 startup`。**
## 7. 多执行器转发(Web 面板维护)
同一套突破信号可向 **多个** `gate_order_executor` 广播(对照实验:各执行器自行配置盈亏比、仓位等)。
| 部署 | 操作 |
|------|------|
| **单账户** | 面板「下单执行器」中保留 **1 条** Base URL(如 `http://127.0.0.1:8090` |
| **多账户** | 添加多条 URL(如 `:8090``:8091`),各进程绑定不同 Gate API |
| **暂停某一账户** | 将该条设为「停用」,或关闭总开关 |
| **Webhook** | 面板保存的密钥须与各执行器 `security.webhook_secret` **一致**(改后需同步执行器 config |
- 列表保存在 `runtime/order_executors.json`,**仅扫描端维护**,执行器不会反向注册。
- 转发请求 **不走** `proxy`,直连 `base_url`(同机可用 `127.0.0.1`)。
- 详细设计见 `docs/多执行器与信号转发归档.md`
## 8. 云服务器:关闭代理
本机开发若使用 Clash 等 SOCKS`proxy.enabled: true`),迁到 **可直连 Gate 的境外云主机** 后应关闭代理。
### 8.1 扫描端 `config.yaml`
```yaml
proxy:
enabled: false
url: "socks5h://127.0.0.1:1080" # enabled=false 时可保留
```
修改后:`pm2 restart onchain-scout`(或你的 PM2 应用名)。
**说明:**
- `proxy` 仅影响 **Gate 行情** 请求;企业微信、转发执行器均为 **直连**
- 自检:`curl -I --max-time 15 https://api.gateio.ws` 成功后再关代理。
### 8.2 执行器 `gate_order_executor/config.yaml`(每个实例)
```yaml
proxy:
enabled: false
```
每个执行器进程改完后分别 `pm2 restart gate-order-executor`(多实例用不同应用名/端口)。
### 8.3 同机典型拓扑
```text
onchain_scout_gate :8088 proxy.enabled: false
gate_order_executor :8090 账户 A
gate_order_executor :8091 账户 B(第二份目录或第二 PM2 应用)
```
面板两条 Base URL 指向上述地址即可。
## 9. 常见问题
| 现象 | 处理 |
|------|------|
| `配置文件校验失败` / 缺少 `gate` | 将 `config.yaml``okx:` 改为本文 §3 的 `gate:` 段 |
| ModuleNotFoundError | 进入 `.venv` 后执行 `pip install -r requirements.txt` |
| 拉不到行情 | 检查网络、`proxy`、防火墙;可选 `curl -I https://api.gateio.ws` |
| 限流 / 周期过长 | 增大 `poll_interval_seconds` 或提高 `min_24h_quote_volume_usdt` |
| 有 TRIGGER 但未下单 | 看面板执行器总开关、列表是否为空、webhook 是否一致;查运行日志 `order_executor_*` |
## 10. 运维建议
- 公网建议 Nginx 反代 + HTTPS8088 仅内网暴露。
- 定期备份:`runtime/alerts.db``runtime/order_executors.json`
- 修改 `config.yaml` 后执行 **`pm2 restart onchain-scout`**(执行器列表以面板为准,已有 `order_executors.json` 不会被 yaml 覆盖)。
+67
View File
@@ -0,0 +1,67 @@
# 交易系统部署说明(Gate USDT 永续)
## 1. 系统概要
本系统是基于 Python 的监控服务,使用 **Gate.io** 公共 APIUSDT 永续)。
- 监控市场:Gate USDT 线性永续(如 `BTC_USDT`
- 周期:固定 5m 扫描
- 信号分级:WATCH / TRIGGER
## 2. config.yaml(节选)
```yaml
gate:
api_base: https://api.gateio.ws/api/v4
settle: usdt
quote_currency: USDT
monitor:
universe: all_swaps
min_24h_quote_volume_usdt: 10000000
watch_symbols: []
```
完整示例见 `config.example.yaml`。若仍使用旧字段 **`okx:`**,请改为 **`gate:`**,否则服务无法启动。
## 3. 安装(Linux 示例)
```bash
apt update && apt install -y python3 python3-pip python3-venv
cd /root/onchain_scout_gate
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
cp -n config.example.yaml config.yaml
nano config.yaml
```
## 4. 生产部署:PM2(推荐)
```bash
npm install -g pm2
cd /root/onchain_scout_gate
pm2 start deploy/ecosystem.config.cjs
pm2 logs onchain-scout
```
开机自启:`pm2 save``pm2 startup`(按 CLI 提示执行)。
## 5. 前台调试
```bash
cd /root/onchain_scout_gate && source .venv/bin/activate && python -m app.main
```
## 6. 常见问题
- 配置报错:确认已使用 `gate:` 配置块。
- 依赖缺失:在 venv 内重装 `requirements.txt`
- 网络:需要能访问 `api.gateio.ws`(或通过 `proxy`)。
## 7. 运维
- 备份 `runtime/alerts.db`
- 改配置后:`pm2 restart onchain-scout`
+428
View File
@@ -0,0 +1,428 @@
# gate_scout_order · 完整部署说明
本文档面向 **Linux 云服务器(Ubuntu / Debian** 从零部署 **扫描端 + 下单执行器**,仓库地址:
**https://git.bz121.com/dekun/gate_scout_order.git**
Gitea 私有仓库:[dekun/gate_scout_order](https://git.bz121.com/dekun/gate_scout_order)
---
## 目录
1. [系统架构](#1-系统架构)
2. [升级前备份(已在跑旧版必读)](#2-升级前备份已在跑旧版必读)
3. [服务器环境准备](#3-服务器环境准备)
4. [克隆代码](#4-克隆代码)
5. [部署扫描端 onchain_scout_gate](#5-部署扫描端-onchain_scout_gate)
6. [部署执行器 gate_order_executor](#6-部署执行器-gate_order_executor)
7. [扫描端与执行器串联](#7-扫描端与执行器串联)
8. [多执行器 / 多账户](#8-多执行器--多账户)
9. [云服务器关闭代理](#9-云服务器关闭代理)
10. [日常运维与升级](#10-日常运维与升级)
11. [防火墙与安全](#11-防火墙与安全)
12. [故障速查](#12-故障速查)
---
## 1. 系统架构
```text
/opt/gate_scout_order/ ← git clone 根目录
├── onchain_scout_gate/ ← 扫描端 :8088
│ ├── config.yaml ← 本地配置(不入库)
│ └── runtime/
│ ├── alerts.db ← 告警、面板策略参数
│ └── order_executors.json ← 执行器转发列表(面板维护)
└── gate_order_executor/ ← 执行器 :8090(可多实例)
├── config.yaml
└── runtime/
├── signals.sqlite ← 信号流 / 执行结果
├── risk_prefs.json ← 面板「最低盈亏比」
├── breakeven_prefs.json ← 移动保本开关
└── breakeven_active.json ← 移动保本运行态
```
| 服务 | PM2 名称 | 默认端口 | 作用 |
|------|----------|----------|------|
| 扫描端 | `onchain-scout` | 8088 | Gate 5m 监控、企微告警、向执行器 POST 信号 |
| 执行器 | `gate-order-executor` | 8090 | 接信号、Gate 下单、止盈止损、移动保本 |
---
## 2. 升级前备份(已在跑旧版必读)
若你 **之前已在服务器上运行** 扫描端和/或执行器(目录可能是 `/root/onchain_scout_gate``/root/gate_order_executor` 等),在 **停进程、拉新代码或改目录** 之前,请先备份下列文件。**`config.yaml``runtime/` 不会进 Git**,丢失后需重新填密钥并丢失历史记录。
### 2.1 必须备份(丢失难恢复)
| 来源目录 | 文件 | 内容 |
|----------|------|------|
| **扫描端** `onchain_scout_gate/` | `config.yaml` | 企微 Webhook、auth 密码、`session_secret`、proxy、monitor 等 |
| **扫描端** | `runtime/alerts.db` | 历史告警、**Web 面板写入的策略参数**(横盘/放量/黑名单/晨报开关等 SQLite KV |
| **扫描端** | `runtime/order_executors.json` | **执行器转发列表**、总开关、面板里的 Webhook 密钥(若已用面板配置) |
| **执行器** `gate_order_executor/` | `config.yaml` | Gate `api_key` / `api_secret``webhook_secret``dry_run`、风险参数等 |
| **执行器** | `runtime/signals.sqlite` | **信号流与每笔下单结果**(面板导出/对账) |
| **执行器** | `runtime/risk_prefs.json` | 面板保存的 **最低盈亏比**(覆盖 config 默认) |
| **执行器** | `runtime/breakeven_prefs.json` | **移动保本** 全局/单合约开关 |
| **执行器** | `runtime/breakeven_active.json` | 当前持仓的移动保本登记态(entry、initial_sl、是否已拉保本) |
### 2.2 建议备份(便于排障)
| 来源 | 文件 |
|------|------|
| 扫描端 | `runtime/system.log` |
| 扫描端 | `runtime/pm2-out.log``runtime/pm2-error.log` |
| 执行器 | `runtime/executor.log` |
| 执行器 | `runtime/pm2-executor-out.log``runtime/pm2-executor-error.log` |
### 2.3 一键打包示例(按你实际旧路径改)
```bash
BACKUP_DIR=/root/backup_gate_$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# 若旧版扫描端在此路径:
OLD_SCOUT=/root/onchain_scout_gate
if [ -d "$OLD_SCOUT" ]; then
mkdir -p "$BACKUP_DIR/onchain_scout_gate"
cp -a "$OLD_SCOUT/config.yaml" "$BACKUP_DIR/onchain_scout_gate/" 2>/dev/null || true
cp -a "$OLD_SCOUT/runtime" "$BACKUP_DIR/onchain_scout_gate/" 2>/dev/null || true
fi
# 若旧版执行器在此路径:
OLD_EXEC=/root/gate_order_executor
if [ -d "$OLD_EXEC" ]; then
mkdir -p "$BACKUP_DIR/gate_order_executor"
cp -a "$OLD_EXEC/config.yaml" "$BACKUP_DIR/gate_order_executor/" 2>/dev/null || true
cp -a "$OLD_EXEC/runtime" "$BACKUP_DIR/gate_order_executor/" 2>/dev/null || true
fi
# 若已是 monorepo 子目录:
NEW_ROOT=/opt/gate_scout_order
if [ -d "$NEW_ROOT/onchain_scout_gate" ]; then
mkdir -p "$BACKUP_DIR/monorepo_scout"
cp -a "$NEW_ROOT/onchain_scout_gate/config.yaml" "$BACKUP_DIR/monorepo_scout/" 2>/dev/null || true
cp -a "$NEW_ROOT/onchain_scout_gate/runtime" "$BACKUP_DIR/monorepo_scout/" 2>/dev/null || true
fi
if [ -d "$NEW_ROOT/gate_order_executor" ]; then
mkdir -p "$BACKUP_DIR/monorepo_executor"
cp -a "$NEW_ROOT/gate_order_executor/config.yaml" "$BACKUP_DIR/monorepo_executor/" 2>/dev/null || true
cp -a "$NEW_ROOT/gate_order_executor/runtime" "$BACKUP_DIR/monorepo_executor/" 2>/dev/null || true
fi
echo "备份完成: $BACKUP_DIR"
ls -la "$BACKUP_DIR"
```
### 2.4 停旧进程
```bash
pm2 stop onchain-scout gate-order-executor 2>/dev/null || true
pm2 delete onchain-scout gate-order-executor 2>/dev/null || true
pm2 save
```
### 2.5 新目录恢复配置
克隆新仓库后,将备份 **拷回对应子目录**(不要覆盖新代码,只覆盖 `config.yaml``runtime/`):
```bash
# 示例:从备份恢复到新 clone 路径
NEW=/opt/gate_scout_order
cp -a /root/backup_gate_xxxx/onchain_scout_gate/config.yaml "$NEW/onchain_scout_gate/"
cp -a /root/backup_gate_xxxx/onchain_scout_gate/runtime/* "$NEW/onchain_scout_gate/runtime/"
cp -a /root/backup_gate_xxxx/gate_order_executor/config.yaml "$NEW/gate_order_executor/"
cp -a /root/backup_gate_xxxx/gate_order_executor/runtime/* "$NEW/gate_order_executor/runtime/"
```
恢复后执行 `git pull`**不要**`git checkout -- runtime/` 覆盖业务数据。
---
## 3. 服务器环境准备
```bash
sudo apt update && sudo apt install -y python3 python3-venv python3-pip curl git
# Node.js + PM2(任选一种安装 Node 的方式)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pm2
# 验证 Gate 网络(云主机通常直连即可)
curl -I --max-time 15 https://api.gateio.ws
```
建议部署路径(可自定):
```text
/opt/gate_scout_order
```
---
## 4. 克隆代码
### 4.1 HTTPS(常用)
```bash
sudo mkdir -p /opt
cd /opt
git clone https://git.bz121.com/dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
私有仓库会提示输入 Gitea **用户名 + 密码**(或 Personal Access Token)。
### 4.2 SSH(已配置公钥时)
```bash
cd /opt
git clone git@git.bz121.com:dekun/gate_scout_order.git gate_scout_order
cd gate_scout_order
```
### 4.3 首次推送代码到空仓库(仅维护者)
若远程仍为空仓库,在开发机仓库根目录:
```bash
git remote add origin https://git.bz121.com/dekun/gate_scout_order.git
git branch -M main
git push -u origin main
```
---
## 5. 部署扫描端 onchain_scout_gate
```bash
cd /opt/gate_scout_order/onchain_scout_gate
chmod +x deploy/*.sh
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
# 若无 config.yaml(升级已从备份恢复则跳过)
cp config.example.yaml config.yaml
chmod 600 config.yaml
nano config.yaml
```
**`config.yaml` 至少修改:**
| 项 | 说明 |
|----|------|
| `app.session_secret` | 随机长字符串 |
| `auth.username` / `auth.password` | 面板登录 |
| `wecom.webhook` | 企业微信群机器人 URL |
| `proxy.enabled` | 云服务器能直连 Gate 时设为 **`false`**(见 §9 |
| `monitor.*` | 成交额门槛、universe 等 |
**启动 PM2**
```bash
cd /opt/gate_scout_order/onchain_scout_gate
source .venv/bin/activate
pm2 start deploy/ecosystem.config.cjs
pm2 save
```
验证:`http://服务器IP:8088/dashboard`(端口以 `config.yaml``app.port` 为准)。
```bash
pm2 logs onchain-scout
```
---
## 6. 部署执行器 gate_order_executor
```bash
cd /opt/gate_scout_order/gate_order_executor
chmod +x deploy/*.sh
bash deploy/bootstrap.sh /opt/gate_scout_order/gate_order_executor
nano config.yaml # 或从备份恢复后再改
```
**`config.yaml` 至少修改:**
| 项 | 说明 |
|----|------|
| `security.webhook_secret` | 与扫描端面板/Webhook 一致 |
| `app.session_secret` | 随机长字符串 |
| `auth` | 对外暴露时 `enabled: true` |
| `gate.api_key` / `gate.api_secret` | Gate 子账户 API(建议 IP 白名单) |
| `gate.dry_run` | 联调 **`true`**,验证后再改 **`false`** |
| `proxy.enabled` | 云主机通常 **`false`** |
| `app.host` | 仅本机扫描调用可 `127.0.0.1`;远程看面板用 `0.0.0.0` |
**启动 PM2**
```bash
cd /opt/gate_scout_order/gate_order_executor
bash deploy/pm2-start.sh
pm2 save
```
验证:
```bash
curl -s http://127.0.0.1:8090/health
# 面板:http://服务器IP:8090/dashboard
pm2 logs gate-order-executor
```
---
## 7. 扫描端与执行器串联
1. 两边的 `webhook_secret` 一致(扫描端以 **面板「下单执行器」** 保存为准,会写入 `runtime/order_executors.json`)。
2. 打开扫描端面板 → **「下单执行器 · 转发链」**
- 打开 **总开关**
- 填写 **Webhook 密钥**(与各执行器 `security.webhook_secret` 相同)
- **添加执行器**:名称随意,Base URL 填 `http://127.0.0.1:8090`(同机)
3. 执行器 `gate.dry_run: false` 且 API 有效后,企微 **TRIGGER 推送成功** 才会向执行器 POST `/v1/signal`
4. 转发 **不走** 扫描端 proxy,直连 `base_url`
联调执行器(可选):
```bash
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: 你的密钥" \
-d '{"action":"balance"}'
```
详见 [gate_order_executor/docs/使用说明.md](gate_order_executor/docs/使用说明.md) §4.1。
---
## 8. 多执行器 / 多账户
| 目标 | 做法 |
|------|------|
| 单账户 | 扫描面板只保留 **1 条** URL`:8090` |
| 两账户对照实验 | 再部署一份执行器(不同目录或同目录改 `app.port: 8091`),面板添加第二条 URL |
| 暂停某一账户 | 面板将该执行器设为「停用」 |
每个执行器实例需要:
- 独立 `config.yaml`(不同 Gate API
- 独立 `runtime/`(尤其 `signals.sqlite`、移动保本状态)
- 独立 PM2 应用名(复制 `ecosystem.config.cjs` 并改 `name`、端口)
扫描端仍只维护 **一份** `order_executors.json`
设计说明:[onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md)
---
## 9. 云服务器关闭代理
本机开发常用 `proxy.enabled: true` + 本地 SOCKS;**境外云主机**在能 `curl` 通 Gate 后应关闭:
**扫描端** `onchain_scout_gate/config.yaml`
```yaml
proxy:
enabled: false
```
**执行器** `gate_order_executor/config.yaml`(每个实例都要改):
```yaml
proxy:
enabled: false
```
```bash
pm2 restart onchain-scout
pm2 restart gate-order-executor
```
---
## 10. 日常运维与升级
### 10.1 拉取新代码
```bash
cd /opt/gate_scout_order
git pull
```
### 10.2 更新依赖并重启
```bash
cd /opt/gate_scout_order/onchain_scout_gate
source .venv/bin/activate && pip install -r requirements.txt
pm2 restart onchain-scout
cd /opt/gate_scout_order/gate_order_executor
source .venv/bin/activate && pip install -r requirements.txt
bash deploy/pm2-restart.sh
```
### 10.3 定期备份(建议 cron 每周)
```bash
BACKUP=/root/backup_gate_weekly/$(date +%Y%m%d)
mkdir -p "$BACKUP"
cp -a /opt/gate_scout_order/onchain_scout_gate/config.yaml "$BACKUP/scout_config.yaml"
cp -a /opt/gate_scout_order/onchain_scout_gate/runtime/alerts.db "$BACKUP/"
cp -a /opt/gate_scout_order/onchain_scout_gate/runtime/order_executors.json "$BACKUP/" 2>/dev/null || true
cp -a /opt/gate_scout_order/gate_order_executor/config.yaml "$BACKUP/executor_config.yaml"
cp -a /opt/gate_scout_order/gate_order_executor/runtime/signals.sqlite "$BACKUP/"
cp -a /opt/gate_scout_order/gate_order_executor/runtime/risk_prefs.json "$BACKUP/" 2>/dev/null || true
cp -a /opt/gate_scout_order/gate_order_executor/runtime/breakeven_prefs.json "$BACKUP/" 2>/dev/null || true
cp -a /opt/gate_scout_order/gate_order_executor/runtime/breakeven_active.json "$BACKUP/" 2>/dev/null || true
```
### 10.4 PM2 开机自启
```bash
pm2 startup
# 按提示执行 sudo 命令
pm2 save
```
---
## 11. 防火墙与安全
- 执行器若仅本机扫描调用:`app.host: 127.0.0.1`**不要**对公网开放 8090。
- 需外网访问面板:`0.0.0.0` + **防火墙白名单** + `auth.enabled: true`,建议 Nginx HTTPS 反代。
- `chmod 600` 各子项目的 `config.yaml`
- **勿** 将含 API Key 的 config 或 `runtime/*.db` 提交到 Git。
---
## 12. 故障速查
| 现象 | 处理 |
|------|------|
| `git clone` 403 / 认证失败 | 检查 Gitea 账号、Token;或改用 SSH |
| 扫描端拉不到行情 | `proxy`、防火墙;`curl -I https://api.gateio.ws` |
| 有 TRIGGER 未下单 | 面板执行器总开关、列表为空、webhook 不一致;日志搜 `order_executor_` |
| 执行器 401 | `X-Webhook-Secret` 与配置不一致 |
| 升级后面板策略丢了 | 是否未恢复 `runtime/alerts.db` |
| 信号流空了 | 是否未恢复 `runtime/signals.sqlite` |
| PM2 反复重启 | `pm2 logs`;检查 `config.yaml` 校验、端口占用 |
---
## 相关文档
| 文档 | 路径 |
|------|------|
| 仓库总览 | [README.md](README.md) |
| Git 克隆摘要 | [CLONE.md](CLONE.md) |
| 扫描端专题 | [onchain_scout_gate/交易系统部署说明.md](onchain_scout_gate/交易系统部署说明.md) |
| 执行器专题 | [gate_order_executor/docs/部署说明.md](gate_order_executor/docs/部署说明.md) |
| 多执行器归档 | [onchain_scout_gate/docs/多执行器与信号转发归档.md](onchain_scout_gate/docs/多执行器与信号转发归档.md) |