增加k线图
This commit is contained in:
@@ -20,3 +20,5 @@ BAN_COOLDOWN_SEC=90
|
|||||||
CANDIDATE_POOL=150
|
CANDIDATE_POOL=150
|
||||||
TODAY_DATA_MODE=ticker24h
|
TODAY_DATA_MODE=ticker24h
|
||||||
YESTERDAY_DATA_MODE=klines
|
YESTERDAY_DATA_MODE=klines
|
||||||
|
CHART_KLINE_LIMIT=300
|
||||||
|
CHART_CACHE_MINUTES=60
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ git pull
|
|||||||
| `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 |
|
| `cannot pull with rebase: unstaged changes` | 执行 `git stash` 后重试;或 `DEPLOY_SKIP_GIT_PULL=1 ./deploy/pm2-deploy.sh` 跳过拉取 |
|
||||||
| `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv) |
|
| `No module named pip` | 执行 `sudo apt install -y python3-venv` 后重新 `./deploy/pm2-deploy.sh`(脚本会用 .venv) |
|
||||||
| Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` |
|
| Web 无数据 | 检查能否访问币安;国内服务器尝试 `PROXY_ENABLED=true` |
|
||||||
| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**(会加长封禁)。等待日志中的 cooldown 秒数(如 734s)后再启动;今日刷新已改为 `TODAY_DATA_MODE=ticker24h`(仅 1 次 API) |
|
| 大量 `418 I'm a teapot` | IP 被封禁;**不要反复 restart**。日 K 已存 SQLite,图表优先读本地;仅过期或首次才请求币安 |
|
||||||
| 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` |
|
| 企微收不到 | 检查 `WECOM_WEBHOOK_URL`;`curl -X POST .../api/push/test` |
|
||||||
| 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 |
|
| 08:10 未推送 | 确认容器/PM2 在 08:10 前已运行;查日志 |
|
||||||
| 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env` 中 `PORT` |
|
| 端口占用 | `ss -tlnp \| grep 21450` 或改 `.env` 中 `PORT` |
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ python run.py
|
|||||||
| `PROXY_FOR` | 代理范围 binance/wecom/all | binance |
|
| `PROXY_FOR` | 代理范围 binance/wecom/all | binance |
|
||||||
| `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 |
|
| `MAX_CONCURRENCY` | 币安 K 线并发数(过大易 418 封禁) | 3 |
|
||||||
| `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 |
|
| `CANDIDATE_POOL` | 预筛候选合约数(按 24h 成交额) | 150 |
|
||||||
|
| `CHART_KLINE_LIMIT` | 日 K 存储/展示根数 | 300 |
|
||||||
|
| `CHART_CACHE_MINUTES` | 本地日 K 视为新鲜的时间(分钟内不请求币安) | 60 |
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@@ -212,5 +212,24 @@ class BinanceFuturesClient:
|
|||||||
sym_set = set(symbols)
|
sym_set = set(symbols)
|
||||||
return {t["symbol"]: float(t["price"]) for t in tickers if t["symbol"] in sym_set}
|
return {t["symbol"]: float(t["price"]) for t in tickers if t["symbol"] in sym_set}
|
||||||
|
|
||||||
|
async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]:
|
||||||
|
raw = await self._get(
|
||||||
|
"/fapi/v1/klines",
|
||||||
|
{"symbol": symbol.upper(), "interval": "1d", "limit": min(limit, 1500)},
|
||||||
|
)
|
||||||
|
candles = []
|
||||||
|
for k in raw or []:
|
||||||
|
candles.append(
|
||||||
|
{
|
||||||
|
"time": int(k[0]),
|
||||||
|
"open": float(k[1]),
|
||||||
|
"high": float(k[2]),
|
||||||
|
"low": float(k[3]),
|
||||||
|
"close": float(k[4]),
|
||||||
|
"volume": float(k[5]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return candles
|
||||||
|
|
||||||
|
|
||||||
binance_client = BinanceFuturesClient()
|
binance_client = BinanceFuturesClient()
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
|
|||||||
# today: ticker24h=仅1次API(滚动24h); yesterday: klines=按8:00切日精确统计
|
# today: ticker24h=仅1次API(滚动24h); yesterday: klines=按8:00切日精确统计
|
||||||
today_data_mode: str = "ticker24h"
|
today_data_mode: str = "ticker24h"
|
||||||
yesterday_data_mode: str = "klines"
|
yesterday_data_mode: str = "klines"
|
||||||
|
chart_kline_limit: int = 300
|
||||||
|
chart_cache_minutes: int = 60
|
||||||
# 代理默认关闭;仅当 PROXY_ENABLED=true 时生效
|
# 代理默认关闭;仅当 PROXY_ENABLED=true 时生效
|
||||||
proxy_enabled: bool = False
|
proxy_enabled: bool = False
|
||||||
proxy_url: str = "socks5h://192.168.8.4:1081"
|
proxy_url: str = "socks5h://192.168.8.4:1081"
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ def init_db() -> None:
|
|||||||
success INTEGER NOT NULL,
|
success INTEGER NOT NULL,
|
||||||
message TEXT
|
message TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_klines (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
open_time INTEGER NOT NULL,
|
||||||
|
open REAL NOT NULL,
|
||||||
|
high REAL NOT NULL,
|
||||||
|
low REAL NOT NULL,
|
||||||
|
close REAL NOT NULL,
|
||||||
|
volume REAL NOT NULL,
|
||||||
|
quote_volume REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (symbol, open_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_klines_symbol
|
||||||
|
ON daily_klines(symbol, open_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kline_meta (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
last_fetch_at TEXT NOT NULL,
|
||||||
|
bar_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +130,92 @@ def log_push(period_start: str, period_end: str, success: bool, message: str = "
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_daily_klines(symbol: str, candles: list[dict[str, Any]]) -> None:
|
||||||
|
sym = symbol.upper()
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
with get_conn() as conn:
|
||||||
|
for c in candles:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_klines (
|
||||||
|
symbol, open_time, open, high, low, close, volume, quote_volume, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, open_time) DO UPDATE SET
|
||||||
|
open = excluded.open,
|
||||||
|
high = excluded.high,
|
||||||
|
low = excluded.low,
|
||||||
|
close = excluded.close,
|
||||||
|
volume = excluded.volume,
|
||||||
|
quote_volume = excluded.quote_volume,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sym,
|
||||||
|
int(c["time"]),
|
||||||
|
float(c["open"]),
|
||||||
|
float(c["high"]),
|
||||||
|
float(c["low"]),
|
||||||
|
float(c["close"]),
|
||||||
|
float(c.get("volume", 0)),
|
||||||
|
float(c.get("quote_volume", 0)),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO kline_meta (symbol, last_fetch_at, bar_count)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(symbol) DO UPDATE SET
|
||||||
|
last_fetch_at = excluded.last_fetch_at,
|
||||||
|
bar_count = excluded.bar_count
|
||||||
|
""",
|
||||||
|
(sym, now, len(candles)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_klines_from_db(symbol: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
sym = symbol.upper()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT open_time, open, high, low, close, volume, quote_volume
|
||||||
|
FROM daily_klines
|
||||||
|
WHERE symbol = ?
|
||||||
|
ORDER BY open_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(sym, limit),
|
||||||
|
).fetchall()
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"time": int(r["open_time"]),
|
||||||
|
"open": float(r["open"]),
|
||||||
|
"high": float(r["high"]),
|
||||||
|
"low": float(r["low"]),
|
||||||
|
"close": float(r["close"]),
|
||||||
|
"volume": float(r["volume"]),
|
||||||
|
"quote_volume": float(r["quote_volume"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_kline_meta(symbol: str) -> dict[str, Any] | None:
|
||||||
|
sym = symbol.upper()
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_fetch_at, bar_count FROM kline_meta WHERE symbol = ?",
|
||||||
|
(sym,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"last_fetch_at": row["last_fetch_at"],
|
||||||
|
"bar_count": row["bar_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def was_pushed_today(period_start: str, period_end: str) -> bool:
|
def was_pushed_today(period_start: str, period_end: str) -> bool:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""日 K 线:优先 SQLite 本地库,不足或过期再请求币安。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .binance import binance_client
|
||||||
|
from .config import settings
|
||||||
|
from .db import get_daily_klines_from_db, get_kline_meta, save_daily_klines
|
||||||
|
from .exceptions import BinanceRateLimitedError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_db_fresh(symbol: str, min_bars: int) -> bool:
|
||||||
|
meta = get_kline_meta(symbol)
|
||||||
|
if not meta or meta.get("bar_count", 0) < min_bars:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
last_fetch = datetime.fromisoformat(meta["last_fetch_at"])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
age = (datetime.now() - last_fetch).total_seconds()
|
||||||
|
return age < settings.chart_cache_minutes * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_daily_klines(symbol: str, limit: int | None = None) -> list[dict]:
|
||||||
|
"""从币安拉取并写入本地库。"""
|
||||||
|
sym = symbol.upper()
|
||||||
|
n = min(limit or settings.chart_kline_limit, 1500)
|
||||||
|
candles = await binance_client.get_daily_klines(sym, n)
|
||||||
|
save_daily_klines(sym, candles)
|
||||||
|
logger.info("Saved %d daily klines for %s to DB", len(candles), sym)
|
||||||
|
return candles
|
||||||
|
|
||||||
|
|
||||||
|
async def get_daily_candles(
|
||||||
|
symbol: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> tuple[list[dict], str]:
|
||||||
|
"""
|
||||||
|
返回 (candles, source)。
|
||||||
|
source: db | db_stale | binance
|
||||||
|
"""
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
n = min(limit or settings.chart_kline_limit, 1500)
|
||||||
|
min_bars = min(n, 50)
|
||||||
|
|
||||||
|
if not force_refresh and _is_db_fresh(sym, min_bars):
|
||||||
|
candles = get_daily_klines_from_db(sym, n)
|
||||||
|
if len(candles) >= min_bars:
|
||||||
|
return candles, "db"
|
||||||
|
|
||||||
|
stored = get_daily_klines_from_db(sym, n)
|
||||||
|
if binance_client.is_rate_limited():
|
||||||
|
if stored:
|
||||||
|
logger.warning("Rate limited, serve stale DB klines for %s", sym)
|
||||||
|
return stored, "db_stale"
|
||||||
|
raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), sym)
|
||||||
|
|
||||||
|
try:
|
||||||
|
candles = await sync_daily_klines(sym, n)
|
||||||
|
return candles, "binance"
|
||||||
|
except BinanceRateLimitedError:
|
||||||
|
if stored:
|
||||||
|
return stored, "db_stale"
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
if stored:
|
||||||
|
return stored, "db_stale"
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def prefetch_symbols(symbols: list[str]) -> None:
|
||||||
|
"""后台预拉 Top 币种日 K 入库(串行,避免 418)。"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in symbols:
|
||||||
|
sym = raw.upper().strip()
|
||||||
|
if not sym or sym in seen or not sym.endswith("USDT"):
|
||||||
|
continue
|
||||||
|
seen.add(sym)
|
||||||
|
if _is_db_fresh(sym, min(50, settings.chart_kline_limit)):
|
||||||
|
continue
|
||||||
|
if binance_client.is_rate_limited():
|
||||||
|
logger.warning("Prefetch stopped — rate limited")
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await sync_daily_klines(sym)
|
||||||
|
except BinanceRateLimitedError:
|
||||||
|
logger.warning("Prefetch rate limited at %s", sym)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Prefetch %s failed: %s", sym, e)
|
||||||
@@ -8,7 +8,9 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from .aggregator import aggregate_period, enrich_snapshot_meta
|
from .aggregator import aggregate_period, enrich_snapshot_meta
|
||||||
from .config import ROOT_DIR, settings
|
from .config import ROOT_DIR, settings
|
||||||
|
from .kline_store import get_daily_candles, sync_daily_klines
|
||||||
from .db import get_latest_snapshot, init_db, log_push
|
from .db import get_latest_snapshot, init_db, log_push
|
||||||
|
from .exceptions import BinanceRateLimitedError
|
||||||
from .periods import get_today_period, get_yesterday_period
|
from .periods import get_today_period, get_yesterday_period
|
||||||
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
|
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
|
||||||
from .state import get_today_cache
|
from .state import get_today_cache
|
||||||
@@ -128,3 +130,36 @@ async def api_refresh_yesterday():
|
|||||||
async def api_refresh_today():
|
async def api_refresh_today():
|
||||||
await job_refresh_today()
|
await job_refresh_today()
|
||||||
return get_today_cache() or {"message": "done"}
|
return get_today_cache() or {"message": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/chart/{symbol}/daily")
|
||||||
|
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
|
||||||
|
"""合约日 K 线:优先读本地 SQLite,过期再拉币安入库。"""
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
if not sym.endswith("USDT"):
|
||||||
|
raise HTTPException(400, "invalid symbol")
|
||||||
|
try:
|
||||||
|
candles, source = await get_daily_candles(sym, limit, force_refresh=refresh)
|
||||||
|
return {
|
||||||
|
"symbol": sym,
|
||||||
|
"interval": "1d",
|
||||||
|
"limit": len(candles),
|
||||||
|
"candles": candles,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
except BinanceRateLimitedError as e:
|
||||||
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("chart %s failed: %s", sym, e)
|
||||||
|
raise HTTPException(502, "K线获取失败") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chart/{symbol}/daily/refresh")
|
||||||
|
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
|
||||||
|
"""强制从币安同步日 K 到本地库。"""
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
try:
|
||||||
|
candles = await sync_daily_klines(sym, limit)
|
||||||
|
return {"symbol": sym, "saved": len(candles), "source": "binance"}
|
||||||
|
except BinanceRateLimitedError as e:
|
||||||
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushe
|
|||||||
from .exceptions import BinanceRateLimitedError
|
from .exceptions import BinanceRateLimitedError
|
||||||
from .periods import get_today_period, get_yesterday_period, now_shanghai
|
from .periods import get_today_period, get_yesterday_period, now_shanghai
|
||||||
from .state import get_today_cache, set_today_cache
|
from .state import get_today_cache, set_today_cache
|
||||||
|
from .kline_store import prefetch_symbols
|
||||||
from .wecom import build_markdown, send_wecom_markdown
|
from .wecom import build_markdown, send_wecom_markdown
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -53,6 +54,9 @@ async def job_finalize_yesterday() -> None:
|
|||||||
)
|
)
|
||||||
save_snapshot("yesterday", start, end, items)
|
save_snapshot("yesterday", start, end, items)
|
||||||
logger.info("Yesterday snapshot saved: %d items", len(items))
|
logger.info("Yesterday snapshot saved: %d items", len(items))
|
||||||
|
syms = [x["symbol"] for x in items if x.get("symbol")]
|
||||||
|
if syms:
|
||||||
|
await prefetch_symbols(syms)
|
||||||
except BinanceRateLimitedError as e:
|
except BinanceRateLimitedError as e:
|
||||||
logger.error("Finalize yesterday rate limited %ss", e.retry_after_sec)
|
logger.error("Finalize yesterday rate limited %ss", e.retry_after_sec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -113,6 +117,9 @@ async def job_refresh_today() -> None:
|
|||||||
save_snapshot("today", start, end, items)
|
save_snapshot("today", start, end, items)
|
||||||
set_today_cache(meta)
|
set_today_cache(meta)
|
||||||
logger.info("Today cache refreshed: %d items", len(items))
|
logger.info("Today cache refreshed: %d items", len(items))
|
||||||
|
syms = [x["symbol"] for x in items if x.get("symbol")]
|
||||||
|
if syms:
|
||||||
|
await prefetch_symbols(syms)
|
||||||
except BinanceRateLimitedError as e:
|
except BinanceRateLimitedError as e:
|
||||||
logger.error("Refresh today rate limited %ss — use cache", e.retry_after_sec)
|
logger.error("Refresh today rate limited %ss — use cache", e.retry_after_sec)
|
||||||
_restore_today_from_db()
|
_restore_today_from_db()
|
||||||
|
|||||||
+12
-5
@@ -88,7 +88,7 @@ function renderTable(tableId, tbody) {
|
|||||||
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="5" class="loading">暂无数据</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="loading">暂无数据</td></tr>';
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(tableId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,13 @@ function renderTable(tableId, tbody) {
|
|||||||
: idx + 1;
|
: idx + 1;
|
||||||
return `<tr class="${highlight}">
|
return `<tr class="${highlight}">
|
||||||
<td class="rank">${displayRank}</td>
|
<td class="rank">${displayRank}</td>
|
||||||
<td><strong>${row.symbol}</strong></td>
|
<td class="symbol-cell"><strong>${row.symbol}</strong></td>
|
||||||
|
<td class="chart-cell">
|
||||||
|
<div class="mini-chart" data-symbol="${row.symbol}">
|
||||||
|
<canvas width="300" height="64"></canvas>
|
||||||
|
<span class="chart-status"></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
||||||
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
||||||
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
||||||
@@ -113,6 +119,7 @@ function renderTable(tableId, tbody) {
|
|||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(tableId);
|
||||||
|
enqueueCharts(tbody);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTableData(tableId, data) {
|
function setTableData(tableId, data) {
|
||||||
@@ -206,7 +213,7 @@ document.querySelectorAll("[data-reset]").forEach((btn) => {
|
|||||||
|
|
||||||
async function loadYesterday() {
|
async function loadYesterday() {
|
||||||
const body = document.getElementById("yesterday-body");
|
const body = document.getElementById("yesterday-body");
|
||||||
body.innerHTML = '<tr><td colspan="5" class="loading">加载中…</td></tr>';
|
body.innerHTML = '<tr><td colspan="6" class="loading">加载中…</td></tr>';
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/yesterday/top30");
|
const res = await fetch("/api/yesterday/top30");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -218,7 +225,7 @@ async function loadYesterday() {
|
|||||||
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
||||||
setTableData("yesterday", data);
|
setTableData("yesterday", data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
body.innerHTML = `<tr><td colspan="6" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +243,7 @@ async function loadToday() {
|
|||||||
setTableData("today", data);
|
setTableData("today", data);
|
||||||
document.getElementById("status").textContent = "今日数据已刷新";
|
document.getElementById("status").textContent = "今日数据已刷新";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
body.innerHTML = `<tr><td colspan="5" class="error">加载失败: ${e.message}</td></tr>`;
|
body.innerHTML = `<tr><td colspan="6" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
document.getElementById("status").textContent = e.message;
|
document.getElementById("status").textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
/** 迷你日 K 线图(Canvas) + 限速队列 */
|
||||||
|
|
||||||
|
const chartDataCache = new Map();
|
||||||
|
const chartQueue = [];
|
||||||
|
let chartQueueRunning = false;
|
||||||
|
const CHART_FETCH_GAP_MS = 120;
|
||||||
|
|
||||||
|
function enqueueCharts(root) {
|
||||||
|
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
|
||||||
|
const symbol = box.dataset.symbol;
|
||||||
|
if (!symbol || box.dataset.loaded === "1" || box.dataset.loading === "1") return;
|
||||||
|
chartQueue.push(box);
|
||||||
|
});
|
||||||
|
runChartQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChartQueue() {
|
||||||
|
if (chartQueueRunning) return;
|
||||||
|
chartQueueRunning = true;
|
||||||
|
while (chartQueue.length) {
|
||||||
|
const box = chartQueue.shift();
|
||||||
|
if (!box || !box.isConnected) continue;
|
||||||
|
await loadMiniChart(box);
|
||||||
|
await sleep(CHART_FETCH_GAP_MS);
|
||||||
|
}
|
||||||
|
chartQueueRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMiniChart(box) {
|
||||||
|
const symbol = box.dataset.symbol;
|
||||||
|
if (!symbol) return;
|
||||||
|
box.dataset.loading = "1";
|
||||||
|
const canvas = box.querySelector("canvas");
|
||||||
|
const status = box.querySelector(".chart-status");
|
||||||
|
if (status) status.textContent = "加载…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let candles = chartDataCache.get(symbol);
|
||||||
|
let source = "cache";
|
||||||
|
if (!candles) {
|
||||||
|
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
candles = data.candles || [];
|
||||||
|
source = data.source || "db";
|
||||||
|
chartDataCache.set(symbol, candles);
|
||||||
|
}
|
||||||
|
if (!candles.length) throw new Error("无K线数据");
|
||||||
|
drawCandlestickChart(canvas, candles);
|
||||||
|
box.dataset.loaded = "1";
|
||||||
|
const srcLabel =
|
||||||
|
source === "db" ? "本地" : source === "db_stale" ? "本地(旧)" : source === "cache" ? "缓存" : "同步";
|
||||||
|
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
|
||||||
|
box.title = `${symbol} 最近${candles.length}根日K (${srcLabel})`;
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = "—";
|
||||||
|
box.title = `${symbol}: ${e.message}`;
|
||||||
|
drawEmptyChart(canvas);
|
||||||
|
} finally {
|
||||||
|
box.dataset.loading = "0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEmptyChart(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = "#3a4558";
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = "#8b9cb3";
|
||||||
|
ctx.font = "11px sans-serif";
|
||||||
|
ctx.fillText("暂无", 8, h / 2 + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCandlestickChart(canvas, candles) {
|
||||||
|
if (!canvas || !candles.length) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const pad = { t: 4, r: 4, b: 4, l: 4 };
|
||||||
|
const plotW = w - pad.l - pad.r;
|
||||||
|
const plotH = h - pad.t - pad.b;
|
||||||
|
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const c of candles) {
|
||||||
|
min = Math.min(min, c.low);
|
||||||
|
max = Math.max(max, c.high);
|
||||||
|
}
|
||||||
|
const range = max - min || 1;
|
||||||
|
const n = candles.length;
|
||||||
|
const step = plotW / n;
|
||||||
|
const bodyW = Math.max(1, step * 0.65);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = "#121820";
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const yOf = (price) => pad.t + plotH * (1 - (price - min) / range);
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const c = candles[i];
|
||||||
|
const up = c.close >= c.open;
|
||||||
|
const x = pad.l + i * step + step / 2;
|
||||||
|
const yHigh = yOf(c.high);
|
||||||
|
const yLow = yOf(c.low);
|
||||||
|
const yOpen = yOf(c.open);
|
||||||
|
const yClose = yOf(c.close);
|
||||||
|
const color = up ? "#0ecb81" : "#f6465d";
|
||||||
|
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, yHigh);
|
||||||
|
ctx.lineTo(x, yLow);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const top = Math.min(yOpen, yClose);
|
||||||
|
const bodyH = Math.max(1, Math.abs(yClose - yOpen));
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(x - bodyW / 2, top, bodyW, bodyH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击放大 */
|
||||||
|
function setupChartModal() {
|
||||||
|
let modal = document.getElementById("chart-modal");
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement("div");
|
||||||
|
modal.id = "chart-modal";
|
||||||
|
modal.className = "chart-modal hidden";
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="chart-modal-inner">
|
||||||
|
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
|
||||||
|
<h3 id="chart-modal-title"></h3>
|
||||||
|
<canvas id="chart-modal-canvas" width="900" height="360"></canvas>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.querySelector(".chart-modal-close").onclick = () =>
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
modal.addEventListener("click", (e) => {
|
||||||
|
if (e.target === modal) modal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener("click", (e) => {
|
||||||
|
const box = e.target.closest(".mini-chart[data-symbol]");
|
||||||
|
if (!box || box.dataset.loaded !== "1") return;
|
||||||
|
const symbol = box.dataset.symbol;
|
||||||
|
const candles = chartDataCache.get(symbol);
|
||||||
|
if (!candles) return;
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
document.getElementById("chart-modal-title").textContent =
|
||||||
|
`${symbol} · 日K ${candles.length}根`;
|
||||||
|
drawCandlestickChart(
|
||||||
|
document.getElementById("chart-modal-canvas"),
|
||||||
|
candles
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupChartModal();
|
||||||
+4
-1
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>币安 U本位合约 · 成交额排名</h1>
|
<h1>币安 U本位合约 · 成交额排名</h1>
|
||||||
<p class="subtitle">北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 点击表头可排序</p>
|
<p class="subtitle">北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 合约右侧 300 日K · 点击图表放大</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel" id="panel-yesterday">
|
<section class="panel" id="panel-yesterday">
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" data-sort="rank">排名</th>
|
<th class="sortable" data-sort="rank">排名</th>
|
||||||
<th class="sortable" data-sort="symbol">合约</th>
|
<th class="sortable" data-sort="symbol">合约</th>
|
||||||
|
<th class="chart-col">日线图</th>
|
||||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
||||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
<th class="sortable" data-sort="tags">标记</th>
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" data-sort="rank">排名</th>
|
<th class="sortable" data-sort="rank">排名</th>
|
||||||
<th class="sortable" data-sort="symbol">合约</th>
|
<th class="sortable" data-sort="symbol">合约</th>
|
||||||
|
<th class="chart-col">日线图</th>
|
||||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
||||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
<th class="sortable" data-sort="tags">标记</th>
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/charts.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+87
-1
@@ -22,7 +22,7 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1100px;
|
max-width: 1280px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,3 +218,89 @@ button:hover {
|
|||||||
.error {
|
.error {
|
||||||
color: var(--down);
|
color: var(--down);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.symbol-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-col {
|
||||||
|
min-width: 320px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-cell {
|
||||||
|
padding: 0.35rem 0.5rem !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart {
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 64px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #121820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chart canvas {
|
||||||
|
display: block;
|
||||||
|
width: 300px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-status {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 2px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-inner {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-inner h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-close {
|
||||||
|
float: right;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart-modal-canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #121820;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user