增加K线
This commit is contained in:
+17
-5
@@ -236,11 +236,7 @@ class BinanceFuturesClient:
|
|||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]:
|
def _parse_kline_rows(self, raw: list | None) -> list[dict]:
|
||||||
raw = await self._get(
|
|
||||||
"/fapi/v1/klines",
|
|
||||||
{"symbol": symbol.upper(), "interval": "1d", "limit": min(limit, 1500)},
|
|
||||||
)
|
|
||||||
candles = []
|
candles = []
|
||||||
for k in raw or []:
|
for k in raw or []:
|
||||||
candles.append(
|
candles.append(
|
||||||
@@ -256,5 +252,21 @@ class BinanceFuturesClient:
|
|||||||
)
|
)
|
||||||
return candles
|
return candles
|
||||||
|
|
||||||
|
async def get_klines_limit(
|
||||||
|
self, symbol: str, interval: str, limit: int = 500
|
||||||
|
) -> list[dict]:
|
||||||
|
raw = await self._get(
|
||||||
|
"/fapi/v1/klines",
|
||||||
|
{
|
||||||
|
"symbol": symbol.upper(),
|
||||||
|
"interval": interval,
|
||||||
|
"limit": min(limit, 1500),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self._parse_kline_rows(raw)
|
||||||
|
|
||||||
|
async def get_daily_klines(self, symbol: str, limit: int = 300) -> list[dict]:
|
||||||
|
return await self.get_klines_limit(symbol, "1d", limit)
|
||||||
|
|
||||||
|
|
||||||
binance_client = BinanceFuturesClient()
|
binance_client = BinanceFuturesClient()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""K 线周期常量与每周期 bar 数 / 缓存 TTL。"""
|
||||||
|
|
||||||
|
CHART_INTERVALS: tuple[str, ...] = ("5m", "15m", "30m", "1h", "4h", "1d", "1w")
|
||||||
|
|
||||||
|
LONG_INTERVALS: frozenset[str] = frozenset({"1d", "1w"})
|
||||||
|
|
||||||
|
# 1d 及以上 500 根;以下 1000 根
|
||||||
|
INTERVAL_LIMITS: dict[str, int] = {
|
||||||
|
"5m": 1000,
|
||||||
|
"15m": 1000,
|
||||||
|
"30m": 1000,
|
||||||
|
"1h": 1000,
|
||||||
|
"4h": 1000,
|
||||||
|
"1d": 500,
|
||||||
|
"1w": 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 预取 / 本地 freshness(分钟)
|
||||||
|
INTERVAL_CACHE_MINUTES: dict[str, int] = {
|
||||||
|
"5m": 5,
|
||||||
|
"15m": 15,
|
||||||
|
"30m": 15,
|
||||||
|
"1h": 30,
|
||||||
|
"4h": 30,
|
||||||
|
"1d": 60,
|
||||||
|
"1w": 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_interval(interval: str) -> str:
|
||||||
|
iv = interval.lower().strip()
|
||||||
|
if iv not in CHART_INTERVALS:
|
||||||
|
raise ValueError(f"unsupported interval: {interval}")
|
||||||
|
return iv
|
||||||
|
|
||||||
|
|
||||||
|
def limit_for_interval(interval: str) -> int:
|
||||||
|
iv = validate_interval(interval)
|
||||||
|
return INTERVAL_LIMITS[iv]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_minutes_for_interval(interval: str) -> int:
|
||||||
|
iv = validate_interval(interval)
|
||||||
|
return INTERVAL_CACHE_MINUTES[iv]
|
||||||
+173
-14
@@ -47,6 +47,23 @@ def init_db() -> None:
|
|||||||
message TEXT
|
message TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS klines (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval 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, interval, open_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_klines_symbol_interval
|
||||||
|
ON klines(symbol, interval, open_time);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_klines (
|
CREATE TABLE IF NOT EXISTS daily_klines (
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
open_time INTEGER NOT NULL,
|
open_time INTEGER NOT NULL,
|
||||||
@@ -64,9 +81,11 @@ def init_db() -> None:
|
|||||||
ON daily_klines(symbol, open_time);
|
ON daily_klines(symbol, open_time);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS kline_meta (
|
CREATE TABLE IF NOT EXISTS kline_meta (
|
||||||
symbol TEXT PRIMARY KEY,
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL DEFAULT '1d',
|
||||||
last_fetch_at TEXT NOT NULL,
|
last_fetch_at TEXT NOT NULL,
|
||||||
bar_count INTEGER NOT NULL
|
bar_count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (symbol, interval)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS funding_history (
|
CREATE TABLE IF NOT EXISTS funding_history (
|
||||||
@@ -99,6 +118,57 @@ def init_db() -> None:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
_migrate_klines_if_needed(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_klines_if_needed(conn: sqlite3.Connection) -> None:
|
||||||
|
"""从旧版 daily_klines / 单 symbol kline_meta 迁移到多周期表。"""
|
||||||
|
cols = conn.execute("PRAGMA table_info(kline_meta)").fetchall()
|
||||||
|
col_names = {c[1] for c in cols}
|
||||||
|
if "interval" not in col_names and cols:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT symbol, last_fetch_at, bar_count FROM kline_meta"
|
||||||
|
).fetchall()
|
||||||
|
conn.execute("DROP TABLE kline_meta")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE kline_meta (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL DEFAULT '1d',
|
||||||
|
last_fetch_at TEXT NOT NULL,
|
||||||
|
bar_count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (symbol, interval)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO kline_meta (symbol, interval, last_fetch_at, bar_count)
|
||||||
|
VALUES (?, '1d', ?, ?)
|
||||||
|
""",
|
||||||
|
(r[0], r[1], r[2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
has_klines = conn.execute(
|
||||||
|
"SELECT 1 FROM klines LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if has_klines:
|
||||||
|
return
|
||||||
|
|
||||||
|
daily_count = conn.execute("SELECT COUNT(*) FROM daily_klines").fetchone()[0]
|
||||||
|
if not daily_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO klines (
|
||||||
|
symbol, interval, open_time, open, high, low, close, volume, quote_volume, updated_at
|
||||||
|
)
|
||||||
|
SELECT symbol, '1d', open_time, open, high, low, close, volume, quote_volume, updated_at
|
||||||
|
FROM daily_klines
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_snapshot(
|
def save_snapshot(
|
||||||
@@ -159,7 +229,82 @@ def log_push(period_start: str, period_end: str, success: bool, message: str = "
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_klines(symbol: str, interval: str, candles: list[dict[str, Any]]) -> None:
|
||||||
|
sym = symbol.upper()
|
||||||
|
iv = interval.lower()
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
with get_conn() as conn:
|
||||||
|
for c in candles:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO klines (
|
||||||
|
symbol, interval, open_time, open, high, low, close, volume, quote_volume, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, interval, 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,
|
||||||
|
iv,
|
||||||
|
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, interval, last_fetch_at, bar_count)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(symbol, interval) DO UPDATE SET
|
||||||
|
last_fetch_at = excluded.last_fetch_at,
|
||||||
|
bar_count = excluded.bar_count
|
||||||
|
""",
|
||||||
|
(sym, iv, now, len(candles)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_klines_from_db(symbol: str, interval: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
sym = symbol.upper()
|
||||||
|
iv = interval.lower()
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT open_time, open, high, low, close, volume, quote_volume
|
||||||
|
FROM klines
|
||||||
|
WHERE symbol = ? AND interval = ?
|
||||||
|
ORDER BY open_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(sym, iv, 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 save_daily_klines(symbol: str, candles: list[dict[str, Any]]) -> None:
|
def save_daily_klines(symbol: str, candles: list[dict[str, Any]]) -> None:
|
||||||
|
save_klines(symbol, "1d", candles)
|
||||||
sym = symbol.upper()
|
sym = symbol.upper()
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -190,19 +335,12 @@ def save_daily_klines(symbol: str, candles: list[dict[str, Any]]) -> None:
|
|||||||
now,
|
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]]:
|
def get_daily_klines_from_db(symbol: str, limit: int) -> list[dict[str, Any]]:
|
||||||
|
stored = get_klines_from_db(symbol, "1d", limit)
|
||||||
|
if stored:
|
||||||
|
return stored
|
||||||
sym = symbol.upper()
|
sym = symbol.upper()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -230,12 +368,33 @@ def get_daily_klines_from_db(symbol: str, limit: int) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_kline_meta(symbol: str) -> dict[str, Any] | None:
|
def get_kline_meta(symbol: str, interval: str = "1d") -> dict[str, Any] | None:
|
||||||
sym = symbol.upper()
|
sym = symbol.upper()
|
||||||
|
iv = interval.lower()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT last_fetch_at, bar_count FROM kline_meta WHERE symbol = ? AND interval = ?",
|
||||||
|
(sym, iv),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
if iv == "1d":
|
||||||
|
return _legacy_kline_meta(sym)
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"last_fetch_at": row["last_fetch_at"],
|
||||||
|
"bar_count": row["bar_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_kline_meta(symbol: str) -> dict[str, Any] | None:
|
||||||
|
"""兼容旧库仅有 symbol 维度的 meta(迁移前)。"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
cols = {c[1] for c in conn.execute("PRAGMA table_info(kline_meta)").fetchall()}
|
||||||
|
if "interval" in cols:
|
||||||
|
return None
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT last_fetch_at, bar_count FROM kline_meta WHERE symbol = ?",
|
"SELECT last_fetch_at, bar_count FROM kline_meta WHERE symbol = ?",
|
||||||
(sym,),
|
(symbol,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+56
-32
@@ -1,18 +1,23 @@
|
|||||||
"""日 K 线:优先 SQLite 本地库,不足或过期再请求币安。"""
|
"""多周期 K 线:优先 SQLite,不足或过期再请求币安。"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from .binance import binance_client
|
from .binance import binance_client
|
||||||
from .config import settings
|
from .chart_intervals import (
|
||||||
from .db import get_daily_klines_from_db, get_kline_meta, save_daily_klines
|
CHART_INTERVALS,
|
||||||
|
cache_minutes_for_interval,
|
||||||
|
limit_for_interval,
|
||||||
|
validate_interval,
|
||||||
|
)
|
||||||
|
from .db import get_kline_meta, get_klines_from_db, save_klines
|
||||||
from .exceptions import BinanceRateLimitedError
|
from .exceptions import BinanceRateLimitedError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _is_db_fresh(symbol: str, min_bars: int) -> bool:
|
def _is_db_fresh(symbol: str, interval: str, min_bars: int) -> bool:
|
||||||
meta = get_kline_meta(symbol)
|
meta = get_kline_meta(symbol, interval)
|
||||||
if not meta or meta.get("bar_count", 0) < min_bars:
|
if not meta or meta.get("bar_count", 0) < min_bars:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -20,21 +25,29 @@ def _is_db_fresh(symbol: str, min_bars: int) -> bool:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
age = (datetime.now() - last_fetch).total_seconds()
|
age = (datetime.now() - last_fetch).total_seconds()
|
||||||
return age < settings.chart_cache_minutes * 60
|
return age < cache_minutes_for_interval(interval) * 60
|
||||||
|
|
||||||
|
|
||||||
async def sync_daily_klines(symbol: str, limit: int | None = None) -> list[dict]:
|
async def sync_klines(
|
||||||
"""从币安拉取并写入本地库。"""
|
symbol: str, interval: str, limit: int | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""从币安拉取指定周期并写入本地库。"""
|
||||||
sym = symbol.upper()
|
sym = symbol.upper()
|
||||||
n = min(limit or settings.chart_kline_limit, 1500)
|
iv = validate_interval(interval)
|
||||||
candles = await binance_client.get_daily_klines(sym, n)
|
n = min(limit or limit_for_interval(iv), 1500)
|
||||||
save_daily_klines(sym, candles)
|
candles = await binance_client.get_klines_limit(sym, iv, n)
|
||||||
logger.info("Saved %d daily klines for %s to DB", len(candles), sym)
|
save_klines(sym, iv, candles)
|
||||||
|
logger.info("Saved %d %s klines for %s to DB", len(candles), iv, sym)
|
||||||
return candles
|
return candles
|
||||||
|
|
||||||
|
|
||||||
async def get_daily_candles(
|
async def sync_daily_klines(symbol: str, limit: int | None = None) -> list[dict]:
|
||||||
|
return await sync_klines(symbol, "1d", limit)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_candles(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
interval: str = "1d",
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> tuple[list[dict], str]:
|
) -> tuple[list[dict], str]:
|
||||||
@@ -43,23 +56,24 @@ async def get_daily_candles(
|
|||||||
source: db | db_stale | binance
|
source: db | db_stale | binance
|
||||||
"""
|
"""
|
||||||
sym = symbol.upper().strip()
|
sym = symbol.upper().strip()
|
||||||
n = min(limit or settings.chart_kline_limit, 1500)
|
iv = validate_interval(interval)
|
||||||
|
n = min(limit or limit_for_interval(iv), 1500)
|
||||||
min_bars = min(n, 50)
|
min_bars = min(n, 50)
|
||||||
|
|
||||||
if not force_refresh and _is_db_fresh(sym, min_bars):
|
if not force_refresh and _is_db_fresh(sym, iv, min_bars):
|
||||||
candles = get_daily_klines_from_db(sym, n)
|
candles = get_klines_from_db(sym, iv, n)
|
||||||
if len(candles) >= min_bars:
|
if len(candles) >= min_bars:
|
||||||
return candles, "db"
|
return candles, "db"
|
||||||
|
|
||||||
stored = get_daily_klines_from_db(sym, n)
|
stored = get_klines_from_db(sym, iv, n)
|
||||||
if binance_client.is_rate_limited():
|
if binance_client.is_rate_limited():
|
||||||
if stored:
|
if stored:
|
||||||
logger.warning("Rate limited, serve stale DB klines for %s", sym)
|
logger.warning("Rate limited, serve stale DB klines for %s %s", sym, iv)
|
||||||
return stored, "db_stale"
|
return stored, "db_stale"
|
||||||
raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), sym)
|
raise BinanceRateLimitedError(binance_client.rate_limit_remaining_sec(), sym)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
candles = await sync_daily_klines(sym, n)
|
candles = await sync_klines(sym, iv, n)
|
||||||
return candles, "binance"
|
return candles, "binance"
|
||||||
except BinanceRateLimitedError:
|
except BinanceRateLimitedError:
|
||||||
if stored:
|
if stored:
|
||||||
@@ -71,23 +85,33 @@ async def get_daily_candles(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_daily_candles(
|
||||||
|
symbol: str,
|
||||||
|
limit: int | None = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> tuple[list[dict], str]:
|
||||||
|
return await get_candles(symbol, "1d", limit, force_refresh)
|
||||||
|
|
||||||
|
|
||||||
async def prefetch_symbols(symbols: list[str]) -> None:
|
async def prefetch_symbols(symbols: list[str]) -> None:
|
||||||
"""后台预拉 Top 币种日 K 入库(串行,避免 418)。"""
|
"""后台预拉 Top 币种全周期 K 线入库(串行,避免 418)。"""
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for raw in symbols:
|
for raw in symbols:
|
||||||
sym = raw.upper().strip()
|
sym = raw.upper().strip()
|
||||||
if not sym or sym in seen or not sym.endswith("USDT"):
|
if not sym or sym in seen or not sym.endswith("USDT"):
|
||||||
continue
|
continue
|
||||||
seen.add(sym)
|
seen.add(sym)
|
||||||
if _is_db_fresh(sym, min(50, settings.chart_kline_limit)):
|
for interval in CHART_INTERVALS:
|
||||||
continue
|
n = limit_for_interval(interval)
|
||||||
if binance_client.is_rate_limited():
|
if _is_db_fresh(sym, interval, min(50, n)):
|
||||||
logger.warning("Prefetch stopped — rate limited")
|
continue
|
||||||
break
|
if binance_client.is_rate_limited():
|
||||||
try:
|
logger.warning("Prefetch stopped — rate limited")
|
||||||
await sync_daily_klines(sym)
|
return
|
||||||
except BinanceRateLimitedError:
|
try:
|
||||||
logger.warning("Prefetch rate limited at %s", sym)
|
await sync_klines(sym, interval, n)
|
||||||
break
|
except BinanceRateLimitedError:
|
||||||
except Exception as e:
|
logger.warning("Prefetch rate limited at %s %s", sym, interval)
|
||||||
logger.warning("Prefetch %s failed: %s", sym, e)
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Prefetch %s %s failed: %s", sym, interval, e)
|
||||||
|
|||||||
+56
-2
@@ -8,7 +8,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from .config import ROOT_DIR, settings
|
from .config import ROOT_DIR, settings
|
||||||
from .funding_store import get_funding_bundle
|
from .funding_store import get_funding_bundle
|
||||||
from .kline_store import get_daily_candles, sync_daily_klines
|
from .kline_store import get_candles, get_daily_candles, sync_daily_klines, sync_klines
|
||||||
|
from .chart_intervals import CHART_INTERVALS, limit_for_interval, validate_interval
|
||||||
from .db import get_latest_snapshot, init_db, log_push, save_snapshot
|
from .db import get_latest_snapshot, init_db, log_push, save_snapshot
|
||||||
from .exceptions import BinanceRateLimitedError
|
from .exceptions import BinanceRateLimitedError
|
||||||
from .period_api import get_period_top30
|
from .period_api import get_period_top30
|
||||||
@@ -144,9 +145,44 @@ async def api_refresh_daybefore():
|
|||||||
return get_latest_snapshot("daybefore") or {"message": "done"}
|
return get_latest_snapshot("daybefore") or {"message": "done"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/chart/{symbol}")
|
||||||
|
async def api_chart(
|
||||||
|
symbol: str,
|
||||||
|
interval: str = "1d",
|
||||||
|
limit: int | None = None,
|
||||||
|
refresh: bool = False,
|
||||||
|
):
|
||||||
|
"""合约 K 线:优先读本地 SQLite,过期再拉币安入库。"""
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
if not sym.endswith("USDT"):
|
||||||
|
raise HTTPException(400, "invalid symbol")
|
||||||
|
try:
|
||||||
|
iv = validate_interval(interval)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
default_limit = limit_for_interval(iv)
|
||||||
|
try:
|
||||||
|
candles, source = await get_candles(
|
||||||
|
sym, iv, limit or default_limit, force_refresh=refresh
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbol": sym,
|
||||||
|
"interval": iv,
|
||||||
|
"limit": len(candles),
|
||||||
|
"candles": candles,
|
||||||
|
"source": source,
|
||||||
|
"intervals": list(CHART_INTERVALS),
|
||||||
|
}
|
||||||
|
except BinanceRateLimitedError as e:
|
||||||
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("chart %s %s failed: %s", sym, iv, e)
|
||||||
|
raise HTTPException(502, "K线获取失败") from e
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chart/{symbol}/daily")
|
@app.get("/api/chart/{symbol}/daily")
|
||||||
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
|
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
|
||||||
"""合约日 K 线:优先读本地 SQLite,过期再拉币安入库。"""
|
"""合约日 K 线(兼容旧路径)。"""
|
||||||
sym = symbol.upper().strip()
|
sym = symbol.upper().strip()
|
||||||
if not sym.endswith("USDT"):
|
if not sym.endswith("USDT"):
|
||||||
raise HTTPException(400, "invalid symbol")
|
raise HTTPException(400, "invalid symbol")
|
||||||
@@ -158,6 +194,7 @@ async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool =
|
|||||||
"limit": len(candles),
|
"limit": len(candles),
|
||||||
"candles": candles,
|
"candles": candles,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"intervals": list(CHART_INTERVALS),
|
||||||
}
|
}
|
||||||
except BinanceRateLimitedError as e:
|
except BinanceRateLimitedError as e:
|
||||||
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") from e
|
||||||
@@ -180,6 +217,23 @@ async def api_funding_history(symbol: str, limit: int | None = None, refresh: bo
|
|||||||
raise HTTPException(502, "资金费率获取失败") from e
|
raise HTTPException(502, "资金费率获取失败") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chart/{symbol}/refresh")
|
||||||
|
async def api_chart_refresh(
|
||||||
|
symbol: str, interval: str = "1d", limit: int | None = None
|
||||||
|
):
|
||||||
|
"""强制从币安同步 K 线到本地库。"""
|
||||||
|
sym = symbol.upper().strip()
|
||||||
|
try:
|
||||||
|
iv = validate_interval(interval)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
try:
|
||||||
|
candles = await sync_klines(sym, iv, limit)
|
||||||
|
return {"symbol": sym, "interval": iv, "saved": len(candles), "source": "binance"}
|
||||||
|
except BinanceRateLimitedError as e:
|
||||||
|
raise HTTPException(503, f"币安限流,请 {e.retry_after_sec} 秒后再试") 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 到本地库。"""
|
||||||
|
|||||||
+271
-78
@@ -1,4 +1,15 @@
|
|||||||
/** 日 K + 成交量(Canvas 高清)· 浏览器 localStorage 缓存 · 点击全屏 */
|
/** 多周期 K 线 · SQLite 后端 + localStorage · 弹窗全屏 Lightweight Charts */
|
||||||
|
|
||||||
|
const CHART_INTERVALS = ["5m", "15m", "30m", "1h", "4h", "1d", "1w"];
|
||||||
|
const INTERVAL_LIMITS = {
|
||||||
|
"5m": 1000,
|
||||||
|
"15m": 1000,
|
||||||
|
"30m": 1000,
|
||||||
|
"1h": 1000,
|
||||||
|
"4h": 1000,
|
||||||
|
"1d": 500,
|
||||||
|
"1w": 500,
|
||||||
|
};
|
||||||
|
|
||||||
const chartDataCache = new Map();
|
const chartDataCache = new Map();
|
||||||
const chartQueue = [];
|
const chartQueue = [];
|
||||||
@@ -19,21 +30,37 @@ const COLORS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MINI_SIZE = { w: 380, h: 100 };
|
const MINI_SIZE = { w: 380, h: 100 };
|
||||||
|
const DEFAULT_MINI_INTERVAL = "1d";
|
||||||
|
|
||||||
|
let chartModalSymbol = "";
|
||||||
|
let chartModalInterval = "1d";
|
||||||
|
let lwcChart = null;
|
||||||
|
let lwcCandleSeries = null;
|
||||||
|
let lwcVolumeSeries = null;
|
||||||
|
let lwcResizeObserver = null;
|
||||||
|
|
||||||
|
function cacheKey(symbol, interval) {
|
||||||
|
return `${symbol}:${interval}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitForInterval(interval) {
|
||||||
|
return INTERVAL_LIMITS[interval] || 500;
|
||||||
|
}
|
||||||
|
|
||||||
function modalSize() {
|
function modalSize() {
|
||||||
const fs = document.fullscreenElement;
|
const fs = document.fullscreenElement;
|
||||||
if (fs) {
|
if (fs) {
|
||||||
return {
|
return {
|
||||||
w: Math.max(800, window.innerWidth - 48),
|
w: Math.max(800, window.innerWidth - 48),
|
||||||
h: Math.max(480, window.innerHeight - 100),
|
h: Math.max(480, window.innerHeight - 160),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { w: 1280, h: 720 };
|
return { w: 1280, h: 680 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadKlineFromLS(symbol) {
|
function loadKlineFromLS(symbol, interval) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol);
|
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol + "_" + interval);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const obj = JSON.parse(raw);
|
const obj = JSON.parse(raw);
|
||||||
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
|
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
|
||||||
@@ -43,17 +70,59 @@ function loadKlineFromLS(symbol) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveKlineToLS(symbol, candles, source) {
|
function saveKlineToLS(symbol, interval, candles, source) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
LS_KLINE_PREFIX + symbol,
|
LS_KLINE_PREFIX + symbol + "_" + interval,
|
||||||
JSON.stringify({ ts: Date.now(), candles, source })
|
JSON.stringify({ ts: Date.now(), candles, source, interval })
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* quota */
|
/* quota */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source) {
|
||||||
|
if (source === "browser") return "浏览器";
|
||||||
|
if (source === "db") return "本地";
|
||||||
|
if (source === "db_stale") return "本地(旧)";
|
||||||
|
if (source === "memory") return "缓存";
|
||||||
|
return "同步";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLwcTime(ms, interval) {
|
||||||
|
if (interval === "1d" || interval === "1w") {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return {
|
||||||
|
year: d.getUTCFullYear(),
|
||||||
|
month: d.getUTCMonth() + 1,
|
||||||
|
day: d.getUTCDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Math.floor(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candlesToLwc(candles, interval) {
|
||||||
|
const ohlc = [];
|
||||||
|
const vol = [];
|
||||||
|
for (const c of candles) {
|
||||||
|
const t = toLwcTime(c.time, interval);
|
||||||
|
const up = c.close >= c.open;
|
||||||
|
ohlc.push({
|
||||||
|
time: t,
|
||||||
|
open: c.open,
|
||||||
|
high: c.high,
|
||||||
|
low: c.low,
|
||||||
|
close: c.close,
|
||||||
|
});
|
||||||
|
vol.push({
|
||||||
|
time: t,
|
||||||
|
value: Number(c.quote_volume || c.volume || 0),
|
||||||
|
color: up ? COLORS.volUp : COLORS.volDown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ohlc, vol };
|
||||||
|
}
|
||||||
|
|
||||||
function enqueueCharts(root) {
|
function enqueueCharts(root) {
|
||||||
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
|
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
|
||||||
const symbol = box.dataset.symbol;
|
const symbol = box.dataset.symbol;
|
||||||
@@ -134,28 +203,6 @@ function drawCandlestickChart(canvas, candles, options = {}) {
|
|||||||
ctx.fillStyle = COLORS.bg;
|
ctx.fillStyle = COLORS.bg;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
if (large) {
|
|
||||||
ctx.strokeStyle = COLORS.grid;
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 0; i <= 4; i++) {
|
|
||||||
const y = pad.t + (priceH * i) / 4;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(pad.l, y);
|
|
||||||
ctx.lineTo(w - pad.r, y);
|
|
||||||
ctx.stroke();
|
|
||||||
const price = pMax - (pRange * i) / 4;
|
|
||||||
ctx.fillStyle = COLORS.text;
|
|
||||||
ctx.font = "11px Segoe UI, system-ui, sans-serif";
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.fillText(price.toPrecision(6), pad.l - 8, y + 4);
|
|
||||||
}
|
|
||||||
ctx.fillStyle = COLORS.text;
|
|
||||||
ctx.font = "12px Segoe UI, system-ui, sans-serif";
|
|
||||||
ctx.textAlign = "left";
|
|
||||||
ctx.fillText("价格", pad.l, pad.t - 4);
|
|
||||||
ctx.fillText("成交量", pad.l, volTop - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.strokeStyle = COLORS.grid;
|
ctx.strokeStyle = COLORS.grid;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -198,10 +245,9 @@ function drawCandlestickChart(canvas, candles, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawEmptyChart(canvas, large = false) {
|
function drawEmptyChart(canvas) {
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const size = large ? modalSize() : MINI_SIZE;
|
const { ctx, w, h } = setupCanvas(canvas, MINI_SIZE.w, MINI_SIZE.h);
|
||||||
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
|
|
||||||
ctx.fillStyle = "#1a2332";
|
ctx.fillStyle = "#1a2332";
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
ctx.fillStyle = COLORS.text;
|
ctx.fillStyle = COLORS.text;
|
||||||
@@ -209,30 +255,33 @@ function drawEmptyChart(canvas, large = false) {
|
|||||||
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
|
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchKlines(symbol) {
|
async function fetchKlines(symbol, interval = DEFAULT_MINI_INTERVAL) {
|
||||||
let candles = chartDataCache.get(symbol);
|
const key = cacheKey(symbol, interval);
|
||||||
let source = "memory";
|
let cached = chartDataCache.get(key);
|
||||||
if (candles) return { candles, source };
|
if (cached) return cached;
|
||||||
|
|
||||||
const ls = loadKlineFromLS(symbol);
|
const ls = loadKlineFromLS(symbol, interval);
|
||||||
if (ls) {
|
if (ls) {
|
||||||
candles = ls.candles;
|
const result = { candles: ls.candles, source: ls.source || "browser", interval };
|
||||||
source = "browser";
|
chartDataCache.set(key, result);
|
||||||
chartDataCache.set(symbol, candles);
|
return result;
|
||||||
return { candles, source: ls.source || source };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
|
const limit = limitForInterval(interval);
|
||||||
|
const res = await fetch(`/api/chart/${symbol}?interval=${interval}&limit=${limit}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.detail || res.statusText);
|
throw new Error(err.detail || res.statusText);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
candles = data.candles || [];
|
const result = {
|
||||||
source = data.source || "db";
|
candles: data.candles || [],
|
||||||
chartDataCache.set(symbol, candles);
|
source: data.source || "db",
|
||||||
saveKlineToLS(symbol, candles, source);
|
interval,
|
||||||
return { candles, source };
|
};
|
||||||
|
chartDataCache.set(key, result);
|
||||||
|
saveKlineToLS(symbol, interval, result.candles, result.source);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMiniChart(box) {
|
async function loadMiniChart(box) {
|
||||||
@@ -244,53 +293,178 @@ async function loadMiniChart(box) {
|
|||||||
if (status) status.textContent = "加载…";
|
if (status) status.textContent = "加载…";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { candles, source } = await fetchKlines(symbol);
|
const { candles, source } = await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
|
||||||
if (!candles.length) throw new Error("无K线数据");
|
if (!candles.length) throw new Error("无K线数据");
|
||||||
drawCandlestickChart(canvas, candles, { large: false });
|
drawCandlestickChart(canvas, candles, { large: false });
|
||||||
box.dataset.loaded = "1";
|
box.dataset.loaded = "1";
|
||||||
const srcLabel =
|
if (status) status.textContent = `${candles.length}日·${sourceLabel(source)}`;
|
||||||
source === "browser"
|
box.title = `${symbol} 日K ${candles.length}根 (${sourceLabel(source)}),点击全屏`;
|
||||||
? "浏览器"
|
|
||||||
: source === "db"
|
|
||||||
? "本地"
|
|
||||||
: source === "db_stale"
|
|
||||||
? "本地(旧)"
|
|
||||||
: source === "cache"
|
|
||||||
? "缓存"
|
|
||||||
: "同步";
|
|
||||||
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
|
|
||||||
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击全屏`;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (status) status.textContent = "—";
|
if (status) status.textContent = "—";
|
||||||
box.title = `${symbol}: ${e.message}`;
|
box.title = `${symbol}: ${e.message}`;
|
||||||
drawEmptyChart(canvas, false);
|
drawEmptyChart(canvas);
|
||||||
} finally {
|
} finally {
|
||||||
box.dataset.loading = "0";
|
box.dataset.loading = "0";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let chartModalSymbol = "";
|
function destroyLwcChart() {
|
||||||
|
if (lwcResizeObserver) {
|
||||||
|
lwcResizeObserver.disconnect();
|
||||||
|
lwcResizeObserver = null;
|
||||||
|
}
|
||||||
|
if (lwcChart) {
|
||||||
|
lwcChart.remove();
|
||||||
|
lwcChart = null;
|
||||||
|
lwcCandleSeries = null;
|
||||||
|
lwcVolumeSeries = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLwcChart(container) {
|
||||||
|
if (typeof LightweightCharts === "undefined") {
|
||||||
|
container.innerHTML = '<p class="chart-lwc-fallback">图表库加载失败</p>';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyLwcChart();
|
||||||
|
const { w, h } = modalSize();
|
||||||
|
container.style.width = `${w}px`;
|
||||||
|
container.style.height = `${h}px`;
|
||||||
|
|
||||||
|
lwcChart = LightweightCharts.createChart(container, {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
layout: {
|
||||||
|
background: { color: COLORS.bg },
|
||||||
|
textColor: COLORS.text,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: COLORS.grid },
|
||||||
|
horzLines: { color: COLORS.grid },
|
||||||
|
},
|
||||||
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||||
|
rightPriceScale: { borderColor: COLORS.grid },
|
||||||
|
timeScale: {
|
||||||
|
borderColor: COLORS.grid,
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lwcCandleSeries = lwcChart.addCandlestickSeries({
|
||||||
|
upColor: COLORS.up,
|
||||||
|
downColor: COLORS.down,
|
||||||
|
borderUpColor: COLORS.up,
|
||||||
|
borderDownColor: COLORS.down,
|
||||||
|
wickUpColor: COLORS.up,
|
||||||
|
wickDownColor: COLORS.down,
|
||||||
|
});
|
||||||
|
|
||||||
|
lwcVolumeSeries = lwcChart.addHistogramSeries({
|
||||||
|
priceFormat: { type: "volume" },
|
||||||
|
priceScaleId: "",
|
||||||
|
});
|
||||||
|
lwcVolumeSeries.priceScale().applyOptions({
|
||||||
|
scaleMargins: { top: 0.82, bottom: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
lwcResizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!lwcChart || !container.isConnected) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
lwcChart.applyOptions({ width: rect.width, height: rect.height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lwcResizeObserver.observe(container);
|
||||||
|
|
||||||
|
return lwcChart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLwcChart(candles, interval) {
|
||||||
|
const container = document.getElementById("chart-modal-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!lwcChart) ensureLwcChart(container);
|
||||||
|
if (!lwcCandleSeries || !lwcVolumeSeries) return;
|
||||||
|
|
||||||
|
const { ohlc, vol } = candlesToLwc(candles, interval);
|
||||||
|
lwcCandleSeries.setData(ohlc);
|
||||||
|
lwcVolumeSeries.setData(vol);
|
||||||
|
lwcChart.timeScale().fitContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIntervalTabs() {
|
||||||
|
document.querySelectorAll(".chart-interval-btn").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.interval === chartModalInterval);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModalMeta(candles, source, interval) {
|
||||||
|
const title = document.getElementById("chart-modal-title");
|
||||||
|
const hint = document.getElementById("chart-modal-hint");
|
||||||
|
if (title) {
|
||||||
|
title.textContent = `${chartModalSymbol} · ${interval.toUpperCase()} K线`;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = `${candles.length} 根 · ${sourceLabel(source)} · 滚轮缩放 · 拖拽平移 · 十字线 · Esc 退出`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModalChart(interval) {
|
||||||
|
chartModalInterval = interval;
|
||||||
|
updateIntervalTabs();
|
||||||
|
|
||||||
|
const container = document.getElementById("chart-modal-container");
|
||||||
|
const hint = document.getElementById("chart-modal-hint");
|
||||||
|
if (hint) hint.textContent = "加载中…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { candles, source } = await fetchKlines(chartModalSymbol, interval);
|
||||||
|
if (!candles.length) throw new Error("无K线数据");
|
||||||
|
renderLwcChart(candles, interval);
|
||||||
|
updateModalMeta(candles, source, interval);
|
||||||
|
} catch (e) {
|
||||||
|
if (hint) hint.textContent = `加载失败: ${e.message}`;
|
||||||
|
destroyLwcChart();
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<p class="chart-lwc-fallback">${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeChartModal() {
|
function closeChartModal() {
|
||||||
const modal = document.getElementById("chart-modal");
|
const modal = document.getElementById("chart-modal");
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
modal.classList.add("hidden");
|
modal.classList.add("hidden");
|
||||||
|
destroyLwcChart();
|
||||||
|
chartModalSymbol = "";
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
document.exitFullscreen?.().catch(() => {});
|
document.exitFullscreen?.().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChartModal(symbol) {
|
async function openChartModal(symbol) {
|
||||||
const candles = chartDataCache.get(symbol);
|
const key = cacheKey(symbol, DEFAULT_MINI_INTERVAL);
|
||||||
if (!candles?.length) return;
|
const cached = chartDataCache.get(key);
|
||||||
|
if (!cached?.candles?.length) {
|
||||||
|
try {
|
||||||
|
await fetchKlines(symbol, DEFAULT_MINI_INTERVAL);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chartModalSymbol = symbol;
|
chartModalSymbol = symbol;
|
||||||
|
chartModalInterval = DEFAULT_MINI_INTERVAL;
|
||||||
|
|
||||||
const modal = document.getElementById("chart-modal");
|
const modal = document.getElementById("chart-modal");
|
||||||
modal.classList.remove("hidden");
|
modal.classList.remove("hidden");
|
||||||
document.getElementById("chart-modal-title").textContent =
|
|
||||||
`${symbol} · 日K + 成交量(${candles.length} 根)`;
|
const container = document.getElementById("chart-modal-container");
|
||||||
const canvas = document.getElementById("chart-modal-canvas");
|
if (container) container.innerHTML = "";
|
||||||
drawCandlestickChart(canvas, candles, { large: true });
|
|
||||||
|
await loadModalChart(DEFAULT_MINI_INTERVAL);
|
||||||
|
|
||||||
const inner = modal.querySelector(".chart-modal-inner");
|
const inner = modal.querySelector(".chart-modal-inner");
|
||||||
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
|
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
|
||||||
@@ -308,13 +482,31 @@ function setupChartModal() {
|
|||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="chart-modal-inner">
|
<div class="chart-modal-inner">
|
||||||
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
|
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
|
||||||
<h3 id="chart-modal-title"></h3>
|
<div class="chart-modal-head">
|
||||||
<p class="chart-modal-hint">日K + 成交量 · 300根 · 点击全屏 · Esc 退出</p>
|
<h3 id="chart-modal-title"></h3>
|
||||||
|
<div class="chart-interval-tabs" id="chart-interval-tabs"></div>
|
||||||
|
</div>
|
||||||
|
<p class="chart-modal-hint" id="chart-modal-hint"></p>
|
||||||
<div class="chart-modal-canvas-wrap">
|
<div class="chart-modal-canvas-wrap">
|
||||||
<canvas id="chart-modal-canvas"></canvas>
|
<div id="chart-modal-container" class="chart-lwc-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const tabs = modal.querySelector("#chart-interval-tabs");
|
||||||
|
CHART_INTERVALS.forEach((iv) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "chart-interval-btn";
|
||||||
|
btn.dataset.interval = iv;
|
||||||
|
btn.textContent = iv;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (iv === chartModalInterval || !chartModalSymbol) return;
|
||||||
|
loadModalChart(iv);
|
||||||
|
});
|
||||||
|
tabs.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
|
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
|
||||||
modal.addEventListener("click", (e) => {
|
modal.addEventListener("click", (e) => {
|
||||||
if (e.target === modal) closeChartModal();
|
if (e.target === modal) closeChartModal();
|
||||||
@@ -323,11 +515,12 @@ function setupChartModal() {
|
|||||||
if (e.key === "Escape") closeChartModal();
|
if (e.key === "Escape") closeChartModal();
|
||||||
});
|
});
|
||||||
document.addEventListener("fullscreenchange", () => {
|
document.addEventListener("fullscreenchange", () => {
|
||||||
if (!chartModalSymbol) return;
|
if (!chartModalSymbol || !lwcChart) return;
|
||||||
const canvas = document.getElementById("chart-modal-canvas");
|
const container = document.getElementById("chart-modal-container");
|
||||||
const candles = chartDataCache.get(chartModalSymbol);
|
if (!container) return;
|
||||||
if (canvas && candles?.length) {
|
const rect = container.getBoundingClientRect();
|
||||||
drawCandlestickChart(canvas, candles, { large: true });
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
lwcChart.applyOptions({ width: rect.width, height: rect.height });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
<span id="push-status" class="push-status"></span>
|
<span id="push-status" class="push-status"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
<script src="/static/charts.js"></script>
|
<script src="/static/charts.js"></script>
|
||||||
<script src="/static/funding.js"></script>
|
<script src="/static/funding.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|||||||
+65
-3
@@ -352,9 +352,71 @@ button:hover {
|
|||||||
.chart-modal-inner:fullscreen .chart-modal-canvas-wrap {
|
.chart-modal-inner:fullscreen .chart-modal-canvas-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-inner:fullscreen .chart-lwc-container {
|
||||||
|
flex: 1;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-interval-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-interval-btn {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-interval-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-interval-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #0d1118;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-lwc-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-lwc-fallback {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-inner:fullscreen .chart-modal-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-modal-inner:fullscreen .chart-modal-hint {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-table .stats-total-vol {
|
.stats-table .stats-total-vol {
|
||||||
@@ -468,7 +530,7 @@ button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-modal-canvas-wrap {
|
.chart-modal-canvas-wrap {
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #0d1118;
|
background: #0d1118;
|
||||||
|
|||||||
Reference in New Issue
Block a user