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=关闭。 # 复制为 .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
+90 -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
@@ -53,9 +60,14 @@ crypto-atr-signal
├── requirements.txt ├── requirements.txt
├── VERSION ├── VERSION
├── CHANGELOG.md ├── CHANGELOG.md
├── ROADMAP.md
└── README.md └── README.md
``` ```
## 升级路线
后续版本规划与指标口径见 [ROADMAP.md](ROADMAP.md)。当前下一目标为 `v1.4.0`“市场温度”,当前运行版本仍为 `v1.3.1`
## 获取与更新项目 ## 获取与更新项目
推荐使用 SSH 克隆。Gitea 的 SSH 服务使用 `2222` 端口: 推荐使用 SSH 克隆。Gitea 的 SSH 服务使用 `2222` 端口:
@@ -92,6 +104,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 +117,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 +130,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 +223,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 +325,7 @@ PRAGMA journal_mode = WAL;
- FastAPI 常驻 - FastAPI 常驻
- SQLite - SQLite
- cron 每 4 小时分别扫描 Crypto 和 TradFi - cron 每 4 小时聚合扫描 Crypto 和 TradFi
- Nginx 反向代理 - 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 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: