From b9f687f118f6edba0421e36b51affc83e9e9c36f Mon Sep 17 00:00:00 2001 From: mikemoi Date: Tue, 23 Jun 2026 00:11:11 +0200 Subject: [PATCH] Release v1.3.1 reliability improvements --- .env.example | 11 ++++-- .gitignore | 2 ++ CHANGELOG.md | 13 +++++++ README.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++-- VERSION | 2 +- requirements.txt | 1 + scanner.py | 47 +++++++++++++++++++++----- webapp.py | 77 ++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 224 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 55944d7..9b2dbeb 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Crypto ATR Signal v1.3.0 +# Crypto ATR Signal v1.3.1 # 复制为 .env 后按需修改。true=开启,false=关闭。 # 扫描脚本每次启动都会读取最新配置;网页配置修改后需要重启 FastAPI。 @@ -16,7 +16,8 @@ CRYPTO_MIN_BODY_RATIO=0.5 # TradFi 参数与 Crypto 完全独立 TRADFI_ATR_LENGTH=14 TRADFI_ATR_MULTIPLE=1.5 -TRADFI_BODY_RATIO_FILTER_ENABLED=false +# TradFi 默认过滤长影线和弱实体信号 +TRADFI_BODY_RATIO_FILTER_ENABLED=true TRADFI_MIN_BODY_RATIO=0.5 # ==================== Discord 聚合推送 ==================== @@ -50,6 +51,12 @@ HTTP_TIMEOUT=20 MAX_RETRIES=3 # Binance 返回 429/418 时的基础等待秒数 RATE_LIMIT_BACKOFF=30 +# 扫描进程锁文件;同一时间只允许一轮扫描运行 +SCAN_LOCK_PATH=data/scanner.lock + +# ==================== 健康检查 ==================== +# 距离最近成功扫描超过此小时数时,/health 返回 HTTP 503 +HEALTH_MAX_SCAN_AGE_HOURS=8 # ==================== 数据存储 ==================== # 每个市场、每个品种最多保留的 K 线数量 diff --git a/.gitignore b/.gitignore index 51e319e..826dea6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ __pycache__/ data/*.db data/*.db-* data/*.pid +data/*.lock +backups/ *.log # Local development artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0a497..3704d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v1.3.1 - 2026-06-23 + +### Added + +- 新增跨平台扫描进程锁,避免 Cron 重复运行造成并发请求和重复写库。 +- 新增 `/health` 健康检查,报告数据库、最近扫描、失败品种和当前信号数量。 +- README 增加 Nginx、健康监控、备份恢复、安全权限和故障排查说明。 + +### Changed + +- Discord 汇总改为统计最新已收盘 K 线的当前信号总数,重复运行不会错误显示为 0。 +- TradFi 新部署默认启用实体占比 `0.5` 过滤;`.env` 中的明确配置仍优先。 + ## v1.3.0 - 2026-06-22 ### Added diff --git a/README.md b/README.md index 18a5826..a13c018 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Crypto ATR Signal -Version: `v1.3.0` +Version: `v1.3.1` 扫描 Binance Futures 交易对,在 4 小时周期识别已收盘 K 线的大阳 / 大阴信号,并通过 FastAPI 网页展示。 @@ -40,6 +40,13 @@ Version: `v1.3.0` - 页面增加“全部”视图,支持按市场分组或合并排序。 - 页面默认视图、市场入口、分组和版本信息可通过 `.env` 控制。 +## v1.3.1 变化 + +- Discord 汇总使用当前最新 K 线信号总数,重复扫描不会误报为 0。 +- 增加扫描进程锁,避免 Cron 任务重叠。 +- 增加 `/health` 健康检查接口。 +- TradFi 新部署默认启用实体占比 `0.5` 过滤。 + ## 项目结构 ```text @@ -92,6 +99,7 @@ cd /www/wwwroot/crypto-atr-signal python3 -m venv .venv .venv/bin/pip install -r requirements.txt cp .env.example .env +chmod 600 .env ``` ## 配置 @@ -104,7 +112,7 @@ CRYPTO_MIN_BODY_RATIO=0.5 TRADFI_ATR_LENGTH=14 TRADFI_ATR_MULTIPLE=1.5 -TRADFI_BODY_RATIO_FILTER_ENABLED=false +TRADFI_BODY_RATIO_FILTER_ENABLED=true TRADFI_MIN_BODY_RATIO=0.5 DISCORD_ENABLED=false @@ -117,6 +125,9 @@ PAGE_SHOW_TRADFI=true PAGE_GROUP_BY_MARKET=true PAGE_SHOW_VERSION=true +SCAN_LOCK_PATH=data/scanner.lock +HEALTH_MAX_SCAN_AGE_HOURS=8 + INIT_KLINES_LIMIT=40 CONCURRENCY=10 MAX_RETRIES=3 @@ -207,6 +218,77 @@ sudo systemctl start crypto-atr-signal sudo systemctl status crypto-atr-signal ``` +## Nginx 反向代理 + +FastAPI 只监听 `127.0.0.1:8000`,公网访问交给 Nginx: + +```nginx +server { + listen 80; + server_name signal.example.com; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +修改域名后执行: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +生产环境建议为域名配置 HTTPS;如果页面只供客户使用,可在 Nginx 增加访问认证。 + +## 健康检查 + +```bash +curl http://127.0.0.1:8000/health +``` + +数据库正常、Crypto 与 TradFi 最近一次扫描成功且未超过 `HEALTH_MAX_SCAN_AGE_HOURS` 时返回 HTTP `200`。数据库异常、扫描失败或超时未运行时返回 HTTP `503`,可直接接入宝塔或 Uptime Kuma。 + +## 数据库备份与恢复 + +使用 SQLite 自带的在线备份命令,不需要停止网页服务: + +```bash +cd /www/wwwroot/crypto-atr-signal +mkdir -p backups +sqlite3 data/app.db ".backup 'backups/app-$(date +%F-%H%M).db'" +``` + +恢复前先停止网页服务,并确保扫描任务没有运行: + +```bash +sudo systemctl stop crypto-atr-signal +cp backups/app-YYYY-MM-DD-HHMM.db data/app.db +sudo systemctl start crypto-atr-signal +``` + +## 常见故障排查 + +```bash +# 查看网页服务状态和最近日志 +sudo systemctl status crypto-atr-signal +sudo journalctl -u crypto-atr-signal -n 100 --no-pager + +# 手动执行一轮聚合扫描 +cd /www/wwwroot/crypto-atr-signal +.venv/bin/python scanner.py --market all + +# 检查网页和数据库健康状态 +curl -i http://127.0.0.1:8000/health +``` + +如果日志出现 `another scanner process is already running`,说明上一轮尚未结束,本轮已被进程锁安全跳过,无需手动删除锁文件。 + ## 页面功能 - Crypto / TradFi 市场切换 @@ -238,7 +320,7 @@ PRAGMA journal_mode = WAL; - FastAPI 常驻 - SQLite -- cron 每 4 小时分别扫描 Crypto 和 TradFi +- cron 每 4 小时聚合扫描 Crypto 和 TradFi - Nginx 反向代理 建议: diff --git a/VERSION b/VERSION index 18fa8e7..7574079 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.3.0 +v1.3.1 diff --git a/requirements.txt b/requirements.txt index be810b1..c733d63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ jinja2==3.1.4 uvicorn[standard]==0.30.1 tzdata==2025.2 python-dotenv==1.2.2 +filelock==3.20.3 diff --git a/scanner.py b/scanner.py index 6f95d80..f682d42 100644 --- a/scanner.py +++ b/scanner.py @@ -12,6 +12,7 @@ from typing import Any from zoneinfo import ZoneInfo import aiohttp +from filelock import FileLock, Timeout as FileLockTimeout from dotenv import load_dotenv @@ -39,13 +40,14 @@ CRYPTO_BODY_RATIO_FILTER_ENABLED = ( ) CRYPTO_MIN_BODY_RATIO = Decimal(os.getenv("CRYPTO_MIN_BODY_RATIO", str(MIN_BODY_RATIO))) TRADFI_BODY_RATIO_FILTER_ENABLED = ( - os.getenv("TRADFI_BODY_RATIO_FILTER_ENABLED", str(BODY_RATIO_FILTER_ENABLED)).lower() == "true" + os.getenv("TRADFI_BODY_RATIO_FILTER_ENABLED", "true").lower() == "true" ) TRADFI_MIN_BODY_RATIO = Decimal(os.getenv("TRADFI_MIN_BODY_RATIO", str(MIN_BODY_RATIO))) KLINES_RETENTION_PER_SYMBOL = int(os.getenv("KLINES_RETENTION_PER_SYMBOL", "500")) SIGNAL_RETENTION_DAYS = int(os.getenv("SIGNAL_RETENTION_DAYS", "90")) DISCORD_ENABLED = os.getenv("DISCORD_ENABLED", "false").lower() == "true" DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "").strip() +SCAN_LOCK_PATH = Path(os.getenv("SCAN_LOCK_PATH", "data/scanner.lock")) MARKET_CRYPTO = "CRYPTO" MARKET_TRADFI = "TRADFI" @@ -71,6 +73,7 @@ class ScanResult: symbols_failed: int updated_klines: int signals_created: int + current_signals: int elapsed_seconds: float error_summary: str | None = None @@ -652,6 +655,21 @@ def insert_scan_run( conn.commit() +def count_current_signals(conn: sqlite3.Connection, market_type: str) -> int: + row = conn.execute( + """ + SELECT COUNT(*) AS count + FROM signals + WHERE market_type = ? + AND open_time = ( + SELECT MAX(open_time) FROM klines WHERE market_type = ? + ) + """, + (market_type, market_type), + ).fetchone() + return int(row["count"]) if row else 0 + + async def fetch_missing_closed_klines( session: aiohttp.ClientSession, symbol: str, @@ -860,6 +878,7 @@ async def run_scan(market_type: str = MARKET_CRYPTO) -> ScanResult: elapsed_seconds=elapsed, latest_target_open_time=expected_open_time, ) + current_signals = count_current_signals(conn, market_type) conn.close() speed = len(symbols) / elapsed if elapsed > 0 else 0 logger.info( @@ -891,6 +910,7 @@ async def run_scan(market_type: str = MARKET_CRYPTO) -> ScanResult: symbols_failed=failures, updated_klines=updated, signals_created=signals, + current_signals=current_signals, elapsed_seconds=elapsed, ) @@ -909,12 +929,12 @@ async def send_discord_summary(results: list[ScanResult], elapsed_seconds: float market_name = "Crypto" if result.market_type == MARKET_CRYPTO else "TradFi" status = status_labels.get(result.status, result.status) lines.append( - f"{market_name}:{result.signals_created} 个信号 · {status} · " + f"{market_name}:{result.current_signals} 个信号 · {status} · " f"失败 {result.symbols_failed}" ) lines.extend( [ - f"合计:{sum(result.signals_created for result in results)} 个信号", + f"合计:{sum(result.current_signals for result in results)} 个信号", f"耗时:{elapsed_seconds:.1f} 秒", f"时间:{madrid_time.strftime('%Y-%m-%d %H:%M')} 马德里", ] @@ -955,6 +975,7 @@ async def run_all_markets() -> list[ScanResult]: symbols_failed=0, updated_klines=0, signals_created=0, + current_signals=0, elapsed_seconds=0, error_summary=str(exc), ) @@ -963,6 +984,20 @@ async def run_all_markets() -> list[ScanResult]: return results +def run_cli(selected: str) -> None: + SCAN_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + lock = FileLock(str(SCAN_LOCK_PATH)) + try: + with lock.acquire(timeout=0): + if selected == "all": + asyncio.run(run_all_markets()) + else: + market_type = MARKET_TRADFI if selected == "tradfi" else MARKET_CRYPTO + asyncio.run(run_scan(market_type)) + except FileLockTimeout: + logger.warning("Scan skipped: another scanner process is already running") + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Scan Binance ATR signals") parser.add_argument( @@ -972,8 +1007,4 @@ if __name__ == "__main__": help="Market to scan: crypto, tradfi, or all", ) args = parser.parse_args() - if args.market == "all": - asyncio.run(run_all_markets()) - else: - selected_market = MARKET_TRADFI if args.market == "tradfi" else MARKET_CRYPTO - asyncio.run(run_scan(selected_market)) + run_cli(args.market) diff --git a/webapp.py b/webapp.py index f703c2f..6a328ea 100644 --- a/webapp.py +++ b/webapp.py @@ -4,7 +4,7 @@ from urllib.parse import quote from zoneinfo import ZoneInfo from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from scanner import ( @@ -13,6 +13,7 @@ from scanner import ( MARKET_CONFIGS, MARKET_CRYPTO, MARKET_TRADFI, + count_current_signals, db_connect, init_db, ) @@ -21,8 +22,8 @@ from scanner import ( app = FastAPI(title="Crypto ATR Signal") templates = Jinja2Templates(directory="templates") MADRID_TZ = ZoneInfo("Europe/Madrid") -APP_VERSION = "v1.3.0" -APP_RELEASE_DATE = "2026-06-22" +APP_VERSION = "v1.3.1" +APP_RELEASE_DATE = "2026-06-23" APP_AUTHOR = "Z" @@ -36,6 +37,7 @@ PAGE_SHOW_CRYPTO = env_bool("PAGE_SHOW_CRYPTO", True) PAGE_SHOW_TRADFI = env_bool("PAGE_SHOW_TRADFI", True) PAGE_GROUP_BY_MARKET = env_bool("PAGE_GROUP_BY_MARKET", True) PAGE_SHOW_VERSION = env_bool("PAGE_SHOW_VERSION", True) +HEALTH_MAX_SCAN_AGE_HOURS = float(os.getenv("HEALTH_MAX_SCAN_AGE_HOURS", "8")) def fmt_decimal(value: str | None, places: int = 6) -> str: @@ -104,6 +106,75 @@ def startup() -> None: conn.close() +@app.get("/health", response_class=JSONResponse) +def health() -> JSONResponse: + checked_at = datetime.now(timezone.utc) + payload = { + "status": "ok", + "version": APP_VERSION, + "checked_at": checked_at.isoformat(), + "database": "ok", + "markets": {}, + } + healthy = True + conn = None + try: + conn = db_connect(DB_PATH) + integrity = conn.execute("PRAGMA quick_check;").fetchone()[0] + if integrity != "ok": + payload["database"] = integrity + healthy = False + + for market_type, slug in ((MARKET_CRYPTO, "crypto"), (MARKET_TRADFI, "tradfi")): + latest_run = conn.execute( + """ + SELECT status, finished_at, symbols_failed, error_summary + FROM scan_runs + WHERE market_type = ? + ORDER BY finished_at DESC + LIMIT 1 + """, + (market_type,), + ).fetchone() + if latest_run: + finished_at = datetime.fromisoformat(latest_run["finished_at"]) + age_hours = max(0.0, (checked_at - finished_at).total_seconds() / 3600) + market_healthy = ( + latest_run["status"] == "success" + and age_hours <= HEALTH_MAX_SCAN_AGE_HOURS + ) + payload["markets"][slug] = { + "status": latest_run["status"], + "last_scan_at": finished_at.isoformat(), + "age_hours": round(age_hours, 2), + "failed_symbols": latest_run["symbols_failed"], + "current_signals": count_current_signals(conn, market_type), + "error": latest_run["error_summary"], + } + else: + market_healthy = False + payload["markets"][slug] = { + "status": "unavailable", + "last_scan_at": None, + "age_hours": None, + "failed_symbols": None, + "current_signals": count_current_signals(conn, market_type), + "error": "no scan history", + } + healthy = healthy and market_healthy + except Exception as exc: # noqa: BLE001 - health endpoint must report failures as JSON. + healthy = False + payload["database"] = "error" + payload["error"] = str(exc) + finally: + if conn is not None: + conn.close() + + if not healthy: + payload["status"] = "degraded" + return JSONResponse(payload, status_code=200 if healthy else 503) + + def load_market_view( conn, market_type: str, sort_sql: str ) -> dict: