增加资金费率
This commit is contained in:
@@ -22,3 +22,5 @@ TODAY_DATA_MODE=ticker24h
|
|||||||
YESTERDAY_DATA_MODE=klines
|
YESTERDAY_DATA_MODE=klines
|
||||||
CHART_KLINE_LIMIT=300
|
CHART_KLINE_LIMIT=300
|
||||||
CHART_CACHE_MINUTES=60
|
CHART_CACHE_MINUTES=60
|
||||||
|
FUNDING_HISTORY_LIMIT=90
|
||||||
|
FUNDING_CACHE_MINUTES=30
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from .binance import binance_client
|
from .binance import binance_client
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .exceptions import BinanceRateLimitedError
|
from .exceptions import BinanceRateLimitedError
|
||||||
|
from .funding_store import enrich_items_with_funding
|
||||||
from .periods import to_ms
|
from .periods import to_ms
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,14 +39,15 @@ def format_volume(vol: float) -> str:
|
|||||||
return f"{vol:.0f}"
|
return f"{vol:.0f}"
|
||||||
|
|
||||||
|
|
||||||
def _finalize_top(stats: list[SymbolStats]) -> list[dict]:
|
async def _finalize_top(stats: list[SymbolStats]) -> list[dict]:
|
||||||
stats.sort(key=lambda x: x.quote_volume, reverse=True)
|
stats.sort(key=lambda x: x.quote_volume, reverse=True)
|
||||||
top = stats[: settings.top_n]
|
top = stats[: settings.top_n]
|
||||||
for i, s in enumerate(top, 1):
|
for i, s in enumerate(top, 1):
|
||||||
s.rank = i
|
s.rank = i
|
||||||
s.is_high_volume = s.quote_volume >= settings.volume_threshold
|
s.is_high_volume = s.quote_volume >= settings.volume_threshold
|
||||||
s.is_high_change = abs(s.price_change_pct) >= settings.change_threshold
|
s.is_high_change = abs(s.price_change_pct) >= settings.change_threshold
|
||||||
return [s.to_dict() for s in top]
|
items = [s.to_dict() for s in top]
|
||||||
|
return await enrich_items_with_funding(items)
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_klines(klines: list, start_ms: int, end_ms: int) -> tuple[float, float, float]:
|
def _aggregate_klines(klines: list, start_ms: int, end_ms: int) -> tuple[float, float, float]:
|
||||||
@@ -87,7 +89,7 @@ async def aggregate_from_ticker24hr() -> list[dict]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info("ticker24h mode: %d symbols, 1 API call", len(stats))
|
logger.info("ticker24h mode: %d symbols, 1 API call", len(stats))
|
||||||
return _finalize_top(stats)
|
return await _finalize_top(stats)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_symbol_stats(
|
async def _fetch_symbol_stats(
|
||||||
@@ -177,7 +179,7 @@ async def aggregate_period_klines(
|
|||||||
)
|
)
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
stats = [r for r in results if r is not None and r.quote_volume > 0]
|
stats = [r for r in results if r is not None and r.quote_volume > 0]
|
||||||
return _finalize_top(stats)
|
return await _finalize_top(stats)
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_period(
|
async def aggregate_period(
|
||||||
|
|||||||
@@ -212,6 +212,30 @@ 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_premium_index_all(self) -> dict[str, dict]:
|
||||||
|
data = await self._get("/fapi/v1/premiumIndex")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
items = [data]
|
||||||
|
else:
|
||||||
|
items = data or []
|
||||||
|
return {str(x["symbol"]).upper(): x for x in items if x.get("symbol")}
|
||||||
|
|
||||||
|
async def get_funding_rate_history(self, symbol: str, limit: int = 90) -> list[dict]:
|
||||||
|
raw = await self._get(
|
||||||
|
"/fapi/v1/fundingRate",
|
||||||
|
{"symbol": symbol.upper(), "limit": min(limit, 1000)},
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for r in raw or []:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"time": int(r["fundingTime"]),
|
||||||
|
"rate": float(r["fundingRate"]),
|
||||||
|
"mark_price": float(r.get("markPrice", 0) or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]:
|
async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]:
|
||||||
raw = await self._get(
|
raw = await self._get(
|
||||||
"/fapi/v1/klines",
|
"/fapi/v1/klines",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
|
|||||||
yesterday_data_mode: str = "klines"
|
yesterday_data_mode: str = "klines"
|
||||||
chart_kline_limit: int = 300
|
chart_kline_limit: int = 300
|
||||||
chart_cache_minutes: int = 60
|
chart_cache_minutes: int = 60
|
||||||
|
funding_history_limit: int = 90
|
||||||
|
funding_cache_minutes: int = 30
|
||||||
# 代理默认关闭;仅当 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"
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ def init_db() -> None:
|
|||||||
last_fetch_at TEXT NOT NULL,
|
last_fetch_at TEXT NOT NULL,
|
||||||
bar_count INTEGER NOT NULL
|
bar_count INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS funding_history (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
funding_time INTEGER NOT NULL,
|
||||||
|
funding_rate REAL NOT NULL,
|
||||||
|
mark_price REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (symbol, funding_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_funding_history_symbol
|
||||||
|
ON funding_history(symbol, funding_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS funding_meta (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
last_fetch_at TEXT NOT NULL,
|
||||||
|
bar_count INTEGER NOT NULL,
|
||||||
|
last_funding_rate REAL NOT NULL DEFAULT 0,
|
||||||
|
next_funding_time INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS funding_current (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
last_funding_rate REAL NOT NULL,
|
||||||
|
next_funding_time INTEGER NOT NULL,
|
||||||
|
mark_price REAL NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +244,115 @@ def get_kline_meta(symbol: str) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_funding_history(
|
||||||
|
symbol: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
next_funding_time: int = 0,
|
||||||
|
) -> None:
|
||||||
|
sym = symbol.upper()
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
last_rate = 0.0
|
||||||
|
next_time = next_funding_time
|
||||||
|
with get_conn() as conn:
|
||||||
|
for r in rows:
|
||||||
|
ft = int(r["time"])
|
||||||
|
rate = float(r["rate"])
|
||||||
|
mp = float(r.get("mark_price", 0))
|
||||||
|
last_rate = rate
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO funding_history (symbol, funding_time, funding_rate, mark_price, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, funding_time) DO UPDATE SET
|
||||||
|
funding_rate = excluded.funding_rate,
|
||||||
|
mark_price = excluded.mark_price,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(sym, ft, rate, mp, now),
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO funding_meta (symbol, last_fetch_at, bar_count, last_funding_rate, next_funding_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol) DO UPDATE SET
|
||||||
|
last_fetch_at = excluded.last_fetch_at,
|
||||||
|
bar_count = excluded.bar_count,
|
||||||
|
last_funding_rate = excluded.last_funding_rate
|
||||||
|
""",
|
||||||
|
(sym, now, len(rows), last_rate, next_time),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_funding_history_from_db(symbol: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
sym = symbol.upper()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT funding_time, funding_rate, mark_price
|
||||||
|
FROM funding_history
|
||||||
|
WHERE symbol = ?
|
||||||
|
ORDER BY funding_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(sym, limit),
|
||||||
|
).fetchall()
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"time": int(r["funding_time"]),
|
||||||
|
"rate": float(r["funding_rate"]),
|
||||||
|
"rate_pct": float(r["funding_rate"]) * 100,
|
||||||
|
"mark_price": float(r["mark_price"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_funding_meta(symbol: str) -> dict[str, Any] | None:
|
||||||
|
sym = symbol.upper()
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT last_fetch_at, bar_count, last_funding_rate, next_funding_time
|
||||||
|
FROM funding_meta WHERE symbol = ?
|
||||||
|
""",
|
||||||
|
(sym,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"last_fetch_at": row["last_fetch_at"],
|
||||||
|
"bar_count": row["bar_count"],
|
||||||
|
"last_funding_rate": row["last_funding_rate"],
|
||||||
|
"next_funding_time": row["next_funding_time"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_funding_current_bulk(data: dict[str, dict[str, Any]]) -> None:
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
with get_conn() as conn:
|
||||||
|
for sym, info in data.items():
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO funding_current (symbol, last_funding_rate, next_funding_time, mark_price, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol) DO UPDATE SET
|
||||||
|
last_funding_rate = excluded.last_funding_rate,
|
||||||
|
next_funding_time = excluded.next_funding_time,
|
||||||
|
mark_price = excluded.mark_price,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
sym.upper(),
|
||||||
|
float(info.get("lastFundingRate", 0) or 0),
|
||||||
|
int(info.get("nextFundingTime", 0) or 0),
|
||||||
|
float(info.get("markPrice", 0) or 0),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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,160 @@
|
|||||||
|
"""资金费率:当前 premiumIndex + 历史 fundingRate 存 SQLite。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .binance import binance_client
|
||||||
|
from .config import settings
|
||||||
|
from .db import (
|
||||||
|
get_funding_history_from_db,
|
||||||
|
get_funding_meta,
|
||||||
|
save_funding_current_bulk,
|
||||||
|
save_funding_history,
|
||||||
|
)
|
||||||
|
from .exceptions import BinanceRateLimitedError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_premium_cache: dict[str, Any] = {}
|
||||||
|
_premium_cache_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _premium_ttl_sec() -> int:
|
||||||
|
return settings.funding_cache_minutes * 60
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_funding_map(force: bool = False) -> dict[str, dict]:
|
||||||
|
"""全市场当前资金费率(一次 premiumIndex)。"""
|
||||||
|
global _premium_cache, _premium_cache_at
|
||||||
|
now = time.time()
|
||||||
|
if not force and _premium_cache and now - _premium_cache_at < _premium_ttl_sec():
|
||||||
|
return _premium_cache
|
||||||
|
|
||||||
|
if binance_client.is_rate_limited():
|
||||||
|
if _premium_cache:
|
||||||
|
return _premium_cache
|
||||||
|
raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), "premiumIndex")
|
||||||
|
|
||||||
|
data = await binance_client.get_premium_index_all()
|
||||||
|
_premium_cache = data
|
||||||
|
_premium_cache_at = now
|
||||||
|
save_funding_current_bulk(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _is_history_fresh(symbol: str, min_bars: int) -> bool:
|
||||||
|
meta = get_funding_meta(symbol)
|
||||||
|
if not meta or meta.get("bar_count", 0) < min_bars:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(meta["last_fetch_at"])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return (datetime.now() - last).total_seconds() < _premium_ttl_sec()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_funding_history(symbol: str, limit: int | None = None) -> list[dict]:
|
||||||
|
sym = symbol.upper()
|
||||||
|
n = min(limit or settings.funding_history_limit, 1000)
|
||||||
|
rows = await binance_client.get_funding_rate_history(sym, n)
|
||||||
|
try:
|
||||||
|
cur_map = await get_current_funding_map()
|
||||||
|
nft = int(cur_map.get(sym, {}).get("nextFundingTime", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
nft = 0
|
||||||
|
save_funding_history(sym, rows, next_funding_time=nft)
|
||||||
|
logger.info("Saved %d funding records for %s", len(rows), sym)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
async def get_funding_bundle(
|
||||||
|
symbol: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sym = symbol.upper()
|
||||||
|
n = min(limit or settings.funding_history_limit, 1000)
|
||||||
|
min_bars = min(n, 10)
|
||||||
|
|
||||||
|
current_map = await get_current_funding_map()
|
||||||
|
cur = current_map.get(sym, {})
|
||||||
|
current = {
|
||||||
|
"rate": float(cur.get("lastFundingRate", 0) or 0),
|
||||||
|
"rate_pct": float(cur.get("lastFundingRate", 0) or 0) * 100,
|
||||||
|
"next_funding_time": int(cur.get("nextFundingTime", 0) or 0),
|
||||||
|
"mark_price": float(cur.get("markPrice", 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not force_refresh and _is_history_fresh(sym, min_bars):
|
||||||
|
history = get_funding_history_from_db(sym, n)
|
||||||
|
if len(history) >= min_bars:
|
||||||
|
return {
|
||||||
|
"symbol": sym,
|
||||||
|
"current": current,
|
||||||
|
"history": history,
|
||||||
|
"source": "db",
|
||||||
|
}
|
||||||
|
|
||||||
|
stored = get_funding_history_from_db(sym, n)
|
||||||
|
if binance_client.is_rate_limited():
|
||||||
|
if stored:
|
||||||
|
return {"symbol": sym, "current": current, "history": stored, "source": "db_stale"}
|
||||||
|
raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), sym)
|
||||||
|
|
||||||
|
try:
|
||||||
|
history = await sync_funding_history(sym, n)
|
||||||
|
return {"symbol": sym, "current": current, "history": history, "source": "binance"}
|
||||||
|
except BinanceRateLimitedError:
|
||||||
|
if stored:
|
||||||
|
return {"symbol": sym, "current": current, "history": stored, "source": "db_stale"}
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
if stored:
|
||||||
|
return {"symbol": sym, "current": current, "history": stored, "source": "db_stale"}
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_items_with_funding(items: list[dict]) -> list[dict]:
|
||||||
|
try:
|
||||||
|
current_map = await get_current_funding_map()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Funding current map failed: %s", e)
|
||||||
|
current_map = {}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
sym = item.get("symbol", "")
|
||||||
|
info = current_map.get(sym, {})
|
||||||
|
rate = float(info.get("lastFundingRate", 0) or 0)
|
||||||
|
item["funding_rate"] = rate
|
||||||
|
item["funding_rate_pct"] = rate * 100
|
||||||
|
item["funding_rate_fmt"] = f"{rate * 100:.4f}%"
|
||||||
|
nft = info.get("nextFundingTime")
|
||||||
|
item["next_funding_time"] = int(nft) if nft else None
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def prefetch_funding(symbols: list[str]) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
try:
|
||||||
|
await get_current_funding_map()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Prefetch premiumIndex failed: %s", e)
|
||||||
|
|
||||||
|
for raw in symbols:
|
||||||
|
sym = raw.upper().strip()
|
||||||
|
if not sym or sym in seen:
|
||||||
|
continue
|
||||||
|
seen.add(sym)
|
||||||
|
if _is_history_fresh(sym, 10):
|
||||||
|
continue
|
||||||
|
if binance_client.is_rate_limited():
|
||||||
|
logger.warning("Prefetch funding stopped — rate limited")
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await sync_funding_history(sym)
|
||||||
|
except BinanceRateLimitedError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Prefetch funding %s failed: %s", sym, e)
|
||||||
+18
-1
@@ -8,6 +8,7 @@ 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 .funding_store import enrich_items_with_funding, get_funding_bundle
|
||||||
from .kline_store import get_daily_candles, sync_daily_klines
|
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 .exceptions import BinanceRateLimitedError
|
||||||
@@ -62,6 +63,7 @@ async def index():
|
|||||||
async def api_yesterday_top30():
|
async def api_yesterday_top30():
|
||||||
snap = get_latest_snapshot("yesterday")
|
snap = get_latest_snapshot("yesterday")
|
||||||
if snap:
|
if snap:
|
||||||
|
items = await enrich_items_with_funding(snap["items"])
|
||||||
return {
|
return {
|
||||||
"period_start": snap["period_start"],
|
"period_start": snap["period_start"],
|
||||||
"period_end": snap["period_end"],
|
"period_end": snap["period_end"],
|
||||||
@@ -69,7 +71,7 @@ async def api_yesterday_top30():
|
|||||||
"top_n": settings.top_n,
|
"top_n": settings.top_n,
|
||||||
"volume_threshold": settings.volume_threshold,
|
"volume_threshold": settings.volume_threshold,
|
||||||
"change_threshold": settings.change_threshold,
|
"change_threshold": settings.change_threshold,
|
||||||
"items": snap["items"],
|
"items": items,
|
||||||
}
|
}
|
||||||
start, end = get_yesterday_period()
|
start, end = get_yesterday_period()
|
||||||
try:
|
try:
|
||||||
@@ -87,6 +89,7 @@ async def api_yesterday_top30():
|
|||||||
async def api_today_top30():
|
async def api_today_top30():
|
||||||
cached = get_today_cache()
|
cached = get_today_cache()
|
||||||
if cached:
|
if cached:
|
||||||
|
cached["items"] = await enrich_items_with_funding(cached.get("items", []))
|
||||||
return cached
|
return cached
|
||||||
start, end = get_today_period()
|
start, end = get_today_period()
|
||||||
try:
|
try:
|
||||||
@@ -154,6 +157,20 @@ async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool =
|
|||||||
raise HTTPException(502, "K线获取失败") from e
|
raise HTTPException(502, "K线获取失败") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/funding/{symbol}/history")
|
||||||
|
async def api_funding_history(symbol: str, limit: int | None = None, refresh: bool = False):
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
if not sym.endswith("USDT"):
|
||||||
|
raise HTTPException(400, "invalid symbol")
|
||||||
|
try:
|
||||||
|
return await get_funding_bundle(sym, limit, force_refresh=refresh)
|
||||||
|
except BinanceRateLimitedError as e:
|
||||||
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("funding %s failed: %s", sym, e)
|
||||||
|
raise HTTPException(502, "资金费率获取失败") from e
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chart/{symbol}/daily/refresh")
|
@app.post("/api/chart/{symbol}/daily/refresh")
|
||||||
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
|
async def api_chart_daily_refresh(symbol: str, limit: int | None = None):
|
||||||
"""强制从币安同步日 K 到本地库。"""
|
"""强制从币安同步日 K 到本地库。"""
|
||||||
|
|||||||
@@ -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 .funding_store import prefetch_funding
|
||||||
from .kline_store import prefetch_symbols
|
from .kline_store import prefetch_symbols
|
||||||
from .wecom import build_markdown, send_wecom_markdown
|
from .wecom import build_markdown, send_wecom_markdown
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ async def job_finalize_yesterday() -> None:
|
|||||||
syms = [x["symbol"] for x in items if x.get("symbol")]
|
syms = [x["symbol"] for x in items if x.get("symbol")]
|
||||||
if syms:
|
if syms:
|
||||||
await prefetch_symbols(syms)
|
await prefetch_symbols(syms)
|
||||||
|
await prefetch_funding(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:
|
||||||
@@ -120,6 +122,7 @@ async def job_refresh_today() -> None:
|
|||||||
syms = [x["symbol"] for x in items if x.get("symbol")]
|
syms = [x["symbol"] for x in items if x.get("symbol")]
|
||||||
if syms:
|
if syms:
|
||||||
await prefetch_symbols(syms)
|
await prefetch_symbols(syms)
|
||||||
|
await prefetch_funding(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()
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def build_markdown(snapshot: dict) -> str:
|
|||||||
f"> 统计周期(北京时间 8:00 切日)",
|
f"> 统计周期(北京时间 8:00 切日)",
|
||||||
f"> **{period_label}**",
|
f"> **{period_label}**",
|
||||||
"",
|
"",
|
||||||
"| 排名 | 合约 | 成交额(USDT) | 涨跌幅 | 标记 |",
|
"| 排名 | 合约 | 成交额(USDT) | 涨跌幅 | 资金费率 | 标记 |",
|
||||||
"| --- | --- | --- | --- | --- |",
|
"| --- | --- | --- | --- | --- | --- |",
|
||||||
]
|
]
|
||||||
for row in items:
|
for row in items:
|
||||||
tags = []
|
tags = []
|
||||||
@@ -37,8 +37,9 @@ def build_markdown(snapshot: dict) -> str:
|
|||||||
tag_str = " ".join(tags) if tags else "-"
|
tag_str = " ".join(tags) if tags else "-"
|
||||||
vol = row.get("quote_volume_fmt") or f"{row.get('quote_volume', 0):.0f}"
|
vol = row.get("quote_volume_fmt") or f"{row.get('quote_volume', 0):.0f}"
|
||||||
pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%"
|
pct = row.get("price_change_pct_fmt") or f"{row.get('price_change_pct', 0):+.2f}%"
|
||||||
|
fr = row.get("funding_rate_fmt") or "-"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {tag_str} |"
|
f"| {row['rank']} | {row['symbol']} | {vol} | {pct} | {fr} | {tag_str} |"
|
||||||
)
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%")
|
lines.append("> 标记说明:千万+ = 成交额≥1000万 USDT;涨跌5%+ = |涨跌幅|≥5%")
|
||||||
|
|||||||
+16
-4
@@ -26,6 +26,7 @@ const SORT_KEYS = {
|
|||||||
if (r.is_high_change) score += 1;
|
if (r.is_high_change) score += 1;
|
||||||
return score;
|
return score;
|
||||||
},
|
},
|
||||||
|
funding_rate: (r) => Number(r.funding_rate_pct) || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPeriod(start, end) {
|
function formatPeriod(start, end) {
|
||||||
@@ -88,7 +89,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="6" class="loading">暂无数据</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="loading">暂无数据</td></tr>';
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(tableId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,14 @@ function renderTable(tableId, tbody) {
|
|||||||
</td>
|
</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 class="funding-cell" data-value="${row.funding_rate_pct ?? 0}">
|
||||||
|
<div class="funding-cell-inner">
|
||||||
|
<span class="funding-rate-label ${pctClass(row.funding_rate_pct ?? 0)}">${row.funding_rate_fmt || "—"}</span>
|
||||||
|
<div class="mini-funding-chart" data-symbol="${row.symbol}">
|
||||||
|
<canvas></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
@@ -120,6 +129,7 @@ function renderTable(tableId, tbody) {
|
|||||||
|
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(tableId);
|
||||||
enqueueCharts(tbody);
|
enqueueCharts(tbody);
|
||||||
|
if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTableData(tableId, data) {
|
function setTableData(tableId, data) {
|
||||||
@@ -165,6 +175,7 @@ function exportCsv(tableId) {
|
|||||||
"成交额显示",
|
"成交额显示",
|
||||||
"成交额USDT",
|
"成交额USDT",
|
||||||
"涨跌幅%",
|
"涨跌幅%",
|
||||||
|
"资金费率%",
|
||||||
"千万+",
|
"千万+",
|
||||||
"涨跌5%+",
|
"涨跌5%+",
|
||||||
"标记",
|
"标记",
|
||||||
@@ -175,6 +186,7 @@ function exportCsv(tableId) {
|
|||||||
r.quote_volume_fmt || "",
|
r.quote_volume_fmt || "",
|
||||||
r.quote_volume ?? "",
|
r.quote_volume ?? "",
|
||||||
r.price_change_pct ?? "",
|
r.price_change_pct ?? "",
|
||||||
|
r.funding_rate_pct ?? "",
|
||||||
r.is_high_volume ? "是" : "否",
|
r.is_high_volume ? "是" : "否",
|
||||||
r.is_high_change ? "是" : "否",
|
r.is_high_change ? "是" : "否",
|
||||||
tagText(r),
|
tagText(r),
|
||||||
@@ -213,7 +225,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="6" class="loading">加载中…</td></tr>';
|
body.innerHTML = '<tr><td colspan="7" 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();
|
||||||
@@ -225,7 +237,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="6" class="error">加载失败: ${e.message}</td></tr>`;
|
body.innerHTML = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +255,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="6" class="error">加载失败: ${e.message}</td></tr>`;
|
body.innerHTML = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
||||||
document.getElementById("status").textContent = e.message;
|
document.getElementById("status").textContent = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
/** 资金费率历史曲线 */
|
||||||
|
|
||||||
|
const fundingCache = new Map();
|
||||||
|
const fundingQueue = [];
|
||||||
|
let fundingQueueRunning = false;
|
||||||
|
const FUNDING_FETCH_GAP_MS = 200;
|
||||||
|
|
||||||
|
const FUNDING_MINI = { w: 200, h: 56 };
|
||||||
|
const FUNDING_MODAL = { w: 960, h: 320 };
|
||||||
|
|
||||||
|
function enqueueFundingCharts(root) {
|
||||||
|
root.querySelectorAll(".mini-funding-chart[data-symbol]").forEach((box) => {
|
||||||
|
if (box.dataset.loaded === "1" || box.dataset.loading === "1") return;
|
||||||
|
fundingQueue.push(box);
|
||||||
|
});
|
||||||
|
runFundingQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFundingQueue() {
|
||||||
|
if (fundingQueueRunning) return;
|
||||||
|
fundingQueueRunning = true;
|
||||||
|
while (fundingQueue.length) {
|
||||||
|
const box = fundingQueue.shift();
|
||||||
|
if (!box?.isConnected) continue;
|
||||||
|
await loadMiniFundingChart(box);
|
||||||
|
await new Promise((r) => setTimeout(r, FUNDING_FETCH_GAP_MS));
|
||||||
|
}
|
||||||
|
fundingQueueRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCanvas(canvas, w, h) {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
canvas.width = Math.floor(w * dpr);
|
||||||
|
canvas.height = Math.floor(h * dpr);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
return { ctx, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFundingChart(canvas, history, current, large = false) {
|
||||||
|
if (!canvas || !history?.length) return;
|
||||||
|
const size = large ? FUNDING_MODAL : FUNDING_MINI;
|
||||||
|
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
|
||||||
|
const pad = large ? { t: 20, r: 16, b: 24, l: 48 } : { t: 6, r: 4, b: 8, l: 4 };
|
||||||
|
const plotW = w - pad.l - pad.r;
|
||||||
|
const plotH = h - pad.t - pad.b;
|
||||||
|
|
||||||
|
const rates = history.map((p) => p.rate_pct);
|
||||||
|
let min = Math.min(...rates, 0);
|
||||||
|
let max = Math.max(...rates, 0);
|
||||||
|
const margin = Math.max(0.005, (max - min) * 0.15);
|
||||||
|
min -= margin;
|
||||||
|
max += margin;
|
||||||
|
const range = max - min || 1;
|
||||||
|
const n = history.length;
|
||||||
|
const step = plotW / Math.max(n - 1, 1);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = "#0d1118";
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const y0 = pad.t + plotH * (1 - (0 - min) / range);
|
||||||
|
ctx.strokeStyle = "#3a4558";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.l, y0);
|
||||||
|
ctx.lineTo(w - pad.r, y0);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
if (large) {
|
||||||
|
ctx.fillStyle = "#8b9cb3";
|
||||||
|
ctx.font = "11px system-ui,sans-serif";
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const v = max - (range * i) / 4;
|
||||||
|
const y = pad.t + (plotH * i) / 4;
|
||||||
|
ctx.fillText(`${v.toFixed(3)}%`, pad.l - 6, y + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineWidth = large ? 2 : 1.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
history.forEach((p, i) => {
|
||||||
|
const x = pad.l + i * step;
|
||||||
|
const y = pad.t + plotH * (1 - (p.rate_pct - min) / range);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = "#5c7cfa";
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
history.forEach((p, i) => {
|
||||||
|
const x = pad.l + i * step;
|
||||||
|
const y = pad.t + plotH * (1 - (p.rate_pct - min) / range);
|
||||||
|
ctx.fillStyle = p.rate_pct >= 0 ? "#0ecb81" : "#f6465d";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, large ? 3 : 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (current != null) {
|
||||||
|
const lx = w - pad.r - 2;
|
||||||
|
const ly = pad.t + 2;
|
||||||
|
ctx.fillStyle = current >= 0 ? "#0ecb81" : "#f6465d";
|
||||||
|
ctx.font = `${large ? 13 : 10}px system-ui,sans-serif`;
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillText(`当前 ${current.toFixed(4)}%`, lx, ly + (large ? 12 : 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMiniFundingChart(box) {
|
||||||
|
const symbol = box.dataset.symbol;
|
||||||
|
if (!symbol) return;
|
||||||
|
box.dataset.loading = "1";
|
||||||
|
const canvas = box.querySelector("canvas");
|
||||||
|
const label = box.querySelector(".funding-rate-label");
|
||||||
|
|
||||||
|
try {
|
||||||
|
let bundle = fundingCache.get(symbol);
|
||||||
|
if (!bundle) {
|
||||||
|
const res = await fetch(`/api/funding/${symbol}/history?limit=90`);
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
|
||||||
|
bundle = await res.json();
|
||||||
|
fundingCache.set(symbol, bundle);
|
||||||
|
}
|
||||||
|
const history = bundle.history || [];
|
||||||
|
const curPct = bundle.current?.rate_pct ?? 0;
|
||||||
|
if (label) {
|
||||||
|
label.textContent = `${curPct >= 0 ? "+" : ""}${curPct.toFixed(4)}%`;
|
||||||
|
label.className = `funding-rate-label ${curPct >= 0 ? "pct-up" : "pct-down"}`;
|
||||||
|
}
|
||||||
|
drawFundingChart(canvas, history, curPct, false);
|
||||||
|
box.dataset.loaded = "1";
|
||||||
|
box.title = `${symbol} 资金费率历史 ${history.length} 点,点击放大`;
|
||||||
|
} catch (e) {
|
||||||
|
if (label) label.textContent = "—";
|
||||||
|
box.title = e.message;
|
||||||
|
} finally {
|
||||||
|
box.dataset.loading = "0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFundingModal() {
|
||||||
|
let modal = document.getElementById("funding-modal");
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement("div");
|
||||||
|
modal.id = "funding-modal";
|
||||||
|
modal.className = "chart-modal hidden";
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="chart-modal-inner">
|
||||||
|
<button type="button" class="chart-modal-close funding-modal-close">×</button>
|
||||||
|
<h3 id="funding-modal-title"></h3>
|
||||||
|
<p class="chart-modal-hint">资金费率历史(约 90 次结算,8h/次)· 虚线为零轴</p>
|
||||||
|
<div class="chart-modal-canvas-wrap">
|
||||||
|
<canvas id="funding-modal-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.querySelector(".funding-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-funding-chart[data-symbol]");
|
||||||
|
if (!box || box.dataset.loaded !== "1") return;
|
||||||
|
const symbol = box.dataset.symbol;
|
||||||
|
const bundle = fundingCache.get(symbol);
|
||||||
|
if (!bundle) return;
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
document.getElementById("funding-modal-title").textContent =
|
||||||
|
`${symbol} · 资金费率`;
|
||||||
|
drawFundingChart(
|
||||||
|
document.getElementById("funding-modal-canvas"),
|
||||||
|
bundle.history,
|
||||||
|
bundle.current?.rate_pct,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFundingModal();
|
||||||
+4
-1
@@ -9,7 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>币安 U本位合约 · 成交额排名</h1>
|
<h1>币安 U本位合约 · 成交额排名</h1>
|
||||||
<p class="subtitle">北京时间 08:00 切日 · Top30 · 合约右侧 300 日K+成交量 · 点击图表放大查看</p>
|
<p class="subtitle">Top30 · 日K+成交量 · 资金费率当前+历史曲线 · 点击图表放大</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel" id="panel-yesterday">
|
<section class="panel" id="panel-yesterday">
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<th class="chart-col">日线图</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="funding-col">资金费率</th>
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
<th class="sortable" data-sort="tags">标记</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
<th class="chart-col">日线图</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="funding-col">资金费率</th>
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
<th class="sortable" data-sort="tags">标记</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/charts.js"></script>
|
<script src="/static/charts.js"></script>
|
||||||
|
<script src="/static/funding.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+40
-1
@@ -311,7 +311,46 @@ button:hover {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#chart-modal-canvas {
|
#chart-modal-canvas,
|
||||||
|
#funding-modal-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
background: #0d1118;
|
background: #0d1118;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.funding-col {
|
||||||
|
min-width: 220px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funding-cell {
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 0.4rem 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funding-cell-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funding-rate-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-funding-chart {
|
||||||
|
width: 200px;
|
||||||
|
height: 56px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0d1118;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-funding-chart canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user