添加新币对和优化拉取
This commit is contained in:
+1
-1
@@ -9,10 +9,10 @@ COPY requirements.txt /app/requirements.txt
|
|||||||
RUN pip install -r /app/requirements.txt
|
RUN pip install -r /app/requirements.txt
|
||||||
|
|
||||||
COPY app /app/app
|
COPY app /app/app
|
||||||
|
COPY pairs.json /app/pairs.json
|
||||||
|
|
||||||
ENV DATA_DIR=/data \
|
ENV DATA_DIR=/data \
|
||||||
EXCHANGE=binance \
|
EXCHANGE=binance \
|
||||||
SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT \
|
|
||||||
TIMEFRAMES=1m,1h,1d,1w,1M \
|
TIMEFRAMES=1m,1h,1d,1w,1M \
|
||||||
START_FROM=2022-01-01 \
|
START_FROM=2022-01-01 \
|
||||||
POLL_FACTOR=0.5
|
POLL_FACTOR=0.5
|
||||||
|
|||||||
+4
-3
@@ -17,10 +17,11 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `DATA_DIR` | 本地 Parquet 存储目录 | `/data` |
|
| `DATA_DIR` | 本地 Parquet 存储目录 | `/data` |
|
||||||
| `EXCHANGE` | 交易所标识(目前支持 binance) | `binance` |
|
| `EXCHANGE` | 交易所标识(目前支持 binance) | `binance` |
|
||||||
| `SYMBOLS` | 逗号分隔的交易对列表 | `BTC/USDT:USDT,ETH/USDT:USDT` |
|
|
||||||
| `TIMEFRAMES` | 基础抓取周期,逗号分隔 | `1m,1h,1d,1w,1M` |
|
| `TIMEFRAMES` | 基础抓取周期,逗号分隔 | `1m,1h,1d,1w,1M` |
|
||||||
| `START_FROM` | 首次启动回补的起始 UTC 时间(ISO 字符串或毫秒时间戳) | `2022-01-01` |
|
| `START_FROM` | 首次启动回补的起始 UTC 时间(ISO 字符串或毫秒时间戳) | `2022-01-01` |
|
||||||
| `POLL_FACTOR` | 拉取间隔因子,实际间隔 = 周期毫秒 × factor | `0.5` |
|
| `POLL_FACTOR` | 拉取间隔因子,实际间隔 = 周期毫秒 × factor | `0.5` |
|
||||||
|
| `REST_MAX_CONCURRENCY` | REST 历史拉取并发数 | `4` |
|
||||||
|
| `VERIFY_MAX_CONCURRENCY` | 校验请求并发数 | `2` |
|
||||||
| `BACKOFF_BASE / BACKOFF_MAX` | 异常重试的指数退避参数 | `2.0 / 30.0` |
|
| `BACKOFF_BASE / BACKOFF_MAX` | 异常重试的指数退避参数 | `2.0 / 30.0` |
|
||||||
|
|
||||||
> 衍生周期列表由程序自动推导,无需手动写入 `TIMEFRAMES`。
|
> 衍生周期列表由程序自动推导,无需手动写入 `TIMEFRAMES`。
|
||||||
@@ -40,7 +41,6 @@ docker compose down # 关闭
|
|||||||
### 2.2 本地运行(无 Docker)
|
### 2.2 本地运行(无 Docker)
|
||||||
```bash
|
```bash
|
||||||
export DATA_DIR=./data
|
export DATA_DIR=./data
|
||||||
export SYMBOLS="BTC/USDT:USDT"
|
|
||||||
export TIMEFRAMES="1m,1h,1d"
|
export TIMEFRAMES="1m,1h,1d"
|
||||||
|
|
||||||
cd /Users/jack/Project/freqtrade
|
cd /Users/jack/Project/freqtrade
|
||||||
@@ -134,7 +134,8 @@ ws://localhost:8000/ws?symbol=ETH/USDT:USDT&tf=15m&since=1700000000000
|
|||||||
确认该周期在 `TIMEFRAMES` 或自动聚合列表中;若是衍生周期,需要确保对应基础周期已在运行。
|
确认该周期在 `TIMEFRAMES` 或自动聚合列表中;若是衍生周期,需要确保对应基础周期已在运行。
|
||||||
|
|
||||||
3. **如何新增交易对/周期**
|
3. **如何新增交易对/周期**
|
||||||
修改环境变量或 docker-compose 配置后,重启服务即可;Parquet 文件会按需生成。
|
- 交易对:编辑 `pairs.json`,每行一个字符串,保存后重启服务。
|
||||||
|
- 周期:修改 `TIMEFRAMES` 环境变量(Docker 或本地启动命令)后重启。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+347
-92
@@ -5,11 +5,13 @@ import logging
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import ccxt
|
import ccxt
|
||||||
import ccxt.async_support as ccxt_async
|
import ccxt.async_support as ccxt_async
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import websockets
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException, status
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -24,6 +26,7 @@ from .storage import (
|
|||||||
read_candles,
|
read_candles,
|
||||||
upsert_candles,
|
upsert_candles,
|
||||||
get_last_timestamp,
|
get_last_timestamp,
|
||||||
|
read_candle_exact,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,9 +36,16 @@ logging.basicConfig(
|
|||||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("datasvc")
|
logger = logging.getLogger("datasvc")
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_PAIRS_FILE = BASE_DIR / "pairs.json"
|
||||||
RESAMPLE_AVAILABLE = resample_to_interval is not None
|
RESAMPLE_AVAILABLE = resample_to_interval is not None
|
||||||
RESAMPLE_WARNING_EMITTED = False
|
RESAMPLE_WARNING_EMITTED = False
|
||||||
|
|
||||||
|
REST_MAX_CONCURRENCY = int(os.environ.get("REST_MAX_CONCURRENCY", "4"))
|
||||||
|
VERIFY_MAX_CONCURRENCY = int(os.environ.get("VERIFY_MAX_CONCURRENCY", "2"))
|
||||||
|
REST_FETCH_SEMAPHORE = asyncio.Semaphore(max(1, REST_MAX_CONCURRENCY))
|
||||||
|
VERIFY_FETCH_SEMAPHORE = asyncio.Semaphore(max(1, VERIFY_MAX_CONCURRENCY))
|
||||||
|
|
||||||
AGGREGATION_PLAN: Dict[str, List[str]] = {
|
AGGREGATION_PLAN: Dict[str, List[str]] = {
|
||||||
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m"],
|
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m"],
|
||||||
"1h": ["2h", "3h", "4h", "6h", "8h", "12h", "16h"],
|
"1h": ["2h", "3h", "4h", "6h", "8h", "12h", "16h"],
|
||||||
@@ -61,6 +71,37 @@ def _unique_preserve(values: List[str]) -> List[str]:
|
|||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _load_symbols() -> List[str]:
|
||||||
|
path = DEFAULT_PAIRS_FILE
|
||||||
|
if not path.is_file():
|
||||||
|
default_symbols = ["BTC/USDT:USDT"]
|
||||||
|
logger.warning("交易对配置文件不存在,使用默认值", extra={"file": str(path), "symbols": default_symbols})
|
||||||
|
return default_symbols
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
default_symbols = ["BTC/USDT:USDT"]
|
||||||
|
logger.exception("读取交易对配置文件失败,使用默认值", extra={"file": str(path), "symbols": default_symbols})
|
||||||
|
return default_symbols
|
||||||
|
|
||||||
|
raw_symbols: List[str] = []
|
||||||
|
if isinstance(data, list):
|
||||||
|
raw_symbols = [str(item).strip() for item in data if isinstance(item, str) and item.strip()]
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
candidates = data.get("symbols") or data.get("pairs")
|
||||||
|
if isinstance(candidates, list):
|
||||||
|
raw_symbols = [str(item).strip() for item in candidates if isinstance(item, str) and item.strip()]
|
||||||
|
if not raw_symbols:
|
||||||
|
default_symbols = ["BTC/USDT:USDT"]
|
||||||
|
logger.warning("交易对配置文件未提供有效列表,使用默认值", extra={"file": str(path), "symbols": default_symbols})
|
||||||
|
return default_symbols
|
||||||
|
|
||||||
|
symbols = _unique_preserve(raw_symbols)
|
||||||
|
logger.info("已从配置文件载入交易对", extra={"file": str(path), "symbols": symbols})
|
||||||
|
return symbols
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_minutes(tf: str) -> Optional[int]:
|
def timeframe_to_minutes(tf: str) -> Optional[int]:
|
||||||
if not tf:
|
if not tf:
|
||||||
return None
|
return None
|
||||||
@@ -80,11 +121,24 @@ def timeframe_to_minutes(tf: str) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
return value * multiplier
|
return value * multiplier
|
||||||
|
|
||||||
|
|
||||||
|
def binance_stream_symbol(symbol: str) -> str:
|
||||||
|
try:
|
||||||
|
base, rest = symbol.split("/", 1)
|
||||||
|
except ValueError:
|
||||||
|
cleaned = symbol.replace("/", "").split(":")[0]
|
||||||
|
return cleaned.lower()
|
||||||
|
quote = rest.split(":")[0]
|
||||||
|
return f"{base}{quote}".lower()
|
||||||
|
|
||||||
|
|
||||||
|
def build_stream_url(symbol: str, timeframe: str) -> str:
|
||||||
|
stream_symbol = binance_stream_symbol(symbol)
|
||||||
|
return f"{BINANCE_WS_BASE}/{stream_symbol}@kline_{timeframe}"
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||||
EXCHANGE = os.environ.get("EXCHANGE", "binance")
|
EXCHANGE = os.environ.get("EXCHANGE", "binance")
|
||||||
SYMBOLS = _split_env_list(os.environ.get("SYMBOLS", "BTC/USDT:USDT,ETH/USDT:USDT"))
|
SYMBOLS = _load_symbols()
|
||||||
if not SYMBOLS:
|
|
||||||
SYMBOLS = ["BTC/USDT:USDT"]
|
|
||||||
|
|
||||||
_default_timeframes = ["1m", "1h", "1d", "1w", "1M"]
|
_default_timeframes = ["1m", "1h", "1d", "1w", "1M"]
|
||||||
requested_timeframes = _split_env_list(os.environ.get("TIMEFRAMES", ",".join(_default_timeframes)))
|
requested_timeframes = _split_env_list(os.environ.get("TIMEFRAMES", ",".join(_default_timeframes)))
|
||||||
@@ -104,6 +158,7 @@ START_FROM = os.environ.get("START_FROM", "2022-01-01") # 首次启动拉取起
|
|||||||
POLL_FACTOR = float(os.environ.get("POLL_FACTOR", "0.5")) # 轮询间隔 = tf_ms * factor
|
POLL_FACTOR = float(os.environ.get("POLL_FACTOR", "0.5")) # 轮询间隔 = tf_ms * factor
|
||||||
BACKOFF_BASE = float(os.environ.get("BACKOFF_BASE", "2.0"))
|
BACKOFF_BASE = float(os.environ.get("BACKOFF_BASE", "2.0"))
|
||||||
BACKOFF_MAX = float(os.environ.get("BACKOFF_MAX", "30.0"))
|
BACKOFF_MAX = float(os.environ.get("BACKOFF_MAX", "30.0"))
|
||||||
|
BINANCE_WS_BASE = os.environ.get("BINANCE_WS_BASE", "wss://fstream.binance.com/ws").rstrip("/")
|
||||||
|
|
||||||
VALID_SYMBOLS = set(SYMBOLS)
|
VALID_SYMBOLS = set(SYMBOLS)
|
||||||
VALID_TIMEFRAMES = set(AVAILABLE_TIMEFRAMES)
|
VALID_TIMEFRAMES = set(AVAILABLE_TIMEFRAMES)
|
||||||
@@ -194,6 +249,7 @@ class Hub:
|
|||||||
hub = Hub()
|
hub = Hub()
|
||||||
|
|
||||||
fetch_tasks: List[asyncio.Task] = []
|
fetch_tasks: List[asyncio.Task] = []
|
||||||
|
verification_queues: Dict[Tuple[str, str], asyncio.Queue[int]] = {}
|
||||||
|
|
||||||
|
|
||||||
def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: List[str]) -> List[Tuple[str, List[CandleRow]]]:
|
def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: List[str]) -> List[Tuple[str, List[CandleRow]]]:
|
||||||
@@ -227,14 +283,14 @@ def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: Lis
|
|||||||
if "timestamp" not in derived_df.columns:
|
if "timestamp" not in derived_df.columns:
|
||||||
if "date" in derived_df.columns:
|
if "date" in derived_df.columns:
|
||||||
dates = pd.to_datetime(derived_df["date"], utc=True, errors="coerce")
|
dates = pd.to_datetime(derived_df["date"], utc=True, errors="coerce")
|
||||||
derived_df["timestamp"] = (dates.view("int64") // 1_000_000)
|
derived_df["timestamp"] = (dates.astype("int64") // 1_000_000)
|
||||||
elif isinstance(derived_df.index, pd.DatetimeIndex):
|
elif isinstance(derived_df.index, pd.DatetimeIndex):
|
||||||
idx = derived_df.index
|
idx = derived_df.index
|
||||||
if idx.tz is None:
|
if idx.tz is None:
|
||||||
idx = idx.tz_localize("UTC")
|
idx = idx.tz_localize("UTC")
|
||||||
else:
|
else:
|
||||||
idx = idx.tz_convert("UTC")
|
idx = idx.tz_convert("UTC")
|
||||||
derived_df["timestamp"] = (idx.view("int64") // 1_000_000)
|
derived_df["timestamp"] = (idx.astype("int64") // 1_000_000)
|
||||||
if "timestamp" not in derived_df.columns:
|
if "timestamp" not in derived_df.columns:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"聚合结果缺少 timestamp 列,已跳过",
|
"聚合结果缺少 timestamp 列,已跳过",
|
||||||
@@ -280,6 +336,7 @@ class FetchState:
|
|||||||
last_candle_ts: Optional[int] = None
|
last_candle_ts: Optional[int] = None
|
||||||
consecutive_errors: int = 0
|
consecutive_errors: int = 0
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
|
last_verified_ts: Optional[int] = None
|
||||||
|
|
||||||
def to_payload(self) -> dict:
|
def to_payload(self) -> dict:
|
||||||
def serialize_dt(dt: Optional[datetime]) -> Optional[str]:
|
def serialize_dt(dt: Optional[datetime]) -> Optional[str]:
|
||||||
@@ -295,20 +352,210 @@ class FetchState:
|
|||||||
"last_candle_ts": self.last_candle_ts,
|
"last_candle_ts": self.last_candle_ts,
|
||||||
"consecutive_errors": self.consecutive_errors,
|
"consecutive_errors": self.consecutive_errors,
|
||||||
"last_error": self.last_error,
|
"last_error": self.last_error,
|
||||||
|
"last_verified_ts": self.last_verified_ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fetch_states: Dict[Tuple[str, str], FetchState] = {}
|
fetch_states: Dict[Tuple[str, str], FetchState] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_candles(
|
||||||
|
symbol: str,
|
||||||
|
timeframe: str,
|
||||||
|
candles: List[CandleRow],
|
||||||
|
derived_timeframes: List[str],
|
||||||
|
tf_ms: int,
|
||||||
|
schedule_verification: bool,
|
||||||
|
) -> None:
|
||||||
|
if not candles:
|
||||||
|
return
|
||||||
|
state_key = (symbol, timeframe)
|
||||||
|
upsert_candles(DATA_DIR, symbol, timeframe, candles)
|
||||||
|
derived_updates: List[Tuple[str, List[CandleRow]]] = []
|
||||||
|
if derived_timeframes and RESAMPLE_AVAILABLE:
|
||||||
|
derived_updates = await asyncio.to_thread(
|
||||||
|
resample_and_store,
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
derived_timeframes,
|
||||||
|
)
|
||||||
|
for row in candles[-3:]:
|
||||||
|
payload = {
|
||||||
|
"topic": f"candles.{symbol}.{timeframe}",
|
||||||
|
"type": "upsert",
|
||||||
|
"data": {
|
||||||
|
"t": row[0],
|
||||||
|
"o": row[1],
|
||||||
|
"h": row[2],
|
||||||
|
"l": row[3],
|
||||||
|
"c": row[4],
|
||||||
|
"v": row[5],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await hub.publish(symbol, timeframe, payload)
|
||||||
|
for target_tf, rows in derived_updates:
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
for row in rows:
|
||||||
|
ts = int(row[0])
|
||||||
|
o, h, l, c, v = map(float, row[1:])
|
||||||
|
payload = {
|
||||||
|
"topic": f"candles.{symbol}.{target_tf}",
|
||||||
|
"type": "upsert",
|
||||||
|
"data": {
|
||||||
|
"t": ts,
|
||||||
|
"o": o,
|
||||||
|
"h": h,
|
||||||
|
"l": l,
|
||||||
|
"c": c,
|
||||||
|
"v": v,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await hub.publish(symbol, target_tf, payload)
|
||||||
|
state = fetch_states.get(state_key)
|
||||||
|
if state:
|
||||||
|
state.last_fetch_at = datetime.utcnow()
|
||||||
|
state.last_candle_ts = candles[-1][0]
|
||||||
|
state.consecutive_errors = 0
|
||||||
|
state.last_error = None
|
||||||
|
if schedule_verification:
|
||||||
|
queue = verification_queues.get(state_key)
|
||||||
|
if queue:
|
||||||
|
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
latest_ts = candles[-1][0]
|
||||||
|
if now_ms - latest_ts <= 2 * tf_ms:
|
||||||
|
verify_ts = latest_ts - tf_ms
|
||||||
|
if verify_ts > 0 and (state.last_verified_ts is None or verify_ts > state.last_verified_ts):
|
||||||
|
try:
|
||||||
|
queue.put_nowait(verify_ts)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.warning(
|
||||||
|
"验证队列已满,丢弃此次校验请求",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "timestamp": verify_ts},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def rest_catchup(
|
||||||
|
symbol: str,
|
||||||
|
timeframe: str,
|
||||||
|
derived_timeframes: List[str],
|
||||||
|
tf_ms: int,
|
||||||
|
start_since: int,
|
||||||
|
) -> None:
|
||||||
|
state_key = (symbol, timeframe)
|
||||||
|
state = fetch_states[state_key]
|
||||||
|
exchange = build_exchange()
|
||||||
|
since = start_since
|
||||||
|
backoff = 1.0
|
||||||
|
logger.info("开始 REST 补齐历史", extra={"symbol": symbol, "timeframe": timeframe, "since": since})
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
if since >= now_ms - tf_ms:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
async with REST_FETCH_SEMAPHORE:
|
||||||
|
candles = await exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeNotAvailable, ccxt.RequestTimeout) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"历史补齐网络异常,准备重试",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "error": str(exc)},
|
||||||
|
)
|
||||||
|
state.last_error = str(exc)
|
||||||
|
state.consecutive_errors += 1
|
||||||
|
backoff = min(backoff * BACKOFF_BASE, BACKOFF_MAX)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"历史补齐发生异常,准备重试",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe},
|
||||||
|
)
|
||||||
|
state.last_error = str(exc)
|
||||||
|
state.consecutive_errors += 1
|
||||||
|
backoff = min(backoff * BACKOFF_BASE, BACKOFF_MAX)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
continue
|
||||||
|
if not candles:
|
||||||
|
break
|
||||||
|
await process_candles(symbol, timeframe, candles, derived_timeframes, tf_ms, schedule_verification=False)
|
||||||
|
since = candles[-1][0] + tf_ms
|
||||||
|
state.consecutive_errors = 0
|
||||||
|
state.last_error = None
|
||||||
|
backoff = 1.0
|
||||||
|
|
||||||
|
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||||
|
lag = now_ms - since
|
||||||
|
if lag > tf_ms * 10:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(max(1.0, tf_ms * POLL_FACTOR / 1000.0))
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
await exchange.close()
|
||||||
|
logger.info("REST 补齐完成", extra={"symbol": symbol, "timeframe": timeframe, "latest": state.last_candle_ts})
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_loop(symbol: str, timeframe: str, derived_timeframes: List[str], tf_ms: int):
|
||||||
|
state_key = (symbol, timeframe)
|
||||||
|
url = build_stream_url(symbol, timeframe)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
|
||||||
|
logger.info("WebSocket 已连接", extra={"symbol": symbol, "timeframe": timeframe, "url": url})
|
||||||
|
async for message in ws:
|
||||||
|
data = json.loads(message)
|
||||||
|
kline = data.get("k")
|
||||||
|
if not kline or not kline.get("x"):
|
||||||
|
continue
|
||||||
|
row: CandleRow = [
|
||||||
|
int(kline["t"]),
|
||||||
|
float(kline["o"]),
|
||||||
|
float(kline["h"]),
|
||||||
|
float(kline["l"]),
|
||||||
|
float(kline["c"]),
|
||||||
|
float(kline["v"]),
|
||||||
|
]
|
||||||
|
await process_candles(symbol, timeframe, [row], derived_timeframes, tf_ms, schedule_verification=True)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("取消 WebSocket 任务", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"WebSocket 连接异常,准备重连",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "error": str(exc)},
|
||||||
|
)
|
||||||
|
state = fetch_states.get(state_key)
|
||||||
|
start_since = None
|
||||||
|
if state and state.last_candle_ts:
|
||||||
|
start_since = state.last_candle_ts + tf_ms
|
||||||
|
if start_since:
|
||||||
|
await rest_catchup(symbol, timeframe, derived_timeframes, tf_ms, start_since)
|
||||||
|
await asyncio.sleep(5.0)
|
||||||
|
|
||||||
|
|
||||||
def build_exchange():
|
def build_exchange():
|
||||||
if EXCHANGE.lower() == "binance":
|
if EXCHANGE.lower() == "binance":
|
||||||
return ccxt_async.binance({"enableRateLimit": True})
|
return ccxt_async.binance(
|
||||||
|
{
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"timeout": 20_000,
|
||||||
|
"options": {
|
||||||
|
"adjustForTimeDifference": True,
|
||||||
|
"defaultType": "future",
|
||||||
|
"defaultSubType": "linear",
|
||||||
|
"defaultMarket": "future",
|
||||||
|
"defaultSettle": "USDT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
raise RuntimeError(f"Unsupported EXCHANGE: {EXCHANGE}")
|
raise RuntimeError(f"Unsupported EXCHANGE: {EXCHANGE}")
|
||||||
|
|
||||||
|
|
||||||
async def fetch_loop(symbol: str, timeframe: str):
|
async def fetch_loop(symbol: str, timeframe: str):
|
||||||
"""持续增量抓取并广播。"""
|
"""初次通过 REST 补齐历史,随后转入 Binance WebSocket 拉取增量。"""
|
||||||
derived_timeframes = AGGREGATION_TARGETS.get(timeframe, [])
|
derived_timeframes = AGGREGATION_TARGETS.get(timeframe, [])
|
||||||
global RESAMPLE_WARNING_EMITTED
|
global RESAMPLE_WARNING_EMITTED
|
||||||
if derived_timeframes and not RESAMPLE_AVAILABLE and not RESAMPLE_WARNING_EMITTED:
|
if derived_timeframes and not RESAMPLE_AVAILABLE and not RESAMPLE_WARNING_EMITTED:
|
||||||
@@ -318,117 +565,124 @@ async def fetch_loop(symbol: str, timeframe: str):
|
|||||||
)
|
)
|
||||||
RESAMPLE_WARNING_EMITTED = True
|
RESAMPLE_WARNING_EMITTED = True
|
||||||
|
|
||||||
exchange = build_exchange()
|
|
||||||
tf_ms = tf_to_ms(timeframe)
|
tf_ms = tf_to_ms(timeframe)
|
||||||
start_since = parse_start_from_ms(START_FROM)
|
|
||||||
last_ts = get_last_timestamp(DATA_DIR, symbol, timeframe)
|
|
||||||
since = max(start_since, (last_ts + tf_ms) if last_ts else start_since)
|
|
||||||
backoff = 1.0
|
|
||||||
state_key = (symbol, timeframe)
|
state_key = (symbol, timeframe)
|
||||||
|
last_ts = get_last_timestamp(DATA_DIR, symbol, timeframe)
|
||||||
fetch_states[state_key] = FetchState(symbol=symbol, timeframe=timeframe, last_candle_ts=last_ts)
|
fetch_states[state_key] = FetchState(symbol=symbol, timeframe=timeframe, last_candle_ts=last_ts)
|
||||||
|
queue = verification_queues.get(state_key)
|
||||||
|
if queue is None:
|
||||||
|
queue = asyncio.Queue(maxsize=500)
|
||||||
|
verification_queues[state_key] = queue
|
||||||
|
if last_ts is not None:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(last_ts)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.warning(
|
||||||
|
"重启后无法排入校验任务,队列已满",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "timestamp": last_ts},
|
||||||
|
)
|
||||||
|
|
||||||
|
start_since = parse_start_from_ms(START_FROM)
|
||||||
|
if last_ts is not None:
|
||||||
|
rewind_since = max(0, last_ts - tf_ms)
|
||||||
|
initial_since = max(start_since, rewind_since)
|
||||||
|
else:
|
||||||
|
initial_since = start_since
|
||||||
|
|
||||||
logger.info("启动拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
|
logger.info("启动拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
await rest_catchup(symbol, timeframe, derived_timeframes, tf_ms, initial_since)
|
||||||
try:
|
await stream_loop(symbol, timeframe, derived_timeframes, tf_ms)
|
||||||
candles = await exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
|
|
||||||
if candles:
|
|
||||||
upsert_candles(DATA_DIR, symbol, timeframe, candles)
|
|
||||||
derived_updates: List[Tuple[str, List[CandleRow]]] = []
|
|
||||||
if derived_timeframes and RESAMPLE_AVAILABLE:
|
|
||||||
derived_updates = await asyncio.to_thread(
|
|
||||||
resample_and_store,
|
|
||||||
symbol,
|
|
||||||
timeframe,
|
|
||||||
derived_timeframes,
|
|
||||||
)
|
|
||||||
for row in candles[-3:]:
|
|
||||||
payload = {
|
|
||||||
"topic": f"candles.{symbol}.{timeframe}",
|
|
||||||
"type": "upsert",
|
|
||||||
"data": {
|
|
||||||
"t": row[0],
|
|
||||||
"o": row[1],
|
|
||||||
"h": row[2],
|
|
||||||
"l": row[3],
|
|
||||||
"c": row[4],
|
|
||||||
"v": row[5],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await hub.publish(symbol, timeframe, payload)
|
|
||||||
for target_tf, rows in derived_updates:
|
|
||||||
if not rows:
|
|
||||||
continue
|
|
||||||
for row in rows:
|
|
||||||
ts = int(row[0])
|
|
||||||
o, h, l, c, v = map(float, row[1:])
|
|
||||||
payload = {
|
|
||||||
"topic": f"candles.{symbol}.{target_tf}",
|
|
||||||
"type": "upsert",
|
|
||||||
"data": {
|
|
||||||
"t": ts,
|
|
||||||
"o": o,
|
|
||||||
"h": h,
|
|
||||||
"l": l,
|
|
||||||
"c": c,
|
|
||||||
"v": v,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
await hub.publish(symbol, target_tf, payload)
|
|
||||||
since = candles[-1][0] + tf_ms
|
|
||||||
backoff = 1.0
|
|
||||||
state = fetch_states[state_key]
|
|
||||||
state.last_fetch_at = datetime.utcnow()
|
|
||||||
state.last_candle_ts = candles[-1][0]
|
|
||||||
state.consecutive_errors = 0
|
|
||||||
state.last_error = None
|
|
||||||
await asyncio.sleep(max(1.0, tf_ms * POLL_FACTOR / 1000.0))
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except (ccxt.NetworkError, ccxt.ExchangeNotAvailable, ccxt.RequestTimeout) as exc:
|
|
||||||
logger.warning(
|
|
||||||
"网络异常,准备重试",
|
|
||||||
extra={"symbol": symbol, "timeframe": timeframe, "error": str(exc)},
|
|
||||||
)
|
|
||||||
state = fetch_states[state_key]
|
|
||||||
state.last_error = str(exc)
|
|
||||||
state.consecutive_errors += 1
|
|
||||||
backoff = min(backoff * BACKOFF_BASE, BACKOFF_MAX)
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception(
|
|
||||||
"抓取循环发生异常,重建客户端后重试",
|
|
||||||
extra={"symbol": symbol, "timeframe": timeframe},
|
|
||||||
)
|
|
||||||
state = fetch_states[state_key]
|
|
||||||
state.last_error = str(exc)
|
|
||||||
state.consecutive_errors += 1
|
|
||||||
await asyncio.sleep(backoff)
|
|
||||||
with suppress(Exception):
|
|
||||||
await exchange.close()
|
|
||||||
exchange = build_exchange()
|
|
||||||
backoff = min(backoff * BACKOFF_BASE, BACKOFF_MAX)
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("取消拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
|
logger.info("取消拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
state = fetch_states.get(state_key)
|
state = fetch_states.get(state_key)
|
||||||
if state:
|
if state:
|
||||||
state.last_error = "cancelled"
|
state.last_error = "cancelled"
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info("拉取任务退出", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
|
|
||||||
|
|
||||||
|
async def verification_worker(symbol: str, timeframe: str):
|
||||||
|
state_key = (symbol, timeframe)
|
||||||
|
queue = verification_queues.get(state_key)
|
||||||
|
if queue is None:
|
||||||
|
return
|
||||||
|
exchange = build_exchange()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
verify_ts = await queue.get()
|
||||||
|
try:
|
||||||
|
state = fetch_states.get(state_key)
|
||||||
|
if state and state.last_verified_ts is not None and verify_ts <= state.last_verified_ts:
|
||||||
|
queue.task_done()
|
||||||
|
continue
|
||||||
|
async with VERIFY_FETCH_SEMAPHORE:
|
||||||
|
verification = await exchange.fetch_ohlcv(symbol, timeframe, since=verify_ts, limit=2)
|
||||||
|
target_rows = [row for row in verification if row and row[0] == verify_ts]
|
||||||
|
if not target_rows:
|
||||||
|
logger.warning(
|
||||||
|
"验证未获取到目标数据",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "timestamp": verify_ts},
|
||||||
|
)
|
||||||
|
queue.task_done()
|
||||||
|
continue
|
||||||
|
candidate = target_rows[-1]
|
||||||
|
stored = read_candle_exact(DATA_DIR, symbol, timeframe, verify_ts)
|
||||||
|
needs_upsert = stored.empty
|
||||||
|
reason = "missing"
|
||||||
|
if not needs_upsert:
|
||||||
|
stored_row = stored.iloc[0]
|
||||||
|
open_diff = abs(float(stored_row["open"]) - float(candidate[1]))
|
||||||
|
high_diff = abs(float(stored_row["high"]) - float(candidate[2]))
|
||||||
|
low_diff = abs(float(stored_row["low"]) - float(candidate[3]))
|
||||||
|
close_diff = abs(float(stored_row["close"]) - float(candidate[4]))
|
||||||
|
volume_diff = abs(float(stored_row["volume"]) - float(candidate[5]))
|
||||||
|
if any(diff > 1e-9 for diff in (open_diff, high_diff, low_diff, close_diff, volume_diff)):
|
||||||
|
needs_upsert = True
|
||||||
|
reason = "mismatch"
|
||||||
|
if needs_upsert:
|
||||||
|
upsert_candles(DATA_DIR, symbol, timeframe, [candidate])
|
||||||
|
logger.info(
|
||||||
|
"验证回补完成",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "timestamp": verify_ts, "reason": reason},
|
||||||
|
)
|
||||||
|
state = fetch_states.get(state_key)
|
||||||
|
if state:
|
||||||
|
state.last_verified_ts = verify_ts
|
||||||
|
queue.task_done()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
queue.task_done()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"验证请求失败",
|
||||||
|
extra={"symbol": symbol, "timeframe": timeframe, "timestamp": verify_ts, "error": str(exc)},
|
||||||
|
)
|
||||||
|
queue.task_done()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await exchange.close()
|
await exchange.close()
|
||||||
logger.info("拉取任务退出", extra={"symbol": symbol, "timeframe": timeframe})
|
logger.info("验证任务退出", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_start():
|
async def on_start():
|
||||||
ensure_storage(DATA_DIR)
|
ensure_storage(DATA_DIR)
|
||||||
fetch_tasks.clear()
|
fetch_tasks.clear()
|
||||||
|
verification_queues.clear()
|
||||||
for s in SYMBOLS:
|
for s in SYMBOLS:
|
||||||
for tf in FETCH_TIMEFRAMES:
|
for tf in FETCH_TIMEFRAMES:
|
||||||
|
state_key = (s, tf)
|
||||||
|
verification_queues[state_key] = asyncio.Queue(maxsize=500)
|
||||||
task = asyncio.create_task(fetch_loop(s, tf), name=f"fetch::{s}::{tf}")
|
task = asyncio.create_task(fetch_loop(s, tf), name=f"fetch::{s}::{tf}")
|
||||||
fetch_tasks.append(task)
|
fetch_tasks.append(task)
|
||||||
|
verify_task = asyncio.create_task(verification_worker(s, tf), name=f"verify::{s}::{tf}")
|
||||||
|
fetch_tasks.append(verify_task)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
@@ -444,6 +698,7 @@ async def on_shutdown():
|
|||||||
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
||||||
logger.warning("任务停止时出现异常:%s", result)
|
logger.warning("任务停止时出现异常:%s", result)
|
||||||
fetch_tasks.clear()
|
fetch_tasks.clear()
|
||||||
|
verification_queues.clear()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import threading
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import pyarrow.dataset as ds
|
||||||
|
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
@@ -55,3 +56,14 @@ def get_last_timestamp(base_dir: str, symbol: str, timeframe: str) -> Optional[i
|
|||||||
return int(df["timestamp"].iloc[-1])
|
return int(df["timestamp"].iloc[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def read_candle_exact(base_dir: str, symbol: str, timeframe: str, timestamp: int) -> pd.DataFrame:
|
||||||
|
p = _path(base_dir, symbol, timeframe)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
return pd.DataFrame(columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
|
dataset = ds.dataset(p, format="parquet")
|
||||||
|
table = dataset.to_table(filter=ds.field("timestamp") == int(timestamp))
|
||||||
|
if table.num_rows == 0:
|
||||||
|
return pd.DataFrame(columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||||
|
return table.to_pandas()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
datasvc:
|
datasvc:
|
||||||
build: .
|
build: .
|
||||||
@@ -6,7 +5,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- EXCHANGE=binance
|
- EXCHANGE=binance
|
||||||
- SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT
|
|
||||||
- TIMEFRAMES=1m,1h,1d,1w,1M
|
- TIMEFRAMES=1m,1h,1d,1w,1M
|
||||||
- START_FROM=2022-01-01
|
- START_FROM=2022-01-01
|
||||||
- POLL_FACTOR=0.5
|
- POLL_FACTOR=0.5
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
"BTC/USDT:USDT",
|
||||||
|
"ETH/USDT:USDT",
|
||||||
|
"SOL/USDT:USDT",
|
||||||
|
"WIF/USDT:USDT",
|
||||||
|
"1000PEPE/USDT:USDT",
|
||||||
|
"DOGS/USDT:USDT",
|
||||||
|
"ORDI/USDT:USDT",
|
||||||
|
"AAVE/USDT:USDT",
|
||||||
|
"REEF/USDT:USDT",
|
||||||
|
"1000SATS/USDT:USDT",
|
||||||
|
"SUI/USDT:USDT",
|
||||||
|
"1INCH/USDT:USDT",
|
||||||
|
"DOGE/USDT:USDT",
|
||||||
|
"TON/USDT:USDT",
|
||||||
|
"UNI/USDT:USDT",
|
||||||
|
"XRP/USDT:USDT",
|
||||||
|
"SUN/USDT:USDT",
|
||||||
|
"NOT/USDT:USDT",
|
||||||
|
"RARE/USDT:USDT",
|
||||||
|
"RDNT/USDT:USDT"
|
||||||
|
]
|
||||||
@@ -5,4 +5,5 @@ pandas==2.2.2
|
|||||||
pyarrow==16.1.0
|
pyarrow==16.1.0
|
||||||
orjson==3.10.3
|
orjson==3.10.3
|
||||||
technical==1.5.0
|
technical==1.5.0
|
||||||
|
websockets==12.0
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -221,7 +221,7 @@ def detect_symbol_type(symbol):
|
|||||||
else:
|
else:
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
|
|
||||||
def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
|
def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
"""获取K线数据,支持加密货币和A股"""
|
"""获取K线数据,支持加密货币和A股"""
|
||||||
symbol_type = detect_symbol_type(symbol)
|
symbol_type = detect_symbol_type(symbol)
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ def get_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=1000, start_time=None, end_time=None):
|
def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
|
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
|
||||||
try:
|
try:
|
||||||
# 初始化参数
|
# 初始化参数
|
||||||
@@ -346,7 +346,7 @@ def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=1000, start_time=None,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
|
def get_crypto_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
"""优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。"""
|
"""优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。"""
|
||||||
start_ms = _parse_time_input(start_time)
|
start_ms = _parse_time_input(start_time)
|
||||||
end_ms = _parse_time_input(end_time)
|
end_ms = _parse_time_input(end_time)
|
||||||
@@ -369,7 +369,7 @@ def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=
|
|||||||
return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time)
|
return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
|
||||||
|
|
||||||
def get_a_stock_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
|
def get_a_stock_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
"""获取A股K线数据"""
|
"""获取A股K线数据"""
|
||||||
try:
|
try:
|
||||||
# 处理时间戳参数转换为日期字符串
|
# 处理时间戳参数转换为日期字符串
|
||||||
|
|||||||
Reference in New Issue
Block a user