Release v1.3.1 reliability improvements

This commit is contained in:
2026-06-23 00:11:11 +02:00
parent 70121adaf6
commit 6ca66fe482
8 changed files with 224 additions and 17 deletions
+9 -2
View File
@@ -1,4 +1,4 @@
# Crypto ATR Signal v1.3.0 # Crypto ATR Signal v1.3.1
# 复制为 .env 后按需修改。true=开启,false=关闭。 # 复制为 .env 后按需修改。true=开启,false=关闭。
# 扫描脚本每次启动都会读取最新配置;网页配置修改后需要重启 FastAPI。 # 扫描脚本每次启动都会读取最新配置;网页配置修改后需要重启 FastAPI。
@@ -16,7 +16,8 @@ CRYPTO_MIN_BODY_RATIO=0.5
# TradFi 参数与 Crypto 完全独立 # TradFi 参数与 Crypto 完全独立
TRADFI_ATR_LENGTH=14 TRADFI_ATR_LENGTH=14
TRADFI_ATR_MULTIPLE=1.5 TRADFI_ATR_MULTIPLE=1.5
TRADFI_BODY_RATIO_FILTER_ENABLED=false # TradFi 默认过滤长影线和弱实体信号
TRADFI_BODY_RATIO_FILTER_ENABLED=true
TRADFI_MIN_BODY_RATIO=0.5 TRADFI_MIN_BODY_RATIO=0.5
# ==================== Discord 聚合推送 ==================== # ==================== Discord 聚合推送 ====================
@@ -50,6 +51,12 @@ HTTP_TIMEOUT=20
MAX_RETRIES=3 MAX_RETRIES=3
# Binance 返回 429/418 时的基础等待秒数 # Binance 返回 429/418 时的基础等待秒数
RATE_LIMIT_BACKOFF=30 RATE_LIMIT_BACKOFF=30
# 扫描进程锁文件;同一时间只允许一轮扫描运行
SCAN_LOCK_PATH=data/scanner.lock
# ==================== 健康检查 ====================
# 距离最近成功扫描超过此小时数时,/health 返回 HTTP 503
HEALTH_MAX_SCAN_AGE_HOURS=8
# ==================== 数据存储 ==================== # ==================== 数据存储 ====================
# 每个市场、每个品种最多保留的 K 线数量 # 每个市场、每个品种最多保留的 K 线数量
+2
View File
@@ -13,6 +13,8 @@ __pycache__/
data/*.db data/*.db
data/*.db-* data/*.db-*
data/*.pid data/*.pid
data/*.lock
backups/
*.log *.log
# Local development artifacts # Local development artifacts
+13
View File
@@ -1,5 +1,18 @@
# Changelog # 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 ## v1.3.0 - 2026-06-22
### Added ### Added
+85 -3
View File
@@ -1,6 +1,6 @@
# Crypto ATR Signal # Crypto ATR Signal
Version: `v1.3.0` Version: `v1.3.1`
扫描 Binance Futures 交易对,在 4 小时周期识别已收盘 K 线的大阳 / 大阴信号,并通过 FastAPI 网页展示。 扫描 Binance Futures 交易对,在 4 小时周期识别已收盘 K 线的大阳 / 大阴信号,并通过 FastAPI 网页展示。
@@ -40,6 +40,13 @@ Version: `v1.3.0`
- 页面增加“全部”视图,支持按市场分组或合并排序。 - 页面增加“全部”视图,支持按市场分组或合并排序。
- 页面默认视图、市场入口、分组和版本信息可通过 `.env` 控制。 - 页面默认视图、市场入口、分组和版本信息可通过 `.env` 控制。
## v1.3.1 变化
- Discord 汇总使用当前最新 K 线信号总数,重复扫描不会误报为 0。
- 增加扫描进程锁,避免 Cron 任务重叠。
- 增加 `/health` 健康检查接口。
- TradFi 新部署默认启用实体占比 `0.5` 过滤。
## 项目结构 ## 项目结构
```text ```text
@@ -92,6 +99,7 @@ cd /www/wwwroot/crypto-atr-signal
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
cp .env.example .env cp .env.example .env
chmod 600 .env
``` ```
## 配置 ## 配置
@@ -104,7 +112,7 @@ CRYPTO_MIN_BODY_RATIO=0.5
TRADFI_ATR_LENGTH=14 TRADFI_ATR_LENGTH=14
TRADFI_ATR_MULTIPLE=1.5 TRADFI_ATR_MULTIPLE=1.5
TRADFI_BODY_RATIO_FILTER_ENABLED=false TRADFI_BODY_RATIO_FILTER_ENABLED=true
TRADFI_MIN_BODY_RATIO=0.5 TRADFI_MIN_BODY_RATIO=0.5
DISCORD_ENABLED=false DISCORD_ENABLED=false
@@ -117,6 +125,9 @@ PAGE_SHOW_TRADFI=true
PAGE_GROUP_BY_MARKET=true PAGE_GROUP_BY_MARKET=true
PAGE_SHOW_VERSION=true PAGE_SHOW_VERSION=true
SCAN_LOCK_PATH=data/scanner.lock
HEALTH_MAX_SCAN_AGE_HOURS=8
INIT_KLINES_LIMIT=40 INIT_KLINES_LIMIT=40
CONCURRENCY=10 CONCURRENCY=10
MAX_RETRIES=3 MAX_RETRIES=3
@@ -207,6 +218,77 @@ sudo systemctl start crypto-atr-signal
sudo systemctl status 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 市场切换 - Crypto / TradFi 市场切换
@@ -238,7 +320,7 @@ PRAGMA journal_mode = WAL;
- FastAPI 常驻 - FastAPI 常驻
- SQLite - SQLite
- cron 每 4 小时分别扫描 Crypto 和 TradFi - cron 每 4 小时聚合扫描 Crypto 和 TradFi
- Nginx 反向代理 - Nginx 反向代理
建议: 建议:
+1 -1
View File
@@ -1 +1 @@
v1.3.0 v1.3.1
+1
View File
@@ -4,3 +4,4 @@ jinja2==3.1.4
uvicorn[standard]==0.30.1 uvicorn[standard]==0.30.1
tzdata==2025.2 tzdata==2025.2
python-dotenv==1.2.2 python-dotenv==1.2.2
filelock==3.20.3
+39 -8
View File
@@ -12,6 +12,7 @@ from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import aiohttp import aiohttp
from filelock import FileLock, Timeout as FileLockTimeout
from dotenv import load_dotenv 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))) CRYPTO_MIN_BODY_RATIO = Decimal(os.getenv("CRYPTO_MIN_BODY_RATIO", str(MIN_BODY_RATIO)))
TRADFI_BODY_RATIO_FILTER_ENABLED = ( 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))) 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")) KLINES_RETENTION_PER_SYMBOL = int(os.getenv("KLINES_RETENTION_PER_SYMBOL", "500"))
SIGNAL_RETENTION_DAYS = int(os.getenv("SIGNAL_RETENTION_DAYS", "90")) SIGNAL_RETENTION_DAYS = int(os.getenv("SIGNAL_RETENTION_DAYS", "90"))
DISCORD_ENABLED = os.getenv("DISCORD_ENABLED", "false").lower() == "true" DISCORD_ENABLED = os.getenv("DISCORD_ENABLED", "false").lower() == "true"
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "").strip() 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_CRYPTO = "CRYPTO"
MARKET_TRADFI = "TRADFI" MARKET_TRADFI = "TRADFI"
@@ -71,6 +73,7 @@ class ScanResult:
symbols_failed: int symbols_failed: int
updated_klines: int updated_klines: int
signals_created: int signals_created: int
current_signals: int
elapsed_seconds: float elapsed_seconds: float
error_summary: str | None = None error_summary: str | None = None
@@ -652,6 +655,21 @@ def insert_scan_run(
conn.commit() 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( async def fetch_missing_closed_klines(
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
symbol: str, symbol: str,
@@ -860,6 +878,7 @@ async def run_scan(market_type: str = MARKET_CRYPTO) -> ScanResult:
elapsed_seconds=elapsed, elapsed_seconds=elapsed,
latest_target_open_time=expected_open_time, latest_target_open_time=expected_open_time,
) )
current_signals = count_current_signals(conn, market_type)
conn.close() conn.close()
speed = len(symbols) / elapsed if elapsed > 0 else 0 speed = len(symbols) / elapsed if elapsed > 0 else 0
logger.info( logger.info(
@@ -891,6 +910,7 @@ async def run_scan(market_type: str = MARKET_CRYPTO) -> ScanResult:
symbols_failed=failures, symbols_failed=failures,
updated_klines=updated, updated_klines=updated,
signals_created=signals, signals_created=signals,
current_signals=current_signals,
elapsed_seconds=elapsed, 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" market_name = "Crypto" if result.market_type == MARKET_CRYPTO else "TradFi"
status = status_labels.get(result.status, result.status) status = status_labels.get(result.status, result.status)
lines.append( lines.append(
f"{market_name}{result.signals_created} 个信号 · {status} · " f"{market_name}{result.current_signals} 个信号 · {status} · "
f"失败 {result.symbols_failed}" f"失败 {result.symbols_failed}"
) )
lines.extend( 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"耗时:{elapsed_seconds:.1f}",
f"时间:{madrid_time.strftime('%Y-%m-%d %H:%M')} 马德里", f"时间:{madrid_time.strftime('%Y-%m-%d %H:%M')} 马德里",
] ]
@@ -955,6 +975,7 @@ async def run_all_markets() -> list[ScanResult]:
symbols_failed=0, symbols_failed=0,
updated_klines=0, updated_klines=0,
signals_created=0, signals_created=0,
current_signals=0,
elapsed_seconds=0, elapsed_seconds=0,
error_summary=str(exc), error_summary=str(exc),
) )
@@ -963,6 +984,20 @@ async def run_all_markets() -> list[ScanResult]:
return results 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Scan Binance ATR signals") parser = argparse.ArgumentParser(description="Scan Binance ATR signals")
parser.add_argument( parser.add_argument(
@@ -972,8 +1007,4 @@ if __name__ == "__main__":
help="Market to scan: crypto, tradfi, or all", help="Market to scan: crypto, tradfi, or all",
) )
args = parser.parse_args() args = parser.parse_args()
if args.market == "all": run_cli(args.market)
asyncio.run(run_all_markets())
else:
selected_market = MARKET_TRADFI if args.market == "tradfi" else MARKET_CRYPTO
asyncio.run(run_scan(selected_market))
+74 -3
View File
@@ -4,7 +4,7 @@ from urllib.parse import quote
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from scanner import ( from scanner import (
@@ -13,6 +13,7 @@ from scanner import (
MARKET_CONFIGS, MARKET_CONFIGS,
MARKET_CRYPTO, MARKET_CRYPTO,
MARKET_TRADFI, MARKET_TRADFI,
count_current_signals,
db_connect, db_connect,
init_db, init_db,
) )
@@ -21,8 +22,8 @@ from scanner import (
app = FastAPI(title="Crypto ATR Signal") app = FastAPI(title="Crypto ATR Signal")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
MADRID_TZ = ZoneInfo("Europe/Madrid") MADRID_TZ = ZoneInfo("Europe/Madrid")
APP_VERSION = "v1.3.0" APP_VERSION = "v1.3.1"
APP_RELEASE_DATE = "2026-06-22" APP_RELEASE_DATE = "2026-06-23"
APP_AUTHOR = "Z" 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_SHOW_TRADFI = env_bool("PAGE_SHOW_TRADFI", True)
PAGE_GROUP_BY_MARKET = env_bool("PAGE_GROUP_BY_MARKET", True) PAGE_GROUP_BY_MARKET = env_bool("PAGE_GROUP_BY_MARKET", True)
PAGE_SHOW_VERSION = env_bool("PAGE_SHOW_VERSION", 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: def fmt_decimal(value: str | None, places: int = 6) -> str:
@@ -104,6 +106,75 @@ def startup() -> None:
conn.close() 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( def load_market_view(
conn, market_type: str, sort_sql: str conn, market_type: str, sort_sql: str
) -> dict: ) -> dict: