972 lines
42 KiB
Python
972 lines
42 KiB
Python
"""
|
||
Chan 数据服务:用 ccxt 从交易所拉取 K 线,内存缓存 + CSV 落盘;
|
||
后台线程定期增量刷新,断线时记录 resume_since 以免漏 K;
|
||
配置中的基础周期(如 1m/1h)可合成 DERIVED_TIMEFRAME_PLAN 中的衍生周期。
|
||
"""
|
||
import asyncio
|
||
import csv
|
||
import json
|
||
import logging
|
||
import os
|
||
import threading
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Dict, Iterable, List, Optional
|
||
|
||
import ccxt # type: ignore
|
||
import pandas as pd # type: ignore
|
||
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import HTMLResponse
|
||
import uvicorn
|
||
from technical.util import resample_to_interval
|
||
|
||
# docker compose logs --tail=200
|
||
# docker compose down && docker compose build --no-cache && docker compose up -d
|
||
|
||
# 基础周期枚举顺序(用于衍生周期展示顺序);仅允许集合内周期作为交易所直接拉取的 tf
|
||
TIMEFRAME_ORDER = ["1m", "1h", "1d", "1w"]
|
||
ALLOWED_TIMEFRAMES = set(TIMEFRAME_ORDER)
|
||
# 各基础周期一根 K 线的毫秒长度(用于历史分页与断线回退)
|
||
TIMEFRAME_TO_MS: Dict[str, int] = {
|
||
"1m": 60_000,
|
||
"1h": 3_600_000,
|
||
"1d": 86_400_000,
|
||
"1w": 604_800_000,
|
||
}
|
||
# 每个基础周期可派生出的合成周期列表(由该基础周期 K 线 resample 得到)
|
||
DERIVED_TIMEFRAME_PLAN: Dict[str, List[str]] = {
|
||
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m", "45m"],
|
||
"1h": ["2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10", "11h", "12h", "16h", "20h"],
|
||
"1d": ["2d", "3d", "4d", "5d", "6d"],
|
||
"1w": ["2w", "3w"],
|
||
}
|
||
CSV_FIELDNAMES = ["timestamp", "datetime", "open", "high", "low", "close", "volume"]
|
||
DEFAULT_LIMIT = 500
|
||
RECENT_CANDLE_LIMIT = 10
|
||
RECENT_FETCH_INTERVAL = 5 # 后台刷新循环休眠秒数
|
||
PERSIST_INTERVAL = 600 # 全量落盘周期(秒)
|
||
WS_UPDATE_CANDLE_COUNT = 2 # WebSocket 增量推送最近 K 线根数
|
||
|
||
|
||
logger = logging.getLogger("data_provider")
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
)
|
||
|
||
|
||
def to_utc_iso(timestamp_ms: int) -> str:
|
||
"""将毫秒时间戳格式化为 UTC ISO 字符串(末尾 Z)。"""
|
||
dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
|
||
return dt.isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def parse_timestamp(value: Optional[object]) -> Optional[int]:
|
||
"""解析查询参数中的时间为 UTC 毫秒时间戳;支持数字或 ISO 字符串。"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return int(value)
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if not text:
|
||
return None
|
||
if text.isdigit():
|
||
return int(text)
|
||
if text.endswith("Z"):
|
||
text = text[:-1] + "+00:00"
|
||
try:
|
||
dt = datetime.fromisoformat(text)
|
||
except ValueError as exc: # pragma: no cover - informative logging
|
||
raise ValueError(f"无法解析时间字符串: {value}") from exc
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
else:
|
||
dt = dt.astimezone(timezone.utc)
|
||
return int(dt.timestamp() * 1000)
|
||
raise ValueError(f"不支持的时间格式: {value}")
|
||
|
||
|
||
def candle_to_dict(candle: Iterable[float]) -> Dict[str, float]:
|
||
"""ccxt OHLCV 单根 [ts, o, h, l, c, v] 转为内部字典结构。"""
|
||
ts = int(candle[0])
|
||
return {
|
||
"timestamp": ts,
|
||
"datetime": to_utc_iso(ts),
|
||
"open": float(candle[1]),
|
||
"high": float(candle[2]),
|
||
"low": float(candle[3]),
|
||
"close": float(candle[4]),
|
||
"volume": float(candle[5]),
|
||
}
|
||
|
||
|
||
def timeframe_to_minutes(tf: str) -> Optional[int]:
|
||
"""将如 15m、2h 转为「分钟数」,供 resample 与衍生周期计算。"""
|
||
if not tf:
|
||
return None
|
||
unit = tf[-1]
|
||
try:
|
||
value = int(tf[:-1])
|
||
except ValueError:
|
||
return None
|
||
multiplier = {
|
||
"m": 1,
|
||
"h": 60,
|
||
"d": 1_440,
|
||
"w": 10_080,
|
||
}.get(unit)
|
||
if multiplier is None:
|
||
return None
|
||
return value * multiplier
|
||
|
||
|
||
class WebSocketManager:
|
||
"""管理 WebSocket 连接及订阅,线程安全地广播 K 线更新。"""
|
||
|
||
def __init__(self) -> None:
|
||
self._subscriptions: Dict[tuple, set] = {}
|
||
self._async_lock = asyncio.Lock()
|
||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||
|
||
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||
self._loop = loop
|
||
|
||
async def connect(self, ws: WebSocket) -> None:
|
||
await ws.accept()
|
||
logger.info("WebSocket 客户端已连接")
|
||
|
||
async def disconnect(self, ws: WebSocket) -> None:
|
||
async with self._async_lock:
|
||
for key in list(self._subscriptions):
|
||
self._subscriptions[key].discard(ws)
|
||
if not self._subscriptions[key]:
|
||
del self._subscriptions[key]
|
||
logger.info("WebSocket 客户端已断开")
|
||
|
||
async def subscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None:
|
||
key = (symbol, timeframe)
|
||
async with self._async_lock:
|
||
self._subscriptions.setdefault(key, set()).add(ws)
|
||
logger.info("WebSocket 订阅: %s %s", symbol, timeframe)
|
||
|
||
async def unsubscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None:
|
||
key = (symbol, timeframe)
|
||
async with self._async_lock:
|
||
if key in self._subscriptions:
|
||
self._subscriptions[key].discard(ws)
|
||
if not self._subscriptions[key]:
|
||
del self._subscriptions[key]
|
||
|
||
def has_subscribers(self, symbol: str, timeframe: str) -> bool:
|
||
"""非异步快速检查(供同步线程调用)。"""
|
||
return bool(self._subscriptions.get((symbol, timeframe)))
|
||
|
||
async def broadcast(
|
||
self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline",
|
||
) -> None:
|
||
key = (symbol, timeframe)
|
||
async with self._async_lock:
|
||
subscribers = list(self._subscriptions.get(key, set()))
|
||
if not subscribers:
|
||
return
|
||
message = json.dumps(
|
||
{"type": msg_type, "symbol": symbol, "timeframe": timeframe, "data": candles},
|
||
ensure_ascii=False,
|
||
)
|
||
dead: list = []
|
||
for ws in subscribers:
|
||
try:
|
||
await ws.send_text(message)
|
||
except Exception:
|
||
dead.append(ws)
|
||
if dead:
|
||
async with self._async_lock:
|
||
for ws in dead:
|
||
self._subscriptions.get(key, set()).discard(ws)
|
||
|
||
def broadcast_from_thread(
|
||
self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline",
|
||
) -> None:
|
||
"""供同步后台线程调用,将广播提交到 asyncio 事件循环。"""
|
||
if self._loop is None or self._loop.is_closed():
|
||
return
|
||
asyncio.run_coroutine_threadsafe(
|
||
self.broadcast(symbol, timeframe, candles, msg_type),
|
||
self._loop,
|
||
)
|
||
|
||
|
||
class DataProvider:
|
||
"""封装交易所连接、本地 CSV、内存缓存、断线恢复与衍生周期聚合。"""
|
||
|
||
def __init__(self, config_path: Path) -> None:
|
||
self.config_path = config_path
|
||
self.config = self._load_config()
|
||
self.exchange_name: str = self.config["exchange"]
|
||
self.symbols: List[str] = self._load_symbols(self.config)
|
||
self.timeframes: List[str] = self._validate_timeframes(self.config.get("timeframes"))
|
||
self.data_dir = Path(self.config.get("data_dir", "./data")).expanduser()
|
||
start = parse_timestamp(self.config.get("start_time"))
|
||
if start is None:
|
||
raise ValueError("配置文件必须包含 start_time 字段")
|
||
self.start_time_ms: int = start
|
||
self.exchange = self._init_exchange()
|
||
self.data: Dict[str, Dict[str, List[Dict[str, float]]]] = {
|
||
symbol: {tf: [] for tf in self.timeframes} for symbol in self.symbols
|
||
}
|
||
# 衍生周期 -> 用于合成的交易所基础周期(每个衍生只对应一个 base)
|
||
self.derived_map: Dict[str, str] = {}
|
||
for base_tf in self.timeframes:
|
||
for derived_tf in DERIVED_TIMEFRAME_PLAN.get(base_tf, []):
|
||
self.derived_map.setdefault(derived_tf, base_tf)
|
||
# 衍生周期展示顺序:按 TIMEFRAME_ORDER 中的基础周期依次展开
|
||
derived_order: List[str] = []
|
||
for base_tf in TIMEFRAME_ORDER:
|
||
if base_tf not in self.timeframes:
|
||
continue
|
||
for derived_tf in DERIVED_TIMEFRAME_PLAN.get(base_tf, []):
|
||
if derived_tf in self.derived_map and derived_tf not in derived_order:
|
||
derived_order.append(derived_tf)
|
||
self.available_timeframes: List[str] = list(self.timeframes) + derived_order
|
||
self._lock = threading.RLock()
|
||
self._ready = threading.Event()
|
||
self._stop_event = threading.Event()
|
||
self._fetch_thread: Optional[threading.Thread] = None
|
||
self._persist_thread: Optional[threading.Thread] = None
|
||
# 记录断线后需要从哪个 since 重新拉取(symbol -> timeframe -> since_ms)
|
||
self._resume_since: Dict[str, Dict[str, int]] = {}
|
||
# 恢复点持久化文件
|
||
self._resume_file: Path = self.data_dir / "resume_since.json"
|
||
# 尝试加载历史恢复点
|
||
self._load_resume_since()
|
||
self._update_callbacks: List = []
|
||
|
||
def _load_config(self) -> Dict[str, object]:
|
||
"""读取 JSON 配置文件。"""
|
||
if not self.config_path.exists():
|
||
raise FileNotFoundError(f"未找到配置文件: {self.config_path}")
|
||
with self.config_path.open("r", encoding="utf-8") as fp:
|
||
return json.load(fp)
|
||
|
||
def _load_symbols(self, config: Dict[str, object]) -> List[str]:
|
||
"""从 symbols 列表、逗号分隔字符串或单字段 symbol 解析交易对,去重保序。"""
|
||
raw_symbols: List[str] = []
|
||
symbols_value = config.get("symbols")
|
||
if isinstance(symbols_value, list):
|
||
raw_symbols = [str(item).strip() for item in symbols_value if isinstance(item, str) and item.strip()]
|
||
elif isinstance(symbols_value, str) and symbols_value.strip():
|
||
raw_symbols = [item.strip() for item in symbols_value.split(",") if item.strip()]
|
||
symbol_single = config.get("symbol")
|
||
if not raw_symbols and isinstance(symbol_single, str) and symbol_single.strip():
|
||
raw_symbols = [symbol_single.strip()]
|
||
if not raw_symbols:
|
||
raise ValueError("配置文件必须提供 symbols(列表或逗号分隔字符串)或 symbol 字段")
|
||
unique: List[str] = []
|
||
for item in raw_symbols:
|
||
if item not in unique:
|
||
unique.append(item)
|
||
return unique
|
||
|
||
def _validate_timeframes(self, configured: Optional[Iterable[str]]) -> List[str]:
|
||
"""校验周期在允许集合内;未配置则默认 TIMEFRAME_ORDER 全部;顺序优先按 TIMEFRAME_ORDER。"""
|
||
if not configured:
|
||
return list(TIMEFRAME_ORDER)
|
||
invalid = [tf for tf in configured if tf not in ALLOWED_TIMEFRAMES]
|
||
if invalid:
|
||
raise ValueError(f"不支持的时间周期: {invalid}. 允许值: {sorted(ALLOWED_TIMEFRAMES)}")
|
||
unique = []
|
||
seen = set()
|
||
for tf in TIMEFRAME_ORDER:
|
||
if tf in configured and tf not in seen:
|
||
unique.append(tf)
|
||
seen.add(tf)
|
||
for tf in configured:
|
||
if tf not in seen:
|
||
unique.append(tf)
|
||
seen.add(tf)
|
||
return unique
|
||
|
||
def _init_exchange(self):
|
||
"""实例化 ccxt 交易所,币安期货默认 defaultType=future,并 load_markets。"""
|
||
if not hasattr(ccxt, self.exchange_name):
|
||
raise ValueError(f"不支持的交易所: {self.exchange_name}")
|
||
exchange_class = getattr(ccxt, self.exchange_name)
|
||
exchange = exchange_class({"enableRateLimit": True})
|
||
if exchange.id == "binance":
|
||
exchange.options.setdefault("defaultType", "future")
|
||
exchange.load_markets()
|
||
logger.info("已初始化交易所 %s", exchange.id)
|
||
return exchange
|
||
|
||
def _data_file_path(self, symbol: str, timeframe: str) -> Path:
|
||
"""单交易对单周期的 CSV 路径:data_dir/tf/exchange_symbol_tf.csv。"""
|
||
symbol_safe = symbol.replace("/", "_").replace(":", "_")
|
||
return self.data_dir / timeframe / f"{self.exchange.id}_{symbol_safe}_{timeframe}.csv"
|
||
|
||
def _load_local(self, symbol: str, timeframe: str) -> List[Dict[str, float]]:
|
||
"""启动时从磁盘加载已有 K 线,损坏行跳过,按时间排序。"""
|
||
path = self._data_file_path(symbol, timeframe)
|
||
if not path.exists():
|
||
return []
|
||
loaded: List[Dict[str, float]] = []
|
||
with path.open("r", encoding="utf-8", newline="") as fp:
|
||
reader = csv.DictReader(fp)
|
||
for row in reader:
|
||
try:
|
||
loaded.append(
|
||
{
|
||
"timestamp": int(row["timestamp"]),
|
||
"datetime": row.get("datetime") or to_utc_iso(int(row["timestamp"])),
|
||
"open": float(row["open"]),
|
||
"high": float(row["high"]),
|
||
"low": float(row["low"]),
|
||
"close": float(row["close"]),
|
||
"volume": float(row["volume"]),
|
||
}
|
||
)
|
||
except (KeyError, ValueError):
|
||
logger.warning("忽略损坏的行: %s", row)
|
||
loaded.sort(key=lambda item: item["timestamp"])
|
||
logger.info("交易对 %s 时间周期 %s 加载本地K线数量: %s", symbol, timeframe, len(loaded))
|
||
return loaded
|
||
|
||
def _merge_candles(
|
||
self,
|
||
timeframe: str,
|
||
base: List[Dict[str, float]],
|
||
new_candles: Iterable[Iterable[float]],
|
||
) -> List[Dict[str, float]]:
|
||
"""按 timestamp 去重合并,新数据覆盖同时间戳旧数据。"""
|
||
merged = {entry["timestamp"]: entry for entry in base}
|
||
for candle in new_candles:
|
||
entry = candle_to_dict(candle)
|
||
merged[entry["timestamp"]] = entry
|
||
ordered = list(sorted(merged.values(), key=lambda item: item["timestamp"]))
|
||
logger.debug("时间周期 %s 合并后K线数量: %s", timeframe, len(ordered))
|
||
return ordered
|
||
|
||
def _write_to_disk(self, symbol: str, timeframe: str, data: List[Dict[str, float]]) -> None:
|
||
"""先写临时文件再 replace,避免写入中断导致 CSV 损坏。"""
|
||
path = self._data_file_path(symbol, timeframe)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||
try:
|
||
with tmp_path.open("w", encoding="utf-8", newline="") as fp:
|
||
writer = csv.DictWriter(fp, fieldnames=CSV_FIELDNAMES)
|
||
writer.writeheader()
|
||
writer.writerows(data)
|
||
os.replace(tmp_path, path)
|
||
finally:
|
||
if tmp_path.exists():
|
||
try:
|
||
tmp_path.unlink()
|
||
except OSError:
|
||
pass
|
||
logger.info("交易对 %s 时间周期 %s 已写入磁盘 (%s 根K线)", symbol, timeframe, len(data))
|
||
|
||
def _fetch_history(self, symbol: str, timeframe: str, since_ms: int) -> List[List[float]]:
|
||
"""从 since_ms 分页拉取直到接近当前时间;遇限频则 sleep 重试。"""
|
||
results: List[List[float]] = []
|
||
limit = 1500
|
||
now_ms = self.exchange.milliseconds()
|
||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||
fetch_since = since_ms
|
||
max_rounds = 5000
|
||
rounds = 0
|
||
while fetch_since < now_ms and rounds < max_rounds:
|
||
rounds += 1
|
||
try:
|
||
candles = self.exchange.fetch_ohlcv(
|
||
symbol,
|
||
timeframe=timeframe,
|
||
since=fetch_since,
|
||
limit=limit,
|
||
)
|
||
except ccxt.RateLimitExceeded as exc:
|
||
logger.warning("触发频率限制,等待: %s", exc)
|
||
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 1)
|
||
continue
|
||
except ccxt.BaseError as exc:
|
||
logger.error("拉取历史K线失败 (%s, %s): %s", timeframe, fetch_since, exc)
|
||
time.sleep(5)
|
||
continue
|
||
if not candles:
|
||
break
|
||
results.extend(candles)
|
||
last_ts = candles[-1][0]
|
||
fetch_since = last_ts + tf_ms
|
||
if last_ts >= now_ms - tf_ms:
|
||
break
|
||
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 0.2)
|
||
logger.info("交易对 %s 时间周期 %s 拉取历史K线数量: %s", symbol, timeframe, len(results))
|
||
return results
|
||
|
||
def initialize(self) -> None:
|
||
"""快速启动:仅加载本地磁盘已有数据到内存,然后立即标记就绪,不阻塞服务。"""
|
||
logger.info("开始加载本地数据")
|
||
for symbol in self.symbols:
|
||
for timeframe in self.timeframes:
|
||
existing = self._load_local(symbol, timeframe)
|
||
with self._lock:
|
||
self.data.setdefault(symbol, {})[timeframe] = existing
|
||
self._ready.set()
|
||
logger.info("本地数据加载完成,服务已就绪")
|
||
|
||
def run_initial_history_fetch(self) -> None:
|
||
"""后台一次性拉取所有 symbol/tf 的历史数据(从本地末根或配置起点到当前),然后落盘。"""
|
||
logger.info("开始后台历史数据拉取")
|
||
for symbol in self.symbols:
|
||
for timeframe in self.timeframes:
|
||
with self._lock:
|
||
existing = list(self.data.get(symbol, {}).get(timeframe, []))
|
||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||
last_ts = existing[-1]["timestamp"] if existing else None
|
||
if last_ts is not None:
|
||
if len(existing) >= 2:
|
||
# 从倒数第二根起拉,避免最后一根未收盘重复/缺口
|
||
fetch_since = existing[-2]["timestamp"]
|
||
else:
|
||
fetch_since = max(0, last_ts - tf_ms)
|
||
else:
|
||
fetch_since = self.start_time_ms
|
||
logger.debug(
|
||
"初始化拉取参数",
|
||
extra={
|
||
"symbol": symbol,
|
||
"timeframe": timeframe,
|
||
"existing_last": last_ts,
|
||
"fetch_since": fetch_since,
|
||
"tf_ms": tf_ms,
|
||
},
|
||
)
|
||
history = self._fetch_history(symbol, timeframe, fetch_since)
|
||
merged = self._merge_candles(timeframe, existing, history)
|
||
with self._lock:
|
||
self.data[symbol][timeframe] = merged
|
||
self._write_to_disk(symbol, timeframe, merged)
|
||
self._notify_update(symbol, timeframe)
|
||
logger.info("后台历史数据拉取完成")
|
||
|
||
def resample_df(self, df: pd.DataFrame, interval: int) -> pd.DataFrame:
|
||
"""将基础周期 DataFrame 聚合为 interval 分钟周期(freqtrade technical.util)。"""
|
||
return resample_to_interval(df, interval)
|
||
|
||
def _save_resume_since(self) -> None:
|
||
"""将断线恢复点持久化到 resume_since.json(原子替换)。"""
|
||
path = self._resume_file
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||
with self._lock:
|
||
snapshot = {
|
||
symbol: {tf: int(since) for tf, since in tf_map.items()}
|
||
for symbol, tf_map in self._resume_since.items()
|
||
}
|
||
try:
|
||
with tmp_path.open("w", encoding="utf-8") as fp:
|
||
json.dump(snapshot, fp, ensure_ascii=False, separators=(",", ":"))
|
||
os.replace(tmp_path, path)
|
||
finally:
|
||
if tmp_path.exists():
|
||
try:
|
||
tmp_path.unlink()
|
||
except OSError:
|
||
pass
|
||
logger.debug("恢复点已保存到磁盘: %s", path)
|
||
|
||
def _load_resume_since(self) -> None:
|
||
"""启动时加载恢复点;与内存合并时取更早的 since,避免漏拉。"""
|
||
path = self._resume_file
|
||
if not path.exists():
|
||
return
|
||
try:
|
||
with path.open("r", encoding="utf-8") as fp:
|
||
raw = json.load(fp)
|
||
except Exception as exc:
|
||
logger.warning("恢复点文件读取失败,忽略: %s (%s)", path, exc)
|
||
return
|
||
if not isinstance(raw, dict):
|
||
logger.warning("恢复点文件格式错误,忽略: %s", path)
|
||
return
|
||
loaded: Dict[str, Dict[str, int]] = {}
|
||
for symbol, tf_map in raw.items():
|
||
if not isinstance(tf_map, dict):
|
||
continue
|
||
per_symbol: Dict[str, int] = {}
|
||
for timeframe, since in tf_map.items():
|
||
try:
|
||
per_symbol[str(timeframe)] = int(since)
|
||
except Exception:
|
||
continue
|
||
if per_symbol:
|
||
loaded[str(symbol)] = per_symbol
|
||
if not loaded:
|
||
return
|
||
with self._lock:
|
||
# 合并为更早的 since,避免遗漏
|
||
for symbol, tf_map in loaded.items():
|
||
cur = self._resume_since.setdefault(symbol, {})
|
||
for timeframe, since in tf_map.items():
|
||
prev = cur.get(timeframe)
|
||
if prev is None or since < prev:
|
||
cur[timeframe] = since
|
||
logger.info("已加载恢复点: %s", path)
|
||
|
||
def _get_resume_since(self, symbol: str, timeframe: str) -> Optional[int]:
|
||
"""若曾断线,返回应从哪一毫秒起补拉该 symbol/tf。"""
|
||
with self._lock:
|
||
return self._resume_since.get(symbol, {}).get(timeframe)
|
||
|
||
def _set_resume_since(self, symbol: str, timeframe: str, since_ms: int) -> None:
|
||
"""断线时写入恢复点(取更早的 since 以免漏数据),并持久化到磁盘。"""
|
||
with self._lock:
|
||
per_symbol = self._resume_since.setdefault(symbol, {})
|
||
prev = per_symbol.get(timeframe)
|
||
# 取更早的 since,避免跳过数据
|
||
if prev is None or since_ms < prev:
|
||
per_symbol[timeframe] = since_ms
|
||
logger.warning(
|
||
"记录断线恢复点: %s %s since=%s (%s)",
|
||
symbol,
|
||
timeframe,
|
||
since_ms,
|
||
to_utc_iso(since_ms),
|
||
)
|
||
# 同步写盘
|
||
self._save_resume_since()
|
||
|
||
def _clear_resume_since(self, symbol: str, timeframe: str) -> None:
|
||
"""补数成功后清除该 symbol/tf 的恢复点。"""
|
||
with self._lock:
|
||
if symbol in self._resume_since and timeframe in self._resume_since[symbol]:
|
||
del self._resume_since[symbol][timeframe]
|
||
if not self._resume_since[symbol]:
|
||
del self._resume_since[symbol]
|
||
logger.info("清除断线恢复点: %s %s", symbol, timeframe)
|
||
# 同步写盘
|
||
self._save_resume_since()
|
||
|
||
def on_update(self, callback) -> None:
|
||
"""注册数据更新回调(签名: callback(symbol, timeframe))。"""
|
||
self._update_callbacks.append(callback)
|
||
|
||
def _notify_update(self, symbol: str, timeframe: str) -> None:
|
||
"""通知所有回调:某 symbol/timeframe 数据已更新。"""
|
||
for cb in self._update_callbacks:
|
||
try:
|
||
cb(symbol, timeframe)
|
||
except Exception as exc:
|
||
logger.error("数据更新回调异常: %s", exc)
|
||
|
||
def start_background_workers(self) -> None:
|
||
"""启动增量刷新线程与周期性落盘线程。"""
|
||
if self._fetch_thread and self._fetch_thread.is_alive():
|
||
return
|
||
self._stop_event.clear()
|
||
self._fetch_thread = threading.Thread(target=self._refresh_loop, name="refresh-loop", daemon=True)
|
||
self._persist_thread = threading.Thread(target=self._persist_loop, name="persist-loop", daemon=True)
|
||
self._fetch_thread.start()
|
||
self._persist_thread.start()
|
||
logger.info("后台线程已启动")
|
||
|
||
def stop(self) -> None:
|
||
"""停止后台线程(应用关闭时 lifespan finally 调用)。"""
|
||
self._stop_event.set()
|
||
if self._fetch_thread:
|
||
self._fetch_thread.join(timeout=5)
|
||
if self._persist_thread:
|
||
self._persist_thread.join(timeout=5)
|
||
logger.info("数据提供商已停止")
|
||
|
||
def _refresh_loop(self) -> None:
|
||
"""轮询各 symbol/tf:有恢复点则先补历史,否则 fetch 最近 RECENT_CANDLE_LIMIT 根。"""
|
||
while not self._stop_event.is_set():
|
||
for symbol in self.symbols:
|
||
for timeframe in self.timeframes:
|
||
try:
|
||
# 若存在断线恢复点,则优先从该 since 补齐历史数据
|
||
resume_since = self._get_resume_since(symbol, timeframe)
|
||
if resume_since is not None:
|
||
logger.info(
|
||
"开始断线后补数: %s %s since=%s (%s)",
|
||
symbol,
|
||
timeframe,
|
||
resume_since,
|
||
to_utc_iso(resume_since),
|
||
)
|
||
history = self._fetch_history(symbol, timeframe, resume_since)
|
||
with self._lock:
|
||
current = self.data.setdefault(symbol, {}).get(timeframe, [])
|
||
merged = self._merge_candles(timeframe, current, history)
|
||
self.data[symbol][timeframe] = merged
|
||
self._notify_update(symbol, timeframe)
|
||
self._clear_resume_since(symbol, timeframe)
|
||
else:
|
||
# 正常增量获取最近若干根K线
|
||
candles = self.exchange.fetch_ohlcv(
|
||
symbol,
|
||
timeframe=timeframe,
|
||
limit=RECENT_CANDLE_LIMIT,
|
||
)
|
||
if not candles:
|
||
continue
|
||
with self._lock:
|
||
current = self.data.setdefault(symbol, {}).get(timeframe, [])
|
||
merged = self._merge_candles(timeframe, current, candles)
|
||
self.data[symbol][timeframe] = merged
|
||
self._notify_update(symbol, timeframe)
|
||
except ccxt.BaseError as exc:
|
||
logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc)
|
||
# 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K
|
||
with self._lock:
|
||
current = self.data.get(symbol, {}).get(timeframe, [])
|
||
if current:
|
||
last_ts = int(current[-1]["timestamp"])
|
||
else:
|
||
last_ts = self.start_time_ms
|
||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||
# 回退一个周期,确保包含可能未完全收盘的K线,去重由 _merge_candles 处理
|
||
since_ms = max(self.start_time_ms, last_ts - tf_ms)
|
||
self._set_resume_since(symbol, timeframe, since_ms)
|
||
time.sleep(2)
|
||
continue
|
||
if self._stop_event.wait(RECENT_FETCH_INTERVAL):
|
||
break
|
||
|
||
def _persist_loop(self) -> None:
|
||
"""每隔 PERSIST_INTERVAL 秒把内存快照写 CSV 并保存恢复点。"""
|
||
while not self._stop_event.wait(PERSIST_INTERVAL):
|
||
self._persist_all()
|
||
|
||
def _persist_all(self) -> None:
|
||
"""在锁内复制 data 后落盘,避免长时间持锁。"""
|
||
if not self._ready.is_set():
|
||
return
|
||
with self._lock:
|
||
snapshot = {
|
||
symbol: {tf: list(data) for tf, data in tf_map.items()}
|
||
for symbol, tf_map in self.data.items()
|
||
}
|
||
for symbol, tf_map in snapshot.items():
|
||
for timeframe, data in tf_map.items():
|
||
self._write_to_disk(symbol, timeframe, data)
|
||
# 周期性也保存一次恢复点,保证一致性
|
||
self._save_resume_since()
|
||
|
||
def is_ready(self) -> bool:
|
||
return self._ready.is_set()
|
||
|
||
def wait_ready(self, timeout: Optional[float] = None) -> bool:
|
||
return self._ready.wait(timeout)
|
||
|
||
def get_available_timeframes(self) -> List[str]:
|
||
return list(self.available_timeframes)
|
||
|
||
def get_derived_timeframes(self) -> List[str]:
|
||
return list(self.derived_map.keys())
|
||
|
||
def _get_base_klines(
|
||
self,
|
||
symbol: str,
|
||
timeframe: str,
|
||
start_ms: Optional[int],
|
||
end_ms: Optional[int],
|
||
limit: Optional[int],
|
||
) -> List[Dict[str, float]]:
|
||
"""从内存读取已缓存的基础周期 K 线并按时间/limit 裁剪。"""
|
||
with self._lock:
|
||
candles = list(self.data.get(symbol, {}).get(timeframe, []))
|
||
if start_ms is not None:
|
||
candles = [row for row in candles if row["timestamp"] >= start_ms]
|
||
if end_ms is not None:
|
||
candles = [row for row in candles if row["timestamp"] <= end_ms]
|
||
if limit:
|
||
candles = candles[-limit:]
|
||
return candles
|
||
|
||
def get_klines(
|
||
self,
|
||
symbol: str,
|
||
timeframe: str,
|
||
start_time: Optional[object] = None,
|
||
end_time: Optional[object] = None,
|
||
limit: Optional[int] = None,
|
||
) -> List[Dict[str, float]]:
|
||
"""对外查询:基础周期直接返回;衍生周期从 derived_map 取 base,resample 后对齐时间戳再裁剪。"""
|
||
if symbol not in self.symbols:
|
||
raise HTTPException(status_code=404, detail=f"symbol {symbol} 不可用")
|
||
self.wait_ready()
|
||
start_ms = parse_timestamp(start_time)
|
||
end_ms = parse_timestamp(end_time)
|
||
if timeframe in self.timeframes:
|
||
return self._get_base_klines(symbol, timeframe, start_ms, end_ms, limit)
|
||
base_tf = self.derived_map.get(timeframe)
|
||
if not base_tf:
|
||
raise HTTPException(status_code=404, detail=f"{symbol} 时间周期 {timeframe} 不可用")
|
||
target_minutes = timeframe_to_minutes(timeframe)
|
||
if target_minutes is None:
|
||
raise HTTPException(status_code=400, detail=f"不支持的时间周期: {timeframe}")
|
||
target_ms = target_minutes * 60_000
|
||
# 起点前移一根目标周期长度,保证首根合成 K 边界完整
|
||
adjusted_start = None if start_ms is None else max(0, start_ms - target_ms)
|
||
base_candles = self._get_base_klines(symbol, base_tf, adjusted_start, end_ms, None)
|
||
if not base_candles:
|
||
return []
|
||
df = pd.DataFrame(base_candles)
|
||
if df.empty:
|
||
return []
|
||
df = df.drop_duplicates(subset=["timestamp"], keep="last").sort_values("timestamp")
|
||
df["date"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||
# resample_to_interval 按「分钟」目标周期聚合 OHLCV
|
||
resampled = self.resample_df(df, target_minutes)
|
||
if resampled is None or resampled.empty:
|
||
return []
|
||
# 统一得到毫秒 timestamp 列(resample 可能返回 date 或 DatetimeIndex)
|
||
if "timestamp" in resampled.columns:
|
||
resampled_df = resampled.copy()
|
||
else:
|
||
resampled_df = resampled.copy()
|
||
if "date" in resampled_df.columns:
|
||
dates = pd.to_datetime(resampled_df["date"], utc=True, errors="coerce")
|
||
resampled_df["timestamp"] = (dates.astype("int64", copy=False) // 1_000_000).astype("int64")
|
||
elif isinstance(resampled_df.index, pd.DatetimeIndex):
|
||
idx = resampled_df.index
|
||
if idx.tz is None:
|
||
idx = idx.tz_localize("UTC")
|
||
else:
|
||
idx = idx.tz_convert("UTC")
|
||
resampled_df["timestamp"] = (idx.astype("int64", copy=False) // 1_000_000).astype("int64")
|
||
else:
|
||
raise HTTPException(status_code=500, detail=f"聚合结果缺少 timestamp 列 ({timeframe})")
|
||
resampled_df = resampled_df.dropna(subset=["timestamp"]).sort_values("timestamp")
|
||
if start_ms is not None:
|
||
resampled_df = resampled_df[resampled_df["timestamp"] >= start_ms]
|
||
if end_ms is not None:
|
||
resampled_df = resampled_df[resampled_df["timestamp"] <= end_ms]
|
||
if resampled_df.empty:
|
||
return []
|
||
resampled_df["datetime"] = resampled_df["timestamp"].apply(to_utc_iso)
|
||
for column in ["open", "high", "low", "close", "volume"]:
|
||
if column not in resampled_df.columns:
|
||
resampled_df[column] = 0.0
|
||
resampled_df = resampled_df[["timestamp", "datetime", "open", "high", "low", "close", "volume"]]
|
||
result = resampled_df.to_dict("records")
|
||
if limit:
|
||
result = result[-limit:]
|
||
logger.debug(
|
||
"衍生周期返回",
|
||
extra={
|
||
"symbol": symbol,
|
||
"timeframe": timeframe,
|
||
"base_timeframe": base_tf,
|
||
"count": len(result),
|
||
},
|
||
)
|
||
return result
|
||
|
||
|
||
def create_app(provider: DataProvider) -> FastAPI:
|
||
"""构造 FastAPI 应用:lifespan 内同步 initialize 并启动后台拉数;WebSocket 实时推送。"""
|
||
ws_manager = WebSocketManager()
|
||
|
||
def _on_data_update(symbol: str, base_tf: str) -> None:
|
||
"""后台刷新线程回调:广播基础及衍生周期更新给 WebSocket 订阅者。"""
|
||
with provider._lock:
|
||
base_data = list(provider.data.get(symbol, {}).get(base_tf, []))
|
||
recent = base_data[-WS_UPDATE_CANDLE_COUNT:] if base_data else []
|
||
if recent:
|
||
ws_manager.broadcast_from_thread(symbol, base_tf, recent)
|
||
for derived_tf, src_base in provider.derived_map.items():
|
||
if src_base != base_tf or not ws_manager.has_subscribers(symbol, derived_tf):
|
||
continue
|
||
try:
|
||
target_min = timeframe_to_minutes(derived_tf)
|
||
if target_min is None:
|
||
continue
|
||
now_ms = int(time.time() * 1000)
|
||
window_ms = target_min * 60_000 * (WS_UPDATE_CANDLE_COUNT + 2)
|
||
derived = provider.get_klines(
|
||
symbol, derived_tf, start_time=now_ms - window_ms, limit=WS_UPDATE_CANDLE_COUNT,
|
||
)
|
||
if derived:
|
||
ws_manager.broadcast_from_thread(symbol, derived_tf, derived)
|
||
except Exception as exc:
|
||
logger.debug("衍生周期广播失败 %s %s: %s", symbol, derived_tf, exc)
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
loop = asyncio.get_running_loop()
|
||
ws_manager.set_loop(loop)
|
||
provider.on_update(_on_data_update)
|
||
# 快速加载本地数据后立即就绪,不阻塞服务启动
|
||
await loop.run_in_executor(None, provider.initialize)
|
||
provider.start_background_workers()
|
||
# 后台拉取历史数据补齐(不阻塞 HTTP/WS 服务)
|
||
threading.Thread(
|
||
target=provider.run_initial_history_fetch,
|
||
name="initial-history-fetch",
|
||
daemon=True,
|
||
).start()
|
||
try:
|
||
yield
|
||
finally:
|
||
provider.stop()
|
||
|
||
app = FastAPI(title="Chan 数据提供商", version="1.0.0", lifespan=lifespan)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
@app.get("/health")
|
||
async def health() -> Dict[str, object]:
|
||
"""存活检查:交易所、交易对、基础/衍生周期、是否已完成冷启动。"""
|
||
return {
|
||
"status": "ok",
|
||
"exchange": provider.exchange_name,
|
||
"symbols": provider.symbols,
|
||
"base_timeframes": provider.timeframes,
|
||
"derived_timeframes": provider.get_derived_timeframes(),
|
||
"timeframes": provider.get_available_timeframes(),
|
||
"ready": provider.is_ready(),
|
||
}
|
||
|
||
@app.get("/timeframes")
|
||
async def list_timeframes() -> Dict[str, List[str]]:
|
||
"""返回配置的基础周期与可合成的衍生周期列表。"""
|
||
provider.wait_ready()
|
||
return {
|
||
"base_timeframes": provider.timeframes,
|
||
"derived_timeframes": provider.get_derived_timeframes(),
|
||
"timeframes": provider.get_available_timeframes(),
|
||
}
|
||
|
||
@app.get("/api/candles")
|
||
async def api_candles(
|
||
symbol: str = Query(..., description="如 BTC/USDT"),
|
||
tf: str = Query("1m", description="时间周期"),
|
||
start: Optional[int] = Query(None, description="开始时间戳(ms)"),
|
||
end: Optional[int] = Query(None, description="结束时间戳(ms)"),
|
||
limit: Optional[int] = Query(None, description="可选,限制返回数量"),
|
||
):
|
||
"""按交易对与时间周期返回 OHLCV;tf 支持配置的基础周期及衍生合成周期。"""
|
||
data = provider.get_klines(symbol=symbol, timeframe=tf, start_time=start, end_time=end, limit=limit)
|
||
return data
|
||
|
||
homepage_path = Path(__file__).resolve().parent / "homepage.html"
|
||
docs_path = Path(__file__).resolve().parent / "api_docs.html"
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def root():
|
||
"""服务主页。"""
|
||
if homepage_path.exists():
|
||
return HTMLResponse(content=homepage_path.read_text(encoding="utf-8"))
|
||
return HTMLResponse(content="<h1>主页页面未找到</h1>", status_code=404)
|
||
|
||
@app.websocket("/ws")
|
||
async def websocket_endpoint(ws: WebSocket):
|
||
"""WebSocket 实时 K 线推送。
|
||
|
||
客户端发送 JSON:
|
||
{"action": "subscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"}
|
||
{"action": "unsubscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"}
|
||
{"action": "ping"}
|
||
服务端推送:
|
||
{"type": "subscribed", "symbol": "...", "timeframe": "..."}
|
||
{"type": "snapshot", "symbol": "...", "timeframe": "...", "data": [...]}
|
||
{"type": "kline", "symbol": "...", "timeframe": "...", "data": [...]}
|
||
{"type": "pong"}
|
||
{"type": "error", "message": "..."}
|
||
"""
|
||
await ws_manager.connect(ws)
|
||
try:
|
||
while True:
|
||
raw = await ws.receive_text()
|
||
try:
|
||
msg = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
await ws.send_text(json.dumps({"type": "error", "message": "invalid JSON"}))
|
||
continue
|
||
|
||
action = msg.get("action", "")
|
||
symbol = str(msg.get("symbol", "")).strip()
|
||
timeframe = str(msg.get("timeframe", "")).strip()
|
||
|
||
if action == "ping":
|
||
await ws.send_text(json.dumps({"type": "pong"}))
|
||
|
||
elif action == "subscribe":
|
||
if not symbol or not timeframe:
|
||
await ws.send_text(json.dumps(
|
||
{"type": "error", "message": "需要 symbol 和 timeframe 字段"}
|
||
))
|
||
continue
|
||
await ws_manager.subscribe(ws, symbol, timeframe)
|
||
await ws.send_text(json.dumps(
|
||
{"type": "subscribed", "symbol": symbol, "timeframe": timeframe},
|
||
ensure_ascii=False,
|
||
))
|
||
try:
|
||
snapshot = provider.get_klines(symbol, timeframe, limit=DEFAULT_LIMIT)
|
||
if snapshot:
|
||
await ws.send_text(json.dumps(
|
||
{"type": "snapshot", "symbol": symbol, "timeframe": timeframe, "data": snapshot},
|
||
ensure_ascii=False,
|
||
))
|
||
except Exception as exc:
|
||
await ws.send_text(json.dumps({"type": "error", "message": str(exc)}))
|
||
|
||
elif action == "unsubscribe":
|
||
await ws_manager.unsubscribe(ws, symbol, timeframe)
|
||
await ws.send_text(json.dumps(
|
||
{"type": "unsubscribed", "symbol": symbol, "timeframe": timeframe},
|
||
ensure_ascii=False,
|
||
))
|
||
|
||
else:
|
||
await ws.send_text(json.dumps({"type": "error", "message": f"未知 action: {action}"}))
|
||
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
await ws_manager.disconnect(ws)
|
||
|
||
@app.get("/api/docs", response_class=HTMLResponse, include_in_schema=False)
|
||
async def api_docs():
|
||
"""返回自定义 API 文档页面。"""
|
||
if docs_path.exists():
|
||
return HTMLResponse(content=docs_path.read_text(encoding="utf-8"))
|
||
return HTMLResponse(content="<h1>API 文档页面未找到</h1>", status_code=404)
|
||
|
||
return app
|
||
|
||
|
||
def build_app() -> FastAPI:
|
||
"""默认入口:从环境变量 CONFIG_PATH(或 config.json)加载配置并创建 FastAPI app。"""
|
||
config_path = Path(os.getenv("CONFIG_PATH", "config.json"))
|
||
provider = DataProvider(config_path)
|
||
return create_app(provider)
|
||
|
||
|
||
app = build_app()
|
||
|
||
|
||
def main() -> None:
|
||
"""直接运行本模块时启动 uvicorn(监听 UVICORN_HOST / UVICORN_PORT)。"""
|
||
host = os.getenv("UVICORN_HOST", "0.0.0.0")
|
||
port = int(os.getenv("UVICORN_PORT", "9009"))
|
||
|
||
uvicorn.run(app, host=host, port=port, log_level=os.getenv("UVICORN_LOG_LEVEL", "info"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
|