2 Commits

Author SHA1 Message Date
mikemoi 7651a68370 Document market temperature roadmap 2026-06-23 00:43:44 +02:00
mikemoi b9f687f118 Release v1.3.1 reliability improvements 2026-06-23 00:11:11 +02:00
9 changed files with 359 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=关闭。
# 扫描脚本每次启动都会读取最新配置;网页配置修改后需要重启 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 线数量
+2
View File
@@ -13,6 +13,8 @@ __pycache__/
data/*.db
data/*.db-*
data/*.pid
data/*.lock
backups/
*.log
# Local development artifacts
+13
View File
@@ -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
+90 -3
View File
@@ -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
@@ -53,9 +60,14 @@ crypto-atr-signal
├── requirements.txt
├── VERSION
├── CHANGELOG.md
├── ROADMAP.md
└── README.md
```
## 升级路线
后续版本规划与指标口径见 [ROADMAP.md](ROADMAP.md)。当前下一目标为 `v1.4.0`“市场温度”,当前运行版本仍为 `v1.3.1`
## 获取与更新项目
推荐使用 SSH 克隆。Gitea 的 SSH 服务使用 `2222` 端口:
@@ -92,6 +104,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 +117,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 +130,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 +223,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 +325,7 @@ PRAGMA journal_mode = WAL;
- FastAPI 常驻
- SQLite
- cron 每 4 小时分别扫描 Crypto 和 TradFi
- cron 每 4 小时聚合扫描 Crypto 和 TradFi
- Nginx 反向代理
建议:
+130
View File
@@ -0,0 +1,130 @@
# Crypto ATR Signal Roadmap
当前稳定版本:`v1.3.1`
路线图用于记录已讨论但尚未实现的功能。版本号只在功能完成、验证并发布后更新。
## v1.4.0 - 市场温度
目标:增加一个只读的市场环境页面,帮助交易者判断当前适合何种风险和交易方式,不提供做多或做空信号。
### 页面与入口
- 新增 FastAPI 路由 `/insight`
- 新增 `templates/insight.html`
- 首页增加“市场温度”入口,现有首页、市场筛选和信号表保持不变。
- 默认分析 `BTCUSDT`,支持 `BTCUSDT``ETHUSDT``SOLUSDT` 切换。
- 分析周期保持 `4H`,只读取现有 SQLite 数据,不修改扫描逻辑。
### 配置
```env
INSIGHT_ENABLED=true
INSIGHT_SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
INSIGHT_DEFAULT_SYMBOL=BTCUSDT
INSIGHT_STALE_HOURS=8
```
不动态获取“市值前十”。这样可以避免稳定币、无合约资产和市值名单变化带来的额外 API 依赖。
### 单品种指标
每个品种从 `klines` 表读取最多最近 100 个非空 ATR 样本,计算:
- 当前 ATR。
- 最近 30 根有效 ATR 均值。
- 当前 ATR / 30 根均值。
- 当前 ATR 在最近最多 100 根有效 ATR 中的百分位排名。
- 最近 10 根 ATR 趋势。
- ATR / 当前收盘价百分比,用于跨币种比较相对波动。
- 最新已收盘 K 线时间、有效样本数量和数据是否过期。
数据不足 100 根时必须显示“样本不足”和实际样本数,不得把不足 100 根的结果标为“100 根百分位”。
### 百分位口径
只使用 `atr14 IS NOT NULL` 的记录:
```text
百分位 = 小于当前 ATR 的样本数 / 有效样本总数 * 100
```
相同值可按中位排名处理。页面应展示整数百分位,并保留内部原始值用于判断。
### ATR 趋势口径
对最近 10 个有效 ATR 按时间顺序做线性回归。将拟合总变化量除以 10 根 ATR 均值:
- 大于 `+5%`:上升。
- 小于 `-5%`:下降。
- 其余:震荡。
不只比较第一根和最后一根,避免单根异常值主导结论。
### 波动状态
- ATR 百分位 `< 20`:低波动压缩。
- `20 <= ATR 百分位 < 70`:正常波动。
- `70 <= ATR 百分位 < 90`:高波动。
- ATR 百分位 `>= 90`:极端波动。
页面中文解释必须强调:ATR 衡量波动强弱,不预测价格上涨或下跌。
### 市场温度总览
总览结合以下数据,而不是只看 ATR:
- BTC、ETH、SOL 的 ATR 百分位与 ATR 趋势。
- 三个品种最近 24 小时价格涨跌,按 6 根 4H 已收盘 K 线计算。
- Crypto 市场最近 24 小时上涨品种比例,作为市场广度。
- SOL 相对 BTC 的 24 小时表现,作为风险偏好辅助指标。
输出状态限定为:
- `稳定偏多`:价格整体上涨,波动处于低至正常区间。
- `亢奋`:价格整体上涨,波动处于高或极端区间。
- `恐慌`:价格整体下跌,波动处于高或极端区间。
- `低波动压缩`:价格横盘且波动处于低位。
- `分化`:主要品种方向或市场广度明显不一致。
- `中性`:不满足以上明确条件。
具体评分阈值在实现时写成纯函数并增加测试,页面同时展示组成指标,避免只给不可解释的单一结论。
### 交易者提示
页面输出风险与策略环境提示,例如:
```text
当前处于高波动上升阶段。信号仍可观察,但建议降低仓位,避免使用过窄止损。
```
提示只用于仓位、止损和策略环境管理,不构成买卖建议。
### 验收标准
- `/insight` 不请求外部 API。
- 不修改 `scanner.py` 的 K 线扫描、ATR 或信号逻辑。
- BTC、ETH、SOL 可通过 `.env` 增减,不需要修改模板。
- 样本不足、ATR 为空、品种不存在和数据过期均有明确中文状态。
- 百分位、趋势、24 小时涨跌和市场广度使用自动化测试验证。
- 桌面与移动页面无内容重叠,入口不破坏现有首页布局。
## v1.4.1 - 验证与稳定
- 根据实际运行数据校准市场温度阈值,不改变指标定义。
- 增加 `/insight` 查询耗时记录和轻量缓存。
- 补充数据缺失、休市、品种下架和异常 ATR 的边界测试。
- 根据客户反馈优化中文解释与风险提示。
- 验证 Discord、Cron、健康检查与市场温度页面长期并行运行。
## v1.5.0 - 候选方向
以下只作为候选,不提前承诺:
- Discord 推送中增加一行市场温度摘要。
- 市场温度历史变化图。
- `1H``4H``1D` 多周期环境对照。
- 市场温度历史回测与策略表现关联分析。
- Telegram、Bybit 或 OKX 扩展。
是否进入 v1.5.0 取决于 v1.4.x 的实际使用价值和客户反馈。
+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
tzdata==2025.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
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)
+74 -3
View File
@@ -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: