增加k线图

This commit is contained in:
dekun
2026-05-22 13:47:27 +08:00
parent ee621976db
commit 74f98af40d
13 changed files with 543 additions and 8 deletions
+19
View File
@@ -212,5 +212,24 @@ class BinanceFuturesClient:
sym_set = set(symbols)
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()
+2
View File
@@ -29,6 +29,8 @@ class Settings(BaseSettings):
# today: ticker24h=仅1次API(滚动24h); yesterday: klines=按8:00切日精确统计
today_data_mode: str = "ticker24h"
yesterday_data_mode: str = "klines"
chart_kline_limit: int = 300
chart_cache_minutes: int = 60
# 代理默认关闭;仅当 PROXY_ENABLED=true 时生效
proxy_enabled: bool = False
proxy_url: str = "socks5h://192.168.8.4:1081"
+108
View File
@@ -46,6 +46,28 @@ def init_db() -> None:
success INTEGER NOT NULL,
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:
with get_conn() as conn:
row = conn.execute(
+93
View File
@@ -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)
+35
View File
@@ -8,7 +8,9 @@ from fastapi.staticfiles import StaticFiles
from .aggregator import aggregate_period, enrich_snapshot_meta
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 .exceptions import BinanceRateLimitedError
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 .state import get_today_cache
@@ -128,3 +130,36 @@ async def api_refresh_yesterday():
async def api_refresh_today():
await job_refresh_today()
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
+7
View File
@@ -11,6 +11,7 @@ from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushe
from .exceptions import BinanceRateLimitedError
from .periods import get_today_period, get_yesterday_period, now_shanghai
from .state import get_today_cache, set_today_cache
from .kline_store import prefetch_symbols
from .wecom import build_markdown, send_wecom_markdown
logger = logging.getLogger(__name__)
@@ -53,6 +54,9 @@ async def job_finalize_yesterday() -> None:
)
save_snapshot("yesterday", start, end, 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:
logger.error("Finalize yesterday rate limited %ss", e.retry_after_sec)
except Exception as e:
@@ -113,6 +117,9 @@ async def job_refresh_today() -> None:
save_snapshot("today", start, end, items)
set_today_cache(meta)
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:
logger.error("Refresh today rate limited %ss — use cache", e.retry_after_sec)
_restore_today_from_db()