Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9f687f118 |
+9
-2
@@ -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 线数量
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 反向代理
|
||||||
|
|
||||||
建议:
|
建议:
|
||||||
|
|||||||
@@ -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
@@ -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))
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user