Release v1.3.0
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user