Release v1.3.0

This commit is contained in:
2026-06-22 22:52:21 +02:00
parent 2b63e5b1d7
commit 62c3899135
11 changed files with 2201 additions and 172 deletions
+60
View File
@@ -0,0 +1,60 @@
# Crypto ATR Signal v1.3.0
# 复制为 .env 后按需修改。true=开启,false=关闭。
# 扫描脚本每次启动都会读取最新配置;网页配置修改后需要重启 FastAPI。
# ==================== Crypto 策略 ====================
# ATR 周期长度(TradingView 默认值为 14
CRYPTO_ATR_LENGTH=14
# 信号阈值:K线 High-Low 必须达到 ATR 的多少倍
CRYPTO_ATR_MULTIPLE=1.5
# 是否启用实体占比过滤;关闭时只判断波幅与涨跌方向
CRYPTO_BODY_RATIO_FILTER_ENABLED=false
# 实体占整根 K 线波幅的最低比例,仅在上方开关为 true 时生效
CRYPTO_MIN_BODY_RATIO=0.5
# ==================== TradFi 策略 ====================
# TradFi 参数与 Crypto 完全独立
TRADFI_ATR_LENGTH=14
TRADFI_ATR_MULTIPLE=1.5
TRADFI_BODY_RATIO_FILTER_ENABLED=false
TRADFI_MIN_BODY_RATIO=0.5
# ==================== Discord 聚合推送 ====================
# 总开关。只在 scanner.py --market all 扫描完成后发送一条汇总消息
DISCORD_ENABLED=false
# Discord 频道的 Webhook 完整地址;总开关关闭时可以留空
DISCORD_WEBHOOK_URL=
# ==================== 页面显示 ====================
# 首页默认视图:all / crypto / tradfi
PAGE_DEFAULT_VIEW=all
# 是否显示“全部”入口
PAGE_SHOW_ALL=true
# 是否允许网页显示 Crypto;只影响页面,不停止 Crypto 扫描
PAGE_SHOW_CRYPTO=true
# 是否允许网页显示 TradFi;只影响页面,不停止 TradFi 扫描
PAGE_SHOW_TRADFI=true
# 全部视图是否按 Crypto / TradFi 分成两张表;false 时合并为一张表排序
PAGE_GROUP_BY_MARKET=true
# 是否在页面右上角显示版本号、发布日期和作者
PAGE_SHOW_VERSION=true
# ==================== 扫描与网络 ====================
# 首次初始化每个品种下载的历史 K 线数量
INIT_KLINES_LIMIT=40
# Binance K 线请求并发数,2C2G 建议 10-20
CONCURRENCY=10
# 单次 HTTP 请求总超时秒数
HTTP_TIMEOUT=20
# 单个请求最多重试次数
MAX_RETRIES=3
# Binance 返回 429/418 时的基础等待秒数
RATE_LIMIT_BACKOFF=30
# ==================== 数据存储 ====================
# 每个市场、每个品种最多保留的 K 线数量
KLINES_RETENTION_PER_SYMBOL=500
# 信号历史保留天数
SIGNAL_RETENTION_DAYS=90
# SQLite 数据库路径
DB_PATH=data/app.db
+21 -170
View File
@@ -1,176 +1,27 @@
# ---> Python # Secrets and local configuration
# Byte-compiled / optimized / DLL files .env
.env.*
!.env.example
# Python
.venv/
venv/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class
# C extensions # Runtime data and logs
*.so data/*.db
data/*.db-*
# Distribution / packaging data/*.pid
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff: # Local development artifacts
instance/ .agents/
.webassets-cache screenshots/
*.7z
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
+127
View File
@@ -0,0 +1,127 @@
# Changelog
## v1.3.0 - 2026-06-22
### Added
- 新增 `scanner.py --market all`,依次扫描 Crypto 与 TradFi。
- 新增 Discord Webhook 聚合推送;两个市场完成后只发送一条汇总消息。
- 首页新增“全部”视图,可按 Crypto / TradFi 分组展示,也可合并排序。
- 新增页面入口、默认视图、分组和版本信息的 `.env` 开关。
### Changed
- Discord 推送使用一个总开关,默认关闭。
- `.env.example` 按功能分区并补充中文注释。
- 页面默认进入全部市场视图。
## v1.2.1 - 2026-06-22
### Added
- 新增 `scan_runs` 表,记录每轮扫描的状态、耗时、失败数量、更新 K 线数量和信号数量。
- 页面顶部显示最近一次扫描状态。
### Changed
- TradFi 缺口逻辑改为休市友好:按 Binance 实际返回的已收盘 K 线处理,不把休市时间段强行当作漏档。
- Crypto 保持严格 4H 时间轴检查,发现缺失时间段会写入 warning 日志。
### Fixed
- 首次初始化 TradFi 时,如果当前处于休市阶段,候选信号改为 Binance 返回的最新真实已收盘 K 线。
## v1.2.0 - 2026-06-22
### Added
- 增加 TradFi 市场模块。
- 首页增加 Crypto / TradFi 市场切换。
- 扫描脚本支持 `--market crypto``--market tradfi`
- Crypto 与 TradFi 使用独立参数:
- `CRYPTO_ATR_LENGTH`
- `CRYPTO_ATR_MULTIPLE`
- `CRYPTO_BODY_RATIO_FILTER_ENABLED`
- `CRYPTO_MIN_BODY_RATIO`
- `TRADFI_ATR_LENGTH`
- `TRADFI_ATR_MULTIPLE`
- `TRADFI_BODY_RATIO_FILTER_ENABLED`
- `TRADFI_MIN_BODY_RATIO`
- 数据库表增加 `market_type` 字段。
- `symbols` 记录 Binance 返回的 `underlyingType` / `underlyingSubType`,方便区分 TradFi。
### Changed
- `symbols``klines``signals` 改为按市场隔离存储。
- 首页查询只展示当前市场的最新已收盘 K 线信号。
- Cron 推荐拆成 Crypto 和 TradFi 两条任务,避免互相影响。
### Migration
- 从 v1.1 升级时,旧数据会自动迁移为 `CRYPTO` 市场。
- 迁移后无需删除旧数据库。
### Verified
- Crypto 扫描:528 个品种,0 个失败。
- TradFi 扫描:100 个品种,0 个失败。
- Crypto 与 TradFi 页面均可独立展示。
## v1.1.0 Hotfix - 2026-06-22
### Fixed
- 修复首页混入历史信号的问题。
- 首页现在只展示数据库中最新已收盘 4H K 线对应的信号。
- `ATR倍数` 正序 / 倒序排序保留,但排序范围限制在当前最新 K 线信号内。
### Verified
- 数据库历史信号总数:`68`
- 最新 K 线信号数:`26`
- 首页当前信号显示:`26`
## v1.1.0 - 2026-06-21
### Added
- 支持 `.env` 配置。
- 网页显示马德里时间。
- 顶部显示交易对列表校对时间和最新已收盘 K 线时间。
- 币种点击跳转 TradingView。
- `ATR倍数` 表头支持点击切换正序 / 倒序。
- 扫描日志增加耗时统计和处理速度。
- 单个品种失败后跳过,不阻塞整轮扫描。
- Binance 429 / 418 限流时等待后重试。
- `exchangeInfo` 临时失败时,使用数据库缓存交易对继续扫描。
- 自动清理历史数据:
- 每个品种保留最近 `KLINES_RETENTION_PER_SYMBOL` 根 K 线。
- 信号保留最近 `SIGNAL_RETENTION_DAYS` 天。
- 可选实体占比过滤:
- `BODY_RATIO_FILTER_ENABLED`
- `MIN_BODY_RATIO`
### Changed
- 页面改为更紧凑的交易信号看板。
- 表格默认按 `ATR倍数` 倒序展示。
- 表格时间列改为 `K线收盘时间`
- 顶部说明突出当前策略参数。
### Fixed
- 已收盘 K 线判定不依赖接口数组最后一根,统一使用 `close_time <= 当前 UTC 时间`
- 首次初始化只用历史 K 线初始化 ATR,不把 40 根历史全部写成当前信号。
- 修复排序按钮独立展示造成的页面冗余。
## v1.0.0 - 2026-06-21
### Added
- 扫描 Binance USDT 永续合约交易对。
- 首次初始化拉取最近 40 根 4H 已收盘 K 线。
- 使用 TradingView 默认 ATR(14, RMA)。
- 识别大阳 / 大阴信号。
- SQLite 存储 symbols、klines、signals。
- FastAPI 网页展示信号。
- SQLite 开启 WAL。
+219 -2
View File
@@ -1,3 +1,220 @@
# crypto-atr-signal # Crypto ATR Signal
Binance 合约 ATR 大阳大阴信号扫描系统 Version: `v1.3.0`
扫描 Binance Futures 交易对,在 4 小时周期识别已收盘 K 线的大阳 / 大阴信号,并通过 FastAPI 网页展示。
## 核心逻辑
- 数据源:Binance Futures
- 市场:
- CryptoUSDT Perpetual crypto 合约
- TradFiBinance Futures 传统金融类合约
- 周期:4H
- ATRTradingView 默认 `ATR(14, RMA)`
- 默认过滤:`High - Low >= ATR * 1.5`
- 方向:
- `Close > Open`:大阳
- `Close < Open`:大阴
- 已收盘判定:只接受 `close_time <= 当前 UTC 时间` 的 K 线
- 页面时间:按 `Europe/Madrid` 显示
## v1.2 变化
- 增加 TradFi 市场模块。
- 首页增加 Crypto / TradFi 市场切换。
- Crypto 和 TradFi 使用独立参数,不共用 ATR 长度、过滤倍数、实体过滤设置。
- 数据库增加 `market_type`,旧 Crypto 数据会自动迁移为 `CRYPTO`
- 扫描命令增加 `--market crypto` / `--market tradfi`
## v1.2.1 变化
- TradFi 使用休市友好逻辑:按 Binance 实际返回的已收盘 K 线处理,不把休市时间段强行当作漏档。
- Crypto 保持严格 4H 时间轴检查。
- 新增 `scan_runs` 扫描记录表。
## v1.3.0 变化
- 增加 `--market all`,一次完成 Crypto 与 TradFi 扫描。
- 增加 Discord 聚合推送,一个总开关、每轮一条消息。
- 页面增加“全部”视图,支持按市场分组或合并排序。
- 页面默认视图、市场入口、分组和版本信息可通过 `.env` 控制。
## 项目结构
```text
crypto-atr-signal
├── scanner.py # 定时扫描:拉 K 线、补缺口、算 ATR、写 signals
├── webapp.py # FastAPI 网页展示
├── templates/
│ └── index.html
├── data/app.db # SQLite 数据库,运行后生成
├── .env.example
├── requirements.txt
├── VERSION
├── CHANGELOG.md
└── README.md
```
## 安装
```bash
cd /www/wwwroot/crypto-atr-signal
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
```
## 配置
```env
CRYPTO_ATR_LENGTH=14
CRYPTO_ATR_MULTIPLE=1.5
CRYPTO_BODY_RATIO_FILTER_ENABLED=false
CRYPTO_MIN_BODY_RATIO=0.5
TRADFI_ATR_LENGTH=14
TRADFI_ATR_MULTIPLE=1.5
TRADFI_BODY_RATIO_FILTER_ENABLED=false
TRADFI_MIN_BODY_RATIO=0.5
DISCORD_ENABLED=false
DISCORD_WEBHOOK_URL=
PAGE_DEFAULT_VIEW=all
PAGE_SHOW_ALL=true
PAGE_SHOW_CRYPTO=true
PAGE_SHOW_TRADFI=true
PAGE_GROUP_BY_MARKET=true
PAGE_SHOW_VERSION=true
INIT_KLINES_LIMIT=40
CONCURRENCY=10
MAX_RETRIES=3
RATE_LIMIT_BACKOFF=30
KLINES_RETENTION_PER_SYMBOL=500
SIGNAL_RETENTION_DAYS=90
DB_PATH=data/app.db
```
## 扫描
首次运行会为对应市场的每个品种下载最近 `INIT_KLINES_LIMIT` 根 4H 已收盘 K 线,用于初始化 ATR。
之后再次运行时:
- 没有历史数据:初始化最近 40 根 K 线
- 有历史数据且无缺口:只拉最新 2 根
- 有缺口:只补中间缺失的 K 线
- 单个品种失败:记录错误并跳过,不阻塞整轮扫描
手动扫描:
```bash
.venv/bin/python scanner.py --market all
.venv/bin/python scanner.py --market crypto
.venv/bin/python scanner.py --market tradfi
```
Discord 聚合推送只由 `--market all` 触发。单独扫描某个市场不会发送汇总消息。
## Cron
建议服务器使用 UTC 时区。Binance 4H K 线按 UTC 收盘:
```text
00:00 / 04:00 / 08:00 / 12:00 / 16:00 / 20:00 UTC
```
推荐使用一条聚合 cron
```cron
# Binance 4H close + 1 min, UTC;扫描两个市场后发送一条 Discord 汇总
1 0,4,8,12,16,20 * * * cd /www/wwwroot/crypto-atr-signal && .venv/bin/python scanner.py --market all >> scanner.log 2>&1
```
对应马德里时间:
- 夏令时:02:01 / 06:01 / 10:01 / 14:01 / 18:01 / 22:01
- 冬令时:01:01 / 05:01 / 09:01 / 13:01 / 17:01 / 21:01
## 启动网页
```bash
.venv/bin/python -m uvicorn webapp:app --host 127.0.0.1 --port 8000
```
访问:
- 全部:`http://127.0.0.1:8000/?market=all&sort=desc`
- Crypto`http://127.0.0.1:8000/?market=crypto&sort=desc`
- TradFi`http://127.0.0.1:8000/?market=tradfi&sort=desc`
## systemd 常驻网页
```ini
[Unit]
Description=Crypto ATR Signal Web
After=network.target
[Service]
WorkingDirectory=/www/wwwroot/crypto-atr-signal
ExecStart=/www/wwwroot/crypto-atr-signal/.venv/bin/python -m uvicorn webapp:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
User=www
Group=www
[Install]
WantedBy=multi-user.target
```
启用:
```bash
sudo systemctl daemon-reload
sudo systemctl enable crypto-atr-signal
sudo systemctl start crypto-atr-signal
sudo systemctl status crypto-atr-signal
```
## 页面功能
- Crypto / TradFi 市场切换
- 交易对列表校对时间
- 最新已收盘 K 线时间
- 当前信号统计
- 大阳 / 大阴数量
- 扫描品种数量
- ATR 倍数点击切换正序 / 倒序
- 点击品种跳转 TradingView
## 数据库
使用 SQLite,并开启 WAL
```sql
PRAGMA journal_mode = WAL;
```
主要表:
- `symbols`:交易对列表,按 `market_type + symbol` 区分市场
- `klines`4H K 线和 ATR,按 `market_type + symbol + open_time` 存储
- `signals`:大阳 / 大阴信号,按 `market_type + symbol + open_time` 存储
## 资源建议
独立 `2C2G` VPS 足够运行:
- FastAPI 常驻
- SQLite
- cron 每 4 小时分别扫描 Crypto 和 TradFi
- Nginx 反向代理
建议:
- `CONCURRENCY=8~10`
- 服务器时区用 UTC
- 页面显示马德里时间
- 不需要 MySQL / PostgreSQL
+118
View File
@@ -0,0 +1,118 @@
# Crypto ATR Signal v1.1 修复报告
修复日期:`2026-06-22`
当前页面版本:`v1.1.0`
## 问题概述
首页信号列表原本会展示 `signals` 表中的历史信号,而不是只展示当前最新已收盘 4H K 线的信号。
这会导致一个问题:
```text
历史 K 线中 ATR 倍数更高的信号,可能排在当前最新 K 线信号前面。
```
因此用户在首页看到的并不完全是“当前最新一根 4H K 线”的信号。
## 问题原因
原首页查询逻辑直接读取 `signals` 表:
```sql
SELECT symbol, direction, multiple, range, atr14, open_time, created_at
FROM signals
ORDER BY CAST(multiple AS REAL) DESC, open_time DESC, symbol ASC
LIMIT 300
```
该查询没有限制:
```sql
WHERE open_time = K线
```
所以历史信号会被一起展示。
## 修复方案
首页先从 `klines` 表中读取当前数据库里的最新 K 线时间:
```sql
SELECT MAX(open_time) AS open_time FROM klines
```
然后只查询该 `open_time` 对应的信号:
```sql
SELECT symbol, direction, multiple, range, atr14, open_time, created_at
FROM signals
WHERE open_time = ?
ORDER BY CAST(multiple AS REAL) DESC, open_time DESC, symbol ASC
LIMIT 300
```
ATR 倍数正序 / 倒序排序仍然保留,但排序范围只限于当前最新已收盘 K 线的信号。
## 修复结果
修复后:
```text
首页 = 当前最新已收盘 K 线信号
历史信号 = 继续保留在数据库中,但不混入首页
```
当前本地验证结果:
```text
数据库历史信号总数:68
最新 K 线信号数:26
首页当前信号:26
```
## 影响范围
影响文件:
```text
webapp.py
```
不影响:
```text
scanner.py 扫描逻辑
ATR 计算逻辑
缺口补抓逻辑
SQLite 数据结构
历史信号保存
实体占比过滤开关
数据清理逻辑
```
## 当前状态
已完成:
- 首页只展示最新已收盘 K 线信号。
- 历史信号继续保留,默认保留 `SIGNAL_RETENTION_DAYS=90` 天。
- ATR 倍数排序继续可用。
- 本地服务已重启验证。
## 后续建议
后续如果需要查看历史信号,建议单独增加:
```text
/history
```
或页面切换:
```text
当前信号 / 历史信号
```
这样首页保持清爽,历史查询也有独立入口。
+1
View File
@@ -0,0 +1 @@
v1.3.0
+10
View File
@@ -0,0 +1,10 @@
services:
web:
image: python:3.11-slim
working_dir: /app
command: sh -c "pip install -r requirements.txt && uvicorn webapp:app --host 0.0.0.0 --port 8000"
ports:
- "8000:8000"
volumes:
- .:/app
restart: unless-stopped
+6
View File
@@ -0,0 +1,6 @@
aiohttp==3.9.5
fastapi==0.111.0
jinja2==3.1.4
uvicorn[standard]==0.30.1
tzdata==2025.2
python-dotenv==1.2.2
+979
View File
@@ -0,0 +1,979 @@
import argparse
import asyncio
import logging
import os
import sqlite3
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import aiohttp
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.getenv("BINANCE_FAPI_BASE_URL", "https://fapi.binance.com")
DB_PATH = Path(os.getenv("DB_PATH", "data/app.db"))
INTERVAL = "4h"
INTERVAL_MS = 4 * 60 * 60 * 1000
ATR_LENGTH = int(os.getenv("ATR_LENGTH", "14"))
ATR_MULTIPLE = Decimal(os.getenv("ATR_MULTIPLE", "1.5"))
CRYPTO_ATR_LENGTH = int(os.getenv("CRYPTO_ATR_LENGTH", str(ATR_LENGTH)))
CRYPTO_ATR_MULTIPLE = Decimal(os.getenv("CRYPTO_ATR_MULTIPLE", str(ATR_MULTIPLE)))
TRADFI_ATR_LENGTH = int(os.getenv("TRADFI_ATR_LENGTH", str(ATR_LENGTH)))
TRADFI_ATR_MULTIPLE = Decimal(os.getenv("TRADFI_ATR_MULTIPLE", str(ATR_MULTIPLE)))
INIT_KLINES_LIMIT = int(os.getenv("INIT_KLINES_LIMIT", "40"))
CONCURRENCY = int(os.getenv("CONCURRENCY", "15"))
HTTP_TIMEOUT = int(os.getenv("HTTP_TIMEOUT", "20"))
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3"))
RATE_LIMIT_BACKOFF = int(os.getenv("RATE_LIMIT_BACKOFF", "30"))
BODY_RATIO_FILTER_ENABLED = os.getenv("BODY_RATIO_FILTER_ENABLED", "false").lower() == "true"
MIN_BODY_RATIO = Decimal(os.getenv("MIN_BODY_RATIO", "0.5"))
CRYPTO_BODY_RATIO_FILTER_ENABLED = (
os.getenv("CRYPTO_BODY_RATIO_FILTER_ENABLED", str(BODY_RATIO_FILTER_ENABLED)).lower() == "true"
)
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"
)
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()
MARKET_CRYPTO = "CRYPTO"
MARKET_TRADFI = "TRADFI"
@dataclass(frozen=True)
class MarketConfig:
market_type: str
label: str
atr_length: int
atr_multiple: Decimal
body_ratio_filter_enabled: bool
min_body_ratio: Decimal
strict_time_grid: bool
@dataclass(frozen=True)
class ScanResult:
market_type: str
label: str
status: str
symbols_total: int
symbols_failed: int
updated_klines: int
signals_created: int
elapsed_seconds: float
error_summary: str | None = None
MARKET_CONFIGS = {
MARKET_CRYPTO: MarketConfig(
market_type=MARKET_CRYPTO,
label="Binance USDT 永续",
atr_length=CRYPTO_ATR_LENGTH,
atr_multiple=CRYPTO_ATR_MULTIPLE,
body_ratio_filter_enabled=CRYPTO_BODY_RATIO_FILTER_ENABLED,
min_body_ratio=CRYPTO_MIN_BODY_RATIO,
strict_time_grid=True,
),
MARKET_TRADFI: MarketConfig(
market_type=MARKET_TRADFI,
label="Binance TradFi 永续",
atr_length=TRADFI_ATR_LENGTH,
atr_multiple=TRADFI_ATR_MULTIPLE,
body_ratio_filter_enabled=TRADFI_BODY_RATIO_FILTER_ENABLED,
min_body_ratio=TRADFI_MIN_BODY_RATIO,
strict_time_grid=False,
),
}
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger("crypto-atr-scanner")
def utc_now_ms() -> int:
return int(datetime.now(timezone.utc).timestamp() * 1000)
def latest_theoretical_closed_open_time(now_ms: int | None = None) -> int:
now_ms = utc_now_ms() if now_ms is None else now_ms
return (now_ms // INTERVAL_MS) * INTERVAL_MS - INTERVAL_MS
def ms_to_iso(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, timezone.utc).isoformat()
def decimal_from_api(value: Any) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"Invalid decimal value from API: {value!r}") from exc
def db_connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA busy_timeout = 5000;")
return conn
def init_db(conn: sqlite3.Connection) -> None:
existing_symbols = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'symbols'"
).fetchone()
if existing_symbols:
symbol_cols = [row["name"] for row in conn.execute("PRAGMA table_info(symbols)")]
if "market_type" not in symbol_cols:
migrate_v11_to_v12(conn)
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS symbols (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
status TEXT NOT NULL,
underlying_type TEXT,
underlying_subtype TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (market_type, symbol)
);
CREATE TABLE IF NOT EXISTS klines (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
open_time INTEGER NOT NULL,
open TEXT NOT NULL,
high TEXT NOT NULL,
low TEXT NOT NULL,
close TEXT NOT NULL,
close_time INTEGER NOT NULL,
atr14 TEXT,
PRIMARY KEY (market_type, symbol, open_time)
);
CREATE TABLE IF NOT EXISTS signals (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
multiple TEXT NOT NULL,
range TEXT NOT NULL,
atr14 TEXT NOT NULL,
open_time INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (market_type, symbol, open_time)
);
CREATE INDEX IF NOT EXISTS idx_klines_symbol_time
ON klines (market_type, symbol, open_time DESC);
CREATE INDEX IF NOT EXISTS idx_signals_time
ON signals (market_type, open_time DESC);
CREATE TABLE IF NOT EXISTS scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_type TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
status TEXT NOT NULL,
symbols_total INTEGER NOT NULL DEFAULT 0,
symbols_success INTEGER NOT NULL DEFAULT 0,
symbols_failed INTEGER NOT NULL DEFAULT 0,
updated_klines INTEGER NOT NULL DEFAULT 0,
signals_created INTEGER NOT NULL DEFAULT 0,
elapsed_seconds TEXT NOT NULL,
latest_target_open_time INTEGER,
error_summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_scan_runs_market_finished
ON scan_runs (market_type, finished_at DESC);
"""
)
conn.commit()
def migrate_v11_to_v12(conn: sqlite3.Connection) -> None:
logger.info("Migrating database schema to v1.2 market_type layout")
conn.executescript(
"""
ALTER TABLE symbols RENAME TO symbols_v11;
ALTER TABLE klines RENAME TO klines_v11;
ALTER TABLE signals RENAME TO signals_v11;
CREATE TABLE symbols (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
status TEXT NOT NULL,
underlying_type TEXT,
underlying_subtype TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (market_type, symbol)
);
CREATE TABLE klines (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
open_time INTEGER NOT NULL,
open TEXT NOT NULL,
high TEXT NOT NULL,
low TEXT NOT NULL,
close TEXT NOT NULL,
close_time INTEGER NOT NULL,
atr14 TEXT,
PRIMARY KEY (market_type, symbol, open_time)
);
CREATE TABLE signals (
market_type TEXT NOT NULL DEFAULT 'CRYPTO',
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
multiple TEXT NOT NULL,
range TEXT NOT NULL,
atr14 TEXT NOT NULL,
open_time INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (market_type, symbol, open_time)
);
INSERT INTO symbols (market_type, symbol, status, updated_at)
SELECT 'CRYPTO', symbol, status, updated_at FROM symbols_v11;
INSERT INTO klines (market_type, symbol, open_time, open, high, low, close, close_time, atr14)
SELECT 'CRYPTO', symbol, open_time, open, high, low, close, close_time, atr14 FROM klines_v11;
INSERT INTO signals (market_type, symbol, direction, multiple, range, atr14, open_time, created_at)
SELECT 'CRYPTO', symbol, direction, multiple, range, atr14, open_time, created_at FROM signals_v11;
DROP TABLE symbols_v11;
DROP TABLE klines_v11;
DROP TABLE signals_v11;
"""
)
conn.commit()
async def fetch_json(
session: aiohttp.ClientSession,
path: str,
params: dict[str, Any] | None = None,
) -> Any:
url = f"{BASE_URL}{path}"
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
async with session.get(url, params=params) as response:
response.raise_for_status()
return await response.json()
except aiohttp.ClientResponseError as exc:
last_error = exc
if exc.status in {418, 429}:
retry_after = exc.headers.get("Retry-After") if exc.headers else None
delay = int(retry_after) if retry_after and retry_after.isdigit() else RATE_LIMIT_BACKOFF
logger.warning(
"Rate limited attempt=%s status=%s path=%s sleep=%ss",
attempt,
exc.status,
path,
delay,
)
else:
delay = min(2**attempt, 10)
logger.warning(
"HTTP error attempt=%s status=%s path=%s error=%s",
attempt,
exc.status,
path,
exc,
)
await asyncio.sleep(delay)
except Exception as exc: # noqa: BLE001 - log and retry all transport/API errors.
last_error = exc
delay = min(2**attempt, 10)
logger.warning("Request failed attempt=%s path=%s error=%s", attempt, path, exc)
await asyncio.sleep(delay)
raise RuntimeError(f"Request failed after {MAX_RETRIES} attempts: {url}") from last_error
def normalize_subtype(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, list):
return ",".join(str(item) for item in value)
return str(value)
async def fetch_symbols(session: aiohttp.ClientSession, market_type: str) -> list[dict[str, Any]]:
exchange_info = await fetch_json(session, "/fapi/v1/exchangeInfo")
symbols: list[dict[str, Any]] = []
for item in exchange_info.get("symbols", []):
if item.get("status") != "TRADING" or item.get("quoteAsset") != "USDT":
continue
subtype = item.get("underlyingSubType") or []
is_tradfi = item.get("contractType") == "TRADIFI_PERPETUAL" or "TradFi" in subtype
is_crypto = item.get("contractType") == "PERPETUAL" and not is_tradfi
if market_type == MARKET_CRYPTO and not is_crypto:
continue
if market_type == MARKET_TRADFI and not is_tradfi:
continue
symbols.append(
{
"symbol": item["symbol"],
"underlying_type": item.get("underlyingType"),
"underlying_subtype": normalize_subtype(subtype),
}
)
return sorted(symbols, key=lambda row: row["symbol"])
async def fetch_klines(
session: aiohttp.ClientSession,
symbol: str,
limit: int = 2,
start_time: int | None = None,
end_time: int | None = None,
) -> list[list[Any]]:
params: dict[str, Any] = {"symbol": symbol, "interval": INTERVAL, "limit": limit}
if start_time is not None:
params["startTime"] = start_time
if end_time is not None:
params["endTime"] = end_time
return await fetch_json(session, "/fapi/v1/klines", params)
def closed_klines(raw_klines: list[list[Any]], now_ms: int) -> list[dict[str, Any]]:
klines: list[dict[str, Any]] = []
for item in raw_klines:
close_time = int(item[6])
if close_time > now_ms:
continue
klines.append(
{
"open_time": int(item[0]),
"open": str(item[1]),
"high": str(item[2]),
"low": str(item[3]),
"close": str(item[4]),
"close_time": close_time,
}
)
return sorted(klines, key=lambda row: row["open_time"])
def upsert_symbols(conn: sqlite3.Connection, market_type: str, symbols: list[dict[str, Any]]) -> None:
now = datetime.now(timezone.utc).isoformat()
active = {row["symbol"] for row in symbols}
for row in symbols:
conn.execute(
"""
INSERT INTO symbols (
market_type, symbol, status, underlying_type, underlying_subtype, updated_at
)
VALUES (?, ?, 'TRADING', ?, ?, ?)
ON CONFLICT(market_type, symbol) DO UPDATE SET
status = excluded.status,
underlying_type = excluded.underlying_type,
underlying_subtype = excluded.underlying_subtype,
updated_at = excluded.updated_at
""",
(
market_type,
row["symbol"],
row.get("underlying_type"),
row.get("underlying_subtype"),
now,
),
)
existing = conn.execute(
"SELECT symbol FROM symbols WHERE market_type = ?",
(market_type,),
).fetchall()
for row in existing:
if row["symbol"] not in active:
conn.execute(
"""
UPDATE symbols
SET status = 'INACTIVE', updated_at = ?
WHERE market_type = ? AND symbol = ?
""",
(now, market_type, row["symbol"]),
)
conn.commit()
def latest_kline(conn: sqlite3.Connection, market_type: str, symbol: str) -> sqlite3.Row | None:
return conn.execute(
"""
SELECT * FROM klines
WHERE market_type = ? AND symbol = ?
ORDER BY open_time DESC
LIMIT 1
""",
(market_type, symbol),
).fetchone()
def active_symbols_from_db(conn: sqlite3.Connection, market_type: str) -> list[str]:
rows = conn.execute(
"""
SELECT symbol FROM symbols
WHERE market_type = ? AND status = 'TRADING'
ORDER BY symbol ASC
""",
(market_type,),
).fetchall()
return [row["symbol"] for row in rows]
def insert_klines(
conn: sqlite3.Connection,
market_type: str,
symbol: str,
klines: list[dict[str, Any]],
) -> None:
if not klines:
return
conn.executemany(
"""
INSERT INTO klines (
market_type, symbol, open_time, open, high, low, close, close_time, atr14
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(market_type, symbol, open_time) DO UPDATE SET
open = excluded.open,
high = excluded.high,
low = excluded.low,
close = excluded.close,
close_time = excluded.close_time
""",
[
(
market_type,
symbol,
row["open_time"],
row["open"],
row["high"],
row["low"],
row["close"],
row["close_time"],
)
for row in klines
],
)
conn.commit()
def recompute_atr_and_signals(
conn: sqlite3.Connection,
config: MarketConfig,
symbol: str,
candidate_open_times: set[int],
) -> int:
rows = conn.execute(
"""
SELECT * FROM klines
WHERE market_type = ? AND symbol = ?
ORDER BY open_time ASC
""",
(config.market_type, symbol),
).fetchall()
if not rows:
return 0
prev_close: Decimal | None = None
atr: Decimal | None = None
tr_values: list[Decimal] = []
signal_count = 0
for row in rows:
high = decimal_from_api(row["high"])
low = decimal_from_api(row["low"])
close = decimal_from_api(row["close"])
open_price = decimal_from_api(row["open"])
if prev_close is None:
tr = high - low
else:
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
tr_values.append(tr)
if len(tr_values) == config.atr_length:
atr = sum(tr_values) / Decimal(config.atr_length)
elif len(tr_values) > config.atr_length and atr is not None:
atr = ((atr * Decimal(config.atr_length - 1)) + tr) / Decimal(config.atr_length)
atr_text = str(atr) if atr is not None else None
conn.execute(
"""
UPDATE klines SET atr14 = ?
WHERE market_type = ? AND symbol = ? AND open_time = ?
""",
(atr_text, config.market_type, symbol, row["open_time"]),
)
if atr is not None and row["open_time"] in candidate_open_times:
candle_range = high - low
threshold = atr * config.atr_multiple
if candle_range >= threshold and close != open_price:
if config.body_ratio_filter_enabled:
body_ratio = abs(close - open_price) / candle_range if candle_range else Decimal("0")
if body_ratio < config.min_body_ratio:
prev_close = close
continue
direction = "Bullish" if close > open_price else "Bearish"
multiple = candle_range / atr
conn.execute(
"""
INSERT INTO signals (
market_type, symbol, direction, multiple, range, atr14, open_time, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(market_type, symbol, open_time) DO UPDATE SET
direction = excluded.direction,
multiple = excluded.multiple,
range = excluded.range,
atr14 = excluded.atr14,
created_at = excluded.created_at
""",
(
config.market_type,
symbol,
direction,
str(multiple),
str(candle_range),
str(atr),
row["open_time"],
datetime.now(timezone.utc).isoformat(),
),
)
signal_count += 1
prev_close = close
conn.commit()
return signal_count
def cleanup_old_data(conn: sqlite3.Connection) -> tuple[int, int]:
deleted_klines = 0
deleted_signals = 0
if KLINES_RETENTION_PER_SYMBOL > 0:
symbols = conn.execute("SELECT DISTINCT market_type, symbol FROM klines").fetchall()
for row in symbols:
cursor = conn.execute(
"""
DELETE FROM klines
WHERE market_type = ?
AND symbol = ?
AND open_time NOT IN (
SELECT open_time FROM klines
WHERE market_type = ?
AND symbol = ?
ORDER BY open_time DESC
LIMIT ?
)
""",
(
row["market_type"],
row["symbol"],
row["market_type"],
row["symbol"],
KLINES_RETENTION_PER_SYMBOL,
),
)
deleted_klines += cursor.rowcount
if SIGNAL_RETENTION_DAYS > 0:
cutoff = utc_now_ms() - (SIGNAL_RETENTION_DAYS * 24 * 60 * 60 * 1000)
cursor = conn.execute("DELETE FROM signals WHERE open_time < ?", (cutoff,))
deleted_signals = cursor.rowcount
conn.commit()
return deleted_klines, deleted_signals
def insert_scan_run(
conn: sqlite3.Connection,
market_type: str,
started_at: str,
status: str,
symbols_total: int,
symbols_success: int,
symbols_failed: int,
updated_klines: int,
signals_created: int,
elapsed_seconds: float,
latest_target_open_time: int | None,
error_summary: str | None = None,
) -> None:
conn.execute(
"""
INSERT INTO scan_runs (
market_type, started_at, finished_at, status, symbols_total, symbols_success,
symbols_failed, updated_klines, signals_created, elapsed_seconds,
latest_target_open_time, error_summary
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
market_type,
started_at,
datetime.now(timezone.utc).isoformat(),
status,
symbols_total,
symbols_success,
symbols_failed,
updated_klines,
signals_created,
f"{elapsed_seconds:.1f}",
latest_target_open_time,
error_summary,
),
)
conn.commit()
async def fetch_missing_closed_klines(
session: aiohttp.ClientSession,
symbol: str,
start_open_time: int,
expected_open_time: int,
now_ms: int,
strict_time_grid: bool,
) -> list[dict[str, Any]]:
if start_open_time > expected_open_time:
return []
needed = ((expected_open_time - start_open_time) // INTERVAL_MS) + 1
raw = await fetch_klines(
session,
symbol,
limit=max(2, min(1500, needed + 2)),
start_time=start_open_time,
end_time=expected_open_time + INTERVAL_MS - 1,
)
rows = closed_klines(raw, now_ms)
by_open = {row["open_time"]: row for row in rows}
missing: list[dict[str, Any]] = []
missed_slots = 0
cursor = start_open_time
while cursor <= expected_open_time:
row = by_open.get(cursor)
if row is not None:
missing.append(row)
else:
missed_slots += 1
cursor += INTERVAL_MS
if strict_time_grid and missed_slots:
logger.warning(
"%s missing %s expected 4H slots between %s and %s",
symbol,
missed_slots,
ms_to_iso(start_open_time),
ms_to_iso(expected_open_time),
)
return missing
async def process_symbol(
session: aiohttp.ClientSession,
conn: sqlite3.Connection,
config: MarketConfig,
symbol: str,
semaphore: asyncio.Semaphore,
now_ms: int,
expected_open_time: int,
) -> tuple[str, int, int]:
async with semaphore:
latest = latest_kline(conn, config.market_type, symbol)
new_rows: list[dict[str, Any]]
if latest is None:
raw = await fetch_klines(session, symbol, limit=INIT_KLINES_LIMIT + 1)
new_rows = closed_klines(raw, now_ms)[-INIT_KLINES_LIMIT:]
if config.strict_time_grid:
candidate_open_times = {expected_open_time}
else:
candidate_open_times = {new_rows[-1]["open_time"]} if new_rows else set()
else:
latest_open_time = int(latest["open_time"])
if latest_open_time >= expected_open_time:
raw = await fetch_klines(session, symbol, limit=2)
rows = closed_klines(raw, now_ms)
new_rows = [row for row in rows if row["open_time"] > latest_open_time]
candidate_open_times = {row["open_time"] for row in new_rows}
else:
next_expected = latest_open_time + INTERVAL_MS
new_rows = await fetch_missing_closed_klines(
session,
symbol,
next_expected,
expected_open_time,
now_ms,
config.strict_time_grid,
)
candidate_open_times = {row["open_time"] for row in new_rows}
insert_klines(conn, config.market_type, symbol, new_rows)
signal_count = recompute_atr_and_signals(
conn,
config,
symbol,
candidate_open_times,
)
return symbol, len(new_rows), signal_count
async def process_symbol_safe(
session: aiohttp.ClientSession,
conn: sqlite3.Connection,
config: MarketConfig,
symbol: str,
semaphore: asyncio.Semaphore,
now_ms: int,
expected_open_time: int,
) -> tuple[str, int, int, bool]:
try:
symbol, inserted, signal_count = await process_symbol(
session,
conn,
config,
symbol,
semaphore,
now_ms,
expected_open_time,
)
return symbol, inserted, signal_count, True
except Exception as exc: # noqa: BLE001 - one failed symbol must not block the scan.
logger.warning("%s skipped error=%s", symbol, exc)
return symbol, 0, 0, False
async def run_scan(market_type: str = MARKET_CRYPTO) -> ScanResult:
config = MARKET_CONFIGS[market_type]
started_perf = time.perf_counter()
started_at = datetime.now(timezone.utc).isoformat()
conn = db_connect()
init_db(conn)
symbols: list[str] = []
expected_open_time: int | None = None
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
try:
symbol_rows = await fetch_symbols(session, market_type)
upsert_symbols(conn, market_type, symbol_rows)
symbols = [row["symbol"] for row in symbol_rows]
except Exception as exc: # noqa: BLE001 - fallback keeps cron useful during API hiccups.
symbols = active_symbols_from_db(conn, market_type)
if not symbols:
elapsed = time.perf_counter() - started_perf
insert_scan_run(
conn,
market_type=market_type,
started_at=started_at,
status="failed",
symbols_total=0,
symbols_success=0,
symbols_failed=0,
updated_klines=0,
signals_created=0,
elapsed_seconds=elapsed,
latest_target_open_time=None,
error_summary=f"symbol refresh failed and no cached symbols: {exc}",
)
logger.error(
"Scan aborted: symbol refresh failed and database has no cached symbols elapsed=%.1fs error=%s",
elapsed,
exc,
)
raise
logger.warning(
"Symbol refresh failed, using %s cached symbols error=%s",
len(symbols),
exc,
)
now_ms = utc_now_ms()
expected_open_time = latest_theoretical_closed_open_time(now_ms)
semaphore = asyncio.Semaphore(CONCURRENCY)
logger.info(
"Scanning market=%s symbols=%s latest_closed_open=%s atr_length=%s atr_multiple=%s strict_time_grid=%s",
market_type,
len(symbols),
ms_to_iso(expected_open_time),
config.atr_length,
config.atr_multiple,
config.strict_time_grid,
)
tasks = [
process_symbol_safe(session, conn, config, symbol, semaphore, now_ms, expected_open_time)
for symbol in symbols
]
updated = 0
signals = 0
failures = 0
success = 0
for task in asyncio.as_completed(tasks):
symbol, inserted, signal_count, ok = await task
if not ok:
failures += 1
continue
success += 1
updated += inserted
signals += signal_count
if inserted:
logger.info("%s updated_klines=%s signals=%s", symbol, inserted, signal_count)
elapsed = time.perf_counter() - started_perf
status = "success" if failures == 0 else "partial"
insert_scan_run(
conn,
market_type=market_type,
started_at=started_at,
status=status,
symbols_total=len(symbols),
symbols_success=success,
symbols_failed=failures,
updated_klines=updated,
signals_created=signals,
elapsed_seconds=elapsed,
latest_target_open_time=expected_open_time,
)
conn.close()
speed = len(symbols) / elapsed if elapsed > 0 else 0
logger.info(
"Scan done market=%s symbols=%s updated_klines=%s signals=%s failures=%s elapsed=%.1fs speed=%.1f_symbols/s",
market_type,
len(symbols),
updated,
signals,
failures,
elapsed,
speed,
)
logger.info("%.1f seconds scan complete", elapsed)
cleanup_conn = db_connect()
deleted_klines, deleted_signals = cleanup_old_data(cleanup_conn)
cleanup_conn.close()
if deleted_klines or deleted_signals:
logger.info(
"Cleanup done deleted_klines=%s deleted_signals=%s",
deleted_klines,
deleted_signals,
)
return ScanResult(
market_type=market_type,
label=config.label,
status=status,
symbols_total=len(symbols),
symbols_failed=failures,
updated_klines=updated,
signals_created=signals,
elapsed_seconds=elapsed,
)
async def send_discord_summary(results: list[ScanResult], elapsed_seconds: float) -> None:
if not DISCORD_ENABLED:
return
if not DISCORD_WEBHOOK_URL:
logger.warning("Discord notification skipped: DISCORD_WEBHOOK_URL is empty")
return
madrid_time = datetime.now(timezone.utc).astimezone(ZoneInfo("Europe/Madrid"))
status_labels = {"success": "成功", "partial": "部分失败", "failed": "失败"}
lines = ["**Crypto ATR Signal · 4H 扫描完成**", ""]
for result in results:
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"失败 {result.symbols_failed}"
)
lines.extend(
[
f"合计:{sum(result.signals_created for result in results)} 个信号",
f"耗时:{elapsed_seconds:.1f}",
f"时间:{madrid_time.strftime('%Y-%m-%d %H:%M')} 马德里",
]
)
payload = {"content": "\n".join(lines), "allowed_mentions": {"parse": []}}
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(DISCORD_WEBHOOK_URL, json=payload) as response:
if response.status not in {200, 204}:
body = await response.text()
logger.warning(
"Discord notification failed status=%s body=%s",
response.status,
body[:300],
)
return
logger.info("Discord aggregate notification sent")
except Exception as exc: # noqa: BLE001 - notification must never fail the scan.
logger.warning("Discord notification skipped error=%s", exc)
async def run_all_markets() -> list[ScanResult]:
started = time.perf_counter()
results: list[ScanResult] = []
for market_type in (MARKET_CRYPTO, MARKET_TRADFI):
try:
results.append(await run_scan(market_type))
except Exception as exc: # noqa: BLE001 - one market must not block the other.
config = MARKET_CONFIGS[market_type]
logger.error("Market scan failed market=%s error=%s", market_type, exc)
results.append(
ScanResult(
market_type=market_type,
label=config.label,
status="failed",
symbols_total=0,
symbols_failed=0,
updated_klines=0,
signals_created=0,
elapsed_seconds=0,
error_summary=str(exc),
)
)
await send_discord_summary(results, time.perf_counter() - started)
return results
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Scan Binance ATR signals")
parser.add_argument(
"--market",
choices=["crypto", "tradfi", "all"],
default="crypto",
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))
+384
View File
@@ -0,0 +1,384 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crypto ATR Signal</title>
<style>
:root {
color-scheme: light;
--bg: #f3f5f7;
--panel: #ffffff;
--panel-soft: #f8fafb;
--line: #d8dee6;
--text: #17202a;
--muted: #657184;
--bull: #087f5b;
--bear: #c92a2a;
--accent: #0b7285;
--shadow: 0 8px 18px rgba(23, 32, 42, .05);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}
main {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 22px 0 36px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
h1 {
margin: 0;
font-size: 26px;
line-height: 1.2;
letter-spacing: 0;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.tabs {
display: inline-flex;
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: var(--panel);
}
.tab {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 6px 12px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
border-right: 1px solid var(--line);
}
.tab:last-child {
border-right: 0;
}
.tab.active {
color: var(--text);
background: var(--panel-soft);
font-weight: 700;
}
.meta {
color: var(--muted);
font-size: 14px;
text-align: right;
}
.status {
display: grid;
grid-template-columns: auto auto;
gap: 5px 10px;
justify-content: end;
font-size: 12px;
line-height: 1.35;
}
.status-label {
color: var(--muted);
text-align: right;
}
.status-value {
color: var(--muted);
text-align: left;
font-variant-numeric: tabular-nums;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.stat {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
box-shadow: none;
}
.sort-heading {
color: var(--muted);
text-decoration: none;
}
.sort-heading:hover,
.sort-heading:focus {
color: var(--accent);
}
.stat-label {
color: var(--muted);
font-size: 12px;
line-height: 1.2;
margin-bottom: 7px;
}
.stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.table-wrap {
overflow-x: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.market-section + .market-section {
margin-top: 18px;
}
.market-heading {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0 0 8px;
font-size: 15px;
line-height: 1.3;
}
.market-count {
color: var(--muted);
font-size: 12px;
font-weight: 400;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
th, td {
padding: 11px 15px;
border-bottom: 1px solid var(--line);
text-align: left;
white-space: nowrap;
font-size: 14px;
}
th:last-child,
td:last-child {
width: 190px;
}
th {
color: var(--muted);
font-weight: 600;
background: var(--panel-soft);
position: sticky;
top: 0;
}
tbody tr:hover { background: #fbfcfd; }
tbody tr:last-child td { border-bottom: 0; }
.symbol {
font-weight: 700;
font-size: 15px;
}
.symbol a {
color: var(--text);
text-decoration: none;
}
.symbol a:hover,
.symbol a:focus {
color: var(--accent);
text-decoration: underline;
}
.badge {
display: inline-flex;
align-items: center;
min-width: 48px;
justify-content: center;
padding: 4px 8px;
border-radius: 6px;
font-weight: 700;
font-size: 13px;
}
.multiple {
font-weight: 800;
color: var(--accent);
}
.bullish {
color: var(--bull);
background: rgba(8, 127, 91, .1);
}
.bearish {
color: var(--bear);
background: rgba(201, 42, 42, .1);
}
.empty {
padding: 44px 16px;
color: var(--muted);
text-align: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
@media (max-width: 680px) {
main {
width: min(100% - 20px, 1180px);
padding-top: 18px;
}
header {
display: block;
}
h1 {
font-size: 23px;
margin-bottom: 8px;
}
.meta {
text-align: left;
}
.status {
justify-content: start;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Crypto ATR Signal</h1>
<div class="subtitle">
{{ strategy.market }} · {{ strategy.timeframe }} / 大阳 / 大阴 / {{ strategy.atr }} / {{ strategy.threshold }}{% if strategy.body_filter %} / {{ strategy.body_filter }}{% endif %}
</div>
<nav class="tabs" aria-label="市场切换">
{% if page_config.show_all %}<a class="tab {% if market == 'all' %}active{% endif %}" href="/?market=all&sort={{ sort_order }}">全部</a>{% endif %}
{% if page_config.show_crypto %}<a class="tab {% if market == 'crypto' %}active{% endif %}" href="/?market=crypto&sort={{ sort_order }}">Crypto</a>{% endif %}
{% if page_config.show_tradfi %}<a class="tab {% if market == 'tradfi' %}active{% endif %}" href="/?market=tradfi&sort={{ sort_order }}">TradFi</a>{% endif %}
</nav>
</div>
<div class="status" aria-label="系统状态">
<div class="status-label">交易对列表校对:</div>
<div class="status-value">{{ symbol_checked_at }}</div>
<div class="status-label">最新已收盘K线:</div>
<div class="status-value">{{ latest_closed_kline }}</div>
{% if page_config.show_version %}
<div class="status-label">Version</div>
<div class="status-value">{{ app_meta.version }} · {{ app_meta.release_date }} · by {{ app_meta.author }}</div>
{% endif %}
</div>
</header>
<section class="stats" aria-label="信号概览">
<div class="stat">
<div class="stat-label">当前信号</div>
<div class="stat-value">{{ signal_count }}</div>
</div>
<div class="stat">
<div class="stat-label">大阳</div>
<div class="stat-value">{{ bullish_count }}</div>
</div>
<div class="stat">
<div class="stat-label">大阴</div>
<div class="stat-value">{{ bearish_count }}</div>
</div>
<div class="stat">
<div class="stat-label">扫描品种</div>
<div class="stat-value">{{ active_symbols }}</div>
</div>
</section>
{% for group in groups %}
<section class="market-section">
{% if market == 'all' and page_config.group_by_market %}
<h2 class="market-heading">{{ group.label }} <span class="market-count">{{ group.signals|length }} 个信号</span></h2>
{% endif %}
{% if group.signals %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>币种</th>
<th>方向</th>
<th>ATR</th>
<th>波幅</th>
<th>
<a class="sort-heading" href="/?market={{ market }}&sort={{ next_sort_order }}" title="点击切换 ATR倍数排序">
ATR倍数 {{ sort_indicator }}
</a>
</th>
<th>K线收盘时间</th>
</tr>
</thead>
<tbody>
{% for signal in group.signals %}
<tr>
<td class="symbol">
<a href="{{ signal.tradingview_url }}" rel="noopener noreferrer">
{{ signal.symbol }}
</a>
</td>
<td><span class="badge {{ signal.direction_class }}">{{ signal.direction }}</span></td>
<td>{{ signal.atr14 }}</td>
<td>{{ signal.range }}</td>
<td class="multiple">{{ signal.multiple }}</td>
<td title="{{ signal.time_title }}">{{ signal.close_time_madrid }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">暂无信号</div>
{% endif %}
</section>
{% endfor %}
</main>
</body>
</html>
+276
View File
@@ -0,0 +1,276 @@
import os
from datetime import datetime, timezone
from urllib.parse import quote
from zoneinfo import ZoneInfo
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from scanner import (
DB_PATH,
INTERVAL_MS,
MARKET_CONFIGS,
MARKET_CRYPTO,
MARKET_TRADFI,
db_connect,
init_db,
)
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_AUTHOR = "Z"
def env_bool(name: str, default: bool) -> bool:
return os.getenv(name, str(default).lower()).lower() == "true"
PAGE_DEFAULT_VIEW = os.getenv("PAGE_DEFAULT_VIEW", "all").lower()
PAGE_SHOW_ALL = env_bool("PAGE_SHOW_ALL", True)
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)
def fmt_decimal(value: str | None, places: int = 6) -> str:
if value is None:
return "-"
number = float(value)
if abs(number) >= 100:
return f"{number:,.2f}"
if abs(number) >= 1:
return f"{number:,.4f}"
return f"{number:,.{places}f}".rstrip("0").rstrip(".")
def fmt_madrid_time(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, timezone.utc).astimezone(MADRID_TZ).strftime(
"%Y-%m-%d %H:%M"
)
def fmt_utc_time(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def fmt_scan_time(value: str | None) -> str:
if not value:
return "-"
dt = datetime.fromisoformat(value)
return dt.astimezone(MADRID_TZ).strftime("%Y-%m-%d %H:%M 马德里")
def fmt_candle_interval_title(open_time: int) -> str:
close_time = open_time + INTERVAL_MS
return (
f"K线区间:{fmt_madrid_time(open_time)} - {fmt_madrid_time(close_time)} 马德里\n"
f"UTC{fmt_utc_time(open_time)} - {fmt_utc_time(close_time)}"
)
def tradingview_url(symbol: str) -> str:
tv_symbol = quote(f"BINANCE:{symbol}.P", safe="")
return f"https://www.tradingview.com/chart/?symbol={tv_symbol}"
def scan_status_label(status: str | None) -> str:
labels = {
"success": "成功",
"partial": "部分失败",
"failed": "失败",
}
return labels.get(status or "", "-")
def multiple_level(value: str) -> str:
number = float(value)
if number >= 3:
return "extreme"
if number >= 2:
return "strong"
return "normal"
@app.on_event("startup")
def startup() -> None:
conn = db_connect(DB_PATH)
init_db(conn)
conn.close()
def load_market_view(
conn, market_type: str, sort_sql: str
) -> dict:
config = MARKET_CONFIGS[market_type]
latest_kline_time = conn.execute(
"SELECT MAX(open_time) AS open_time FROM klines WHERE market_type = ?",
(market_type,),
).fetchone()
latest_open_time = latest_kline_time["open_time"] if latest_kline_time else None
rows = conn.execute(
f"""
SELECT symbol, direction, multiple, range, atr14, open_time, created_at
FROM signals
WHERE market_type = ? AND open_time = ?
ORDER BY CAST(multiple AS REAL) {sort_sql}, open_time DESC, symbol ASC
LIMIT 300
""",
(market_type, latest_open_time),
).fetchall()
latest_scan = conn.execute(
"SELECT MAX(updated_at) AS updated_at FROM symbols WHERE market_type = ?",
(market_type,),
).fetchone()
active_symbols = conn.execute(
"""
SELECT COUNT(*) AS count FROM symbols
WHERE market_type = ? AND status = 'TRADING'
""",
(market_type,),
).fetchone()
signals = [
{
"symbol": row["symbol"],
"tradingview_url": tradingview_url(row["symbol"]),
"direction": "大阳" if row["direction"] == "Bullish" else "大阴",
"direction_class": "bullish" if row["direction"] == "Bullish" else "bearish",
"multiple": f"{float(row['multiple']):.2f}x",
"multiple_level": multiple_level(row["multiple"]),
"range": fmt_decimal(row["range"]),
"atr14": fmt_decimal(row["atr14"]),
"close_time_madrid": f"{fmt_madrid_time(row['open_time'] + INTERVAL_MS)} 马德里",
"time_title": fmt_candle_interval_title(row["open_time"]),
"created_at": row["created_at"],
}
for row in rows
]
return {
"market_type": market_type,
"market": "crypto" if market_type == MARKET_CRYPTO else "tradfi",
"label": "Crypto" if market_type == MARKET_CRYPTO else "TradFi",
"signals": signals,
"latest_open_time": latest_open_time,
"symbol_checked_at_raw": latest_scan["updated_at"] if latest_scan else None,
"symbol_checked_at": fmt_scan_time(latest_scan["updated_at"] if latest_scan else None),
"latest_closed_kline": (
f"{fmt_madrid_time(latest_open_time + INTERVAL_MS)} 马德里"
if latest_open_time is not None
else "-"
),
"active_symbols": active_symbols["count"] if active_symbols else 0,
"config": config,
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request, market: str | None = None, sort: str = "desc") -> HTMLResponse:
visible_markets = []
if PAGE_SHOW_CRYPTO:
visible_markets.append("crypto")
if PAGE_SHOW_TRADFI:
visible_markets.append("tradfi")
if not visible_markets:
visible_markets = ["crypto"]
allowed_views = (["all"] if PAGE_SHOW_ALL else []) + visible_markets
default_view = PAGE_DEFAULT_VIEW if PAGE_DEFAULT_VIEW in allowed_views else allowed_views[0]
selected_view = market.lower() if market else default_view
if selected_view not in allowed_views:
selected_view = default_view
sort_order = "asc" if sort == "asc" else "desc"
sort_sql = "ASC" if sort_order == "asc" else "DESC"
selected_market_types = (
[MARKET_CRYPTO if slug == "crypto" else MARKET_TRADFI for slug in visible_markets]
if selected_view == "all"
else [MARKET_TRADFI if selected_view == "tradfi" else MARKET_CRYPTO]
)
conn = db_connect(DB_PATH)
market_views = [load_market_view(conn, market_type, sort_sql) for market_type in selected_market_types]
conn.close()
if selected_view == "all" and not PAGE_GROUP_BY_MARKET:
combined_signals = [signal for view in market_views for signal in view["signals"]]
combined_signals.sort(
key=lambda item: (float(item["multiple"].rstrip("x")), item["symbol"]),
reverse=sort_order == "desc",
)
groups = [{"label": "全部市场", "market": "all", "signals": combined_signals}]
else:
groups = [
{"label": view["label"], "market": view["market"], "signals": view["signals"]}
for view in market_views
]
signals = [signal for group in groups for signal in group["signals"]]
if selected_view == "all":
symbol_checked_at = " · ".join(
f"{view['label']} {view['symbol_checked_at']}" for view in market_views
)
latest_closed_kline = " · ".join(
f"{view['label']} {view['latest_closed_kline']}" for view in market_views
)
strategy = {
"market": "Crypto + TradFi",
"timeframe": "4H",
"atr": "独立 ATR 参数",
"threshold": "独立倍数过滤",
"body_filter": "",
}
else:
view = market_views[0]
config = view["config"]
symbol_checked_at = view["symbol_checked_at"]
latest_closed_kline = view["latest_closed_kline"]
strategy = {
"market": config.label,
"timeframe": "4H",
"atr": f"ATR {config.atr_length} · RMA",
"threshold": f"{config.atr_multiple}倍过滤",
"body_filter": (
f"实体占比≥{config.min_body_ratio}"
if config.body_ratio_filter_enabled
else ""
),
}
return templates.TemplateResponse(
"index.html",
{
"request": request,
"groups": groups,
"signals": signals,
"signal_count": len(signals),
"bullish_count": sum(1 for item in signals if item["direction_class"] == "bullish"),
"bearish_count": sum(1 for item in signals if item["direction_class"] == "bearish"),
"active_symbols": sum(view["active_symbols"] for view in market_views),
"market": selected_view,
"sort_order": sort_order,
"next_sort_order": "asc" if sort_order == "desc" else "desc",
"sort_indicator": "" if sort_order == "desc" else "",
"symbol_checked_at": symbol_checked_at,
"latest_closed_kline": latest_closed_kline,
"strategy": strategy,
"page_config": {
"show_all": PAGE_SHOW_ALL,
"show_crypto": PAGE_SHOW_CRYPTO,
"show_tradfi": PAGE_SHOW_TRADFI,
"show_version": PAGE_SHOW_VERSION,
"group_by_market": PAGE_GROUP_BY_MARKET,
},
"app_meta": {
"version": APP_VERSION,
"release_date": APP_RELEASE_DATE,
"author": APP_AUTHOR,
},
},
)