添加本地数据源,以后就可以直接用本地数据了

This commit is contained in:
jackyu66git
2025-11-12 23:59:11 +08:00
parent 8e21ecb057
commit ac845ccfd0
14 changed files with 1461 additions and 446 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -28,3 +28,4 @@ feature_meta
*.sqlite-wal *.sqlite-wal
.DS_Store .DS_Store
交易记录/~$交易规则.docx 交易记录/~$交易规则.docx
/datasvc/data
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
from __future__ import annotations
"""
使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。
横盘判定基于以下三项指标(均可通过命令行参数调整):
1. 价格振幅占均价的比例(默认 ≤ 5%
2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%
3. 收盘价标准差占均价的比例(默认 ≤ 1.5%
满足以上全部条件的交易对会被视为长期横盘。
"""
import argparse
import csv
import logging
import math
import statistics
import sys
import time
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import ccxt
# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015
DEFAULT_LIMIT = 100
DEFAULT_TIMEFRAME = "1h"
STABLECOINS = {
"USDT",
"USDC",
"BUSD",
"TUSD",
"USDP",
"DAI",
"FDUSD",
"SUSD",
"UST",
"USTC",
"EUR",
"TRY",
"BFUSD",
"USDE",
"XUSD",
"USD1",
"XUSD"
}
@dataclass
class SidewaysMetrics:
symbol: str
price_range_pct: float
slope_pct: float
std_pct: float
mean_close: float
last_close: float
data_points: int
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)"
)
parser.add_argument(
"--timeframe",
default=DEFAULT_TIMEFRAME,
help="K 线周期(默认:1h",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help="每个交易对获取的 K 线数量(默认:500)",
)
parser.add_argument(
"--range-threshold",
type=float,
default=0.05,
help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%",
)
parser.add_argument(
"--slope-threshold",
type=float,
default=0.0005,
help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%",
)
parser.add_argument(
"--std-threshold",
type=float,
default=0.015,
help="标准差占均价比例阈值(默认:0.015,表示 1.5%%",
)
parser.add_argument(
"--quote",
action="append",
default=[],
help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD",
)
parser.add_argument(
"--symbol",
action="append",
default=[],
help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)",
)
parser.add_argument(
"--max-symbols",
type=int,
default=None,
help="限制最多检测的交易对数量(用于调试)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.35,
help="请求失败后的基础重试等待秒数(默认:0.35)",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="单个交易对请求失败后的最大重试次数(默认:3)",
)
parser.add_argument(
"--include-inactive",
action="store_true",
help="包含已下架/不可交易的交易对(默认不包含)",
)
parser.add_argument(
"--export",
type=str,
default=None,
help="将筛选结果导出为 CSV 文件的路径",
)
parser.add_argument(
"--verbose",
action="store_true",
help="输出更详细的日志信息",
)
return parser.parse_args(argv)
def setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({"enableRateLimit": True})
exchange.options["defaultType"] = "spot"
return exchange
def iter_target_symbols(
exchange: ccxt.binance,
quotes: Sequence[str],
includes: Sequence[str],
include_inactive: bool,
) -> List[str]:
markets = exchange.load_markets()
filtered = []
quote_set = {quote.upper() for quote in quotes}
include_set = {sym.upper() for sym in includes}
for symbol, meta in markets.items():
if not meta.get("spot", False):
continue
if not include_inactive and meta.get("active") is False:
continue
normalized_symbol = symbol.upper()
if include_set and normalized_symbol not in include_set:
continue
parts = symbol.split("/")
if len(parts) != 2:
continue
base_asset, quote_asset = parts[0].upper(), parts[1].upper()
target_quote = quote_set or {"USDT"}
if quote_asset not in target_quote:
continue
if base_asset in STABLECOINS:
continue
filtered.append(symbol)
filtered.sort()
logging.info(
"已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s",
len(filtered),
",".join(sorted(quote_set or {"USDT"})),
",".join(sorted(include_set)) or "",
)
return filtered
def fetch_ohlcv_with_retry(
exchange: ccxt.binance,
symbol: str,
timeframe: str,
limit: int,
retries: int,
base_sleep: float,
) -> List[List[float]]:
attempt = 0
while True:
try:
return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except ccxt.RateLimitExceeded as exc:
wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep)
logging.debug("触发限频,等待 %.2f 秒后重试 %s%s", wait_time, symbol, exc)
time.sleep(wait_time)
except (ccxt.NetworkError, ccxt.ExchangeError) as exc:
attempt += 1
if attempt > retries:
logging.warning("多次获取失败,跳过 %s%s", symbol, exc)
return []
wait_time = base_sleep * attempt
logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc)
time.sleep(wait_time)
def linear_regression_slope(values: Sequence[float]) -> float:
n = len(values)
if n < 2:
return 0.0
mean_x = (n - 1) / 2.0
mean_y = sum(values) / n
numerator = 0.0
denominator = 0.0
for idx, value in enumerate(values):
dx = idx - mean_x
numerator += dx * (value - mean_y)
denominator += dx * dx
if denominator == 0:
return 0.0
return numerator / denominator
def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]:
if not closes:
return None
mean_close = sum(closes) / len(closes)
if math.isclose(mean_close, 0.0):
return None
max_close = max(closes)
min_close = min(closes)
price_range_pct = (max_close - min_close) / mean_close
slope = linear_regression_slope(closes)
slope_pct = slope / mean_close
std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0
std_pct = std_dev / mean_close
return SidewaysMetrics(
symbol=symbol,
price_range_pct=price_range_pct,
slope_pct=slope_pct,
std_pct=std_pct,
mean_close=mean_close,
last_close=closes[-1],
data_points=len(closes),
)
def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool:
return (
metrics.price_range_pct <= range_threshold
and abs(metrics.slope_pct) <= slope_threshold
and metrics.std_pct <= std_threshold
)
def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None:
fieldnames = [
"symbol",
"price_range_pct",
"slope_pct",
"std_pct",
"mean_close",
"last_close",
"data_points",
]
with open(path, "w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for item in results:
writer.writerow(
{
"symbol": item.symbol,
"price_range_pct": f"{item.price_range_pct:.6f}",
"slope_pct": f"{item.slope_pct:.6f}",
"std_pct": f"{item.std_pct:.6f}",
"mean_close": f"{item.mean_close:.8f}",
"last_close": f"{item.last_close:.8f}",
"data_points": item.data_points,
}
)
logging.info("结果已导出至 %s", path)
def run(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
if not args.quote:
args.quote = ["USDT"]
setup_logging(args.verbose)
exchange = create_exchange()
symbols = iter_target_symbols(
exchange=exchange,
quotes=args.quote,
includes=args.symbol,
include_inactive=args.include_inactive,
)
if args.max_symbols is not None:
symbols = symbols[: args.max_symbols]
logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols))
if not symbols:
logging.error("未找到任何满足条件的交易对,请检查过滤条件。")
return 1
sideways_results: List[SidewaysMetrics] = []
total = len(symbols)
for idx, symbol in enumerate(symbols, start=1):
logging.info("(%d/%d) 正在获取 %s%s K 线(limit=%d", idx, total, symbol, args.timeframe, args.limit)
ohlcv = fetch_ohlcv_with_retry(
exchange=exchange,
symbol=symbol,
timeframe=args.timeframe,
limit=args.limit,
retries=args.retries,
base_sleep=args.sleep,
)
if len(ohlcv) < max(100, args.limit // 2):
logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv))
continue
closes = [entry[4] for entry in ohlcv if entry[4] is not None]
metrics = compute_sideways_metrics(closes, symbol)
if not metrics:
continue
if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold):
sideways_results.append(metrics)
logging.info(
"识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
else:
logging.debug(
"未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
if not sideways_results:
logging.warning("未检测到满足定义的长期横盘交易对。")
return 0
sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct))
print("=" * 88)
print(
f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}"
f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%}"
)
print("=" * 88)
header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}"
print(header)
print("-" * len(header))
for item in sideways_results:
print(
f"{item.symbol:15s}"
f" {item.price_range_pct * 100:10.4f}"
f" {item.slope_pct * 100:10.4f}"
f" {item.std_pct * 100:10.4f}"
f" {item.mean_close:14.8f}"
f" {item.last_close:14.8f}"
f" {item.data_points:6d}"
)
if args.export:
export_results(args.export, sideways_results)
logging.info("任务完成。")
return 0
if __name__ == "__main__":
sys.exit(run())
+2 -2
View File
@@ -145,9 +145,9 @@ class ChanKLC():
def set_state(self, state): def set_state(self, state):
self.state = state self.state = state
def check_klu_included(self, klu): def check_klu_included(self, klu):
if self.high >= klu.high: if self.high >= klu.high-10:
# high大于,low小于,左包含 # high大于,low小于,左包含
if self.low <= klu.low: if self.low <= klu.low+10:
self.add_klu(klu=klu) self.add_klu(klu=klu)
# gn>gn-1 # gn>gn-1
if self.dir == Chan_KLINE_DIR.UP: if self.dir == Chan_KLINE_DIR.UP:
+265 -241
View File
@@ -5,246 +5,270 @@ from ChanMACDUnitTF import ChanMACDUnitTF
from ChanMACDHistSet import ChanMACDHistSet from ChanMACDHistSet import ChanMACDHistSet
class ChanMACD(): class ChanMACD():
def __init__(self, klu_list: list[ChanKLU]): def __init__(self, klu_list: list[ChanKLU]):
self.klu_list = klu_list self.klu_list = klu_list
self.seg_list = [] self.seg_list = []
self.unittf_list = [] self.unittf_list = []
self.histset_list = [] self.histset_list = []
# 状态标记列表 # 状态标记列表
self.high_position_list = [] # 高位列表 self.high_position_list = [] # 高位列表
self.high_empty_list = [] # 高位空列表 self.high_empty_list = [] # 高位空列表
self.return_zero_list = [] # 归零轴列表 self.return_zero_list = [] # 归零轴列表
self.cross0_up_list = [] # 向上穿越零轴列表 self.cross0_up_list = [] # 向上穿越零轴列表
self.cross0_down_list = [] # 向下穿越零轴列表 self.cross0_down_list = [] # 向下穿越零轴列表
# 计算段 / UnitTF / HistSet 及状态标记 # 计算段 / UnitTF / HistSet 及状态标记
self.cal_macd_state() self.cal_macd_state()
def cal_macd_state(self): self.get_klu_sd_list()
last_seg = None def get_klu_sd(self):
last_unittf = None if self.klu_list:
last_histset = None sd = self.klu_list[-1].separate_div
last_klu = None if sd > 1:
for klu in self.klu_list: print(self.klu_list[-1].time, sd)
# initialise first histset return True
if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0: return False
continue def get_klu_sd_list(self):
if last_histset is None: sd_list = []
if klu.macdhist > 0: if self.klu_list:
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) for klu in self.klu_list:
self.histset_list.append(last_histset) hist = klu.macdhist
else: singal = False
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) if klu.pre and klu.next:
self.histset_list.append(last_histset) if klu.signal > 0:
else: signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
# initialise first seg and unittf else:
if last_seg is None: signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal
# create histset afterwards sd = klu.separate_div
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: if sd > 1 and ((hist > 0 and hist < 20) or (hist < 0 and hist > -20)):
if klu.macdhist > 0: sd_list.append(klu.time)
last_histset.add_klu(klu) print(klu.time, sd)
else: return sd_list
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) def cal_macd_state(self):
self.histset_list.append(histset) last_seg = None
last_histset.set_next(histset) last_unittf = None
histset.set_pre(last_histset) last_histset = None
last_histset.set_end_klu(last_klu) last_klu = None
last_histset = histset for klu in self.klu_list:
else: # initialise first histset
if klu.macdhist < 0: if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0:
last_histset.add_klu(klu) continue
else: if last_histset is None:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) if klu.macdhist > 0:
self.histset_list.append(histset) last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
last_histset.set_next(histset) self.histset_list.append(last_histset)
histset.set_pre(last_histset) else:
last_histset.set_end_klu(last_klu) last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
last_histset = histset self.histset_list.append(last_histset)
if last_klu.signal >= 0 and klu.signal < 0: else:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) # initialise first seg and unittf
self.unittf_list.append(last_unittf) if last_seg is None:
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf) # create histset afterwards
self.seg_list.append(last_seg) if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
elif last_klu.signal <= 0 and klu.signal > 0: if klu.macdhist > 0:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) last_histset.add_klu(klu)
self.unittf_list.append(last_unittf) else:
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
self.seg_list.append(last_seg) self.histset_list.append(histset)
# after the first seg and unittf last_histset.set_next(histset)
else: histset.set_pre(last_histset)
# create histset afterwards last_histset.set_end_klu(last_klu)
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: last_histset = histset
if klu.macdhist > 0: else:
last_histset.add_klu(klu) if klu.macdhist < 0:
else: last_histset.add_klu(klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER) else:
self.histset_list.append(histset) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
last_histset.set_next(histset) self.histset_list.append(histset)
histset.set_pre(last_histset) last_histset.set_next(histset)
last_histset.set_end_klu(last_klu) histset.set_pre(last_histset)
last_histset = histset last_histset.set_end_klu(last_klu)
if last_unittf: last_histset = histset
last_unittf.add_histset(last_histset) if last_klu.signal >= 0 and klu.signal < 0:
else: last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
if klu.macdhist < 0: self.unittf_list.append(last_unittf)
last_histset.add_klu(klu) last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf)
else: self.seg_list.append(last_seg)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE) elif last_klu.signal <= 0 and klu.signal > 0:
self.histset_list.append(histset) last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
last_histset.set_next(histset) self.unittf_list.append(last_unittf)
histset.set_pre(last_histset) last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf)
last_histset.set_end_klu(last_klu) self.seg_list.append(last_seg)
last_histset = histset # after the first seg and unittf
if last_unittf: else:
last_unittf.add_histset(last_histset) # create histset afterwards
if last_klu.signal >= 0 and klu.signal < 0: if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) if klu.macdhist > 0:
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) last_histset.add_klu(klu)
self.unittf_list.append(unittf) else:
last_unittf.set_next(unittf) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
last_seg.set_end_klu(last_klu) self.histset_list.append(histset)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf) last_histset.set_next(histset)
self.seg_list.append(seg) histset.set_pre(last_histset)
last_seg.set_next(seg) last_histset.set_end_klu(last_klu)
last_seg = seg last_histset = histset
last_unittf = unittf if last_unittf:
elif last_klu.signal <= 0 and klu.signal > 0: last_unittf.add_histset(last_histset)
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) else:
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset) if klu.macdhist < 0:
self.unittf_list.append(unittf) last_histset.add_klu(klu)
last_unittf.set_next(unittf) else:
last_seg.set_end_klu(last_klu) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf) self.histset_list.append(histset)
self.seg_list.append(seg) last_histset.set_next(histset)
last_seg.set_next(seg) histset.set_pre(last_histset)
last_seg = seg last_histset.set_end_klu(last_klu)
last_unittf = unittf last_histset = histset
elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal: if last_unittf:
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset) last_unittf.add_histset(last_histset)
self.unittf_list.append(unittf) if last_klu.signal >= 0 and klu.signal < 0:
last_unittf.set_next(unittf) last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
last_seg.add_unittf(unittf) unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
last_unittf = unittf self.unittf_list.append(unittf)
last_seg.add_klu(klu) last_unittf.set_next(unittf)
else: last_seg.set_end_klu(last_klu)
if not last_unittf.is_end: seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf)
last_unittf.add_klu(klu) self.seg_list.append(seg)
last_seg.add_klu(klu) last_seg.set_next(seg)
last_klu = klu last_seg = seg
klu.cal_macd_state() last_unittf = unittf
#print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close) elif last_klu.signal <= 0 and klu.signal > 0:
return self.klu_list last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
def cal_macd(self): unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
last_seg = None self.unittf_list.append(unittf)
last_unittf = None last_unittf.set_next(unittf)
last_histset = None last_seg.set_end_klu(last_klu)
histset = None seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf)
last_klu = None self.seg_list.append(seg)
for klu in self.klu_list: last_seg.set_next(seg)
klu.cal_macd_state() last_seg = seg
print(klu.time, klu.macd_state) last_unittf = unittf
# 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元 elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal:
if last_seg is None: unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset)
if klu.macd_state != Chan_MACD_STATE.UNKNOWN: self.unittf_list.append(unittf)
# 初始化首个直方图集合(根据当前柱体正负) last_unittf.set_next(unittf)
if klu.macdhist >= 0: last_seg.add_unittf(unittf)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE) last_unittf = unittf
else: last_seg.add_klu(klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER) else:
self.histset_list.append(histset) if not last_unittf.is_end:
last_histset = histset last_unittf.add_klu(klu)
last_seg.add_klu(klu)
# 初始化首段 last_klu = klu
seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER klu.cal_macd_state()
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf) #print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close)
self.seg_list.append(seg) return self.klu_list
last_seg = seg def cal_macd(self):
last_seg = None
last_unittf = None
last_histset = None
histset = None
last_klu = None
for klu in self.klu_list:
klu.cal_macd_state()
print(klu.time, klu.macd_state)
# 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元
if last_seg is None:
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
# 初始化首个直方图集合(根据当前柱体正负)
if klu.macdhist >= 0:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset = histset
# 初始化首段
seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf)
self.seg_list.append(seg)
last_seg = seg
# 初始化首个UnitTF # 初始化首个UnitTF
unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset) unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset)
self.unittf_list.append(unittf) self.unittf_list.append(unittf)
last_unittf = unittf last_unittf = unittf
last_seg.add_unittf(unittf) last_seg.add_unittf(unittf)
# 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根 # 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根
last_klu = klu last_klu = klu
continue continue
# 3) 直方图集合(基于当前 unittf) # 3) 直方图集合(基于当前 unittf)
if klu.macdhist >= 0: if klu.macdhist >= 0:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE: if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
last_histset.add_klu(klu) last_histset.add_klu(klu)
else: else:
# 结束旧 histset(以前一根结束更合理) # 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu: if last_histset and last_klu:
last_histset.set_end_klu(last_klu) last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset) self.histset_list.append(histset)
if last_histset: if last_histset:
last_histset.set_next(histset) last_histset.set_next(histset)
last_histset = histset last_histset = histset
if last_unittf: if last_unittf:
last_unittf.add_histset(histset) last_unittf.add_histset(histset)
else: else:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER: if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
last_histset.add_klu(klu) last_histset.add_klu(klu)
else: else:
# 结束旧 histset(以前一根结束更合理) # 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu: if last_histset and last_klu:
last_histset.set_end_klu(last_klu) last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER) histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset) self.histset_list.append(histset)
if last_histset: if last_histset:
last_histset.set_next(histset) last_histset.set_next(histset)
last_histset = histset last_histset = histset
if last_unittf: if last_unittf:
last_unittf.add_histset(histset) last_unittf.add_histset(histset)
# 2) 过零切段(使用KLU中的穿越状态) # 2) 过零切段(使用KLU中的穿越状态)
if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or
klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN): klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN):
# 结束旧 unittf # 结束旧 unittf
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0) last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
# 新的单位时间周期 # 新的单位时间周期
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset) unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset)
self.unittf_list.append(unittf) self.unittf_list.append(unittf)
last_unittf.set_next(unittf) last_unittf.set_next(unittf)
last_unittf = unittf last_unittf = unittf
# 收尾旧段 # 收尾旧段
last_seg.set_end_klu(last_klu) last_seg.set_end_klu(last_klu)
# 新段方向取反 # 新段方向取反
new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf) seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf)
self.seg_list.append(seg) self.seg_list.append(seg)
last_seg.set_next(seg) last_seg.set_next(seg)
last_seg = seg last_seg = seg
last_seg.add_unittf(unittf) last_seg.add_unittf(unittf)
else: else:
# 4) UnitTF 状态机:用黄线Signal的归零轴 # 4) UnitTF 状态机:用黄线Signal的归零轴
if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1: if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1:
#print(klu.time, klu.macd_state) #print(klu.time, klu.macd_state)
if klu.macd_state == Chan_MACD_STATE.RZ_UP: if klu.macd_state == Chan_MACD_STATE.RZ_UP:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf) self.unittf_list.append(unittf)
last_unittf.set_next(unittf) last_unittf.set_next(unittf)
last_unittf = unittf last_unittf = unittf
last_seg.add_unittf(unittf) last_seg.add_unittf(unittf)
elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN: elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0) last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset) unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf) self.unittf_list.append(unittf)
last_unittf.set_next(unittf) last_unittf.set_next(unittf)
last_unittf = unittf last_unittf = unittf
last_seg.add_unittf(unittf) last_seg.add_unittf(unittf)
else: else:
last_unittf.add_klu(klu) last_unittf.add_klu(klu)
last_seg.add_klu(klu) last_seg.add_klu(klu)
else: else:
last_unittf.add_klu(klu) last_unittf.add_klu(klu)
last_seg.add_klu(klu) last_seg.add_klu(klu)
last_klu = klu last_klu = klu
last_histset.set_end_klu(last_klu) last_histset.set_end_klu(last_klu)
last_unittf.set_end_klu(last_klu, None) last_unittf.set_end_klu(last_klu, None)
last_seg.set_end_klu(last_klu) last_seg.set_end_klu(last_klu)
return self.klu_list return self.klu_list
+4 -4
View File
@@ -123,12 +123,12 @@ class TF_DF():
return klu_state_list return klu_state_list
def check_fx(self, klc): def check_fx(self, klc):
if klc.pre and klc.next: if klc.pre and klc.next:
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low and klc.next.klc_dir == Chan_KLINE_DIR.DOWN: if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low and klc.close > klc.next.close:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0 and klc.macd > klc.macdhist: #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0 and klc.macd > klc.macdhist:
klc.set_fx(Chan_FX_TYPE.TOP) klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP") #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high and klc.next.klc_dir == Chan_KLINE_DIR.UP: elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high and klc.close < klc.next.close:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0 and klc.macd < klc.macdhist: #if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0 and klc.macd < klc.macdhist:
klc.set_fx(Chan_FX_TYPE.BOTTOM) klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM") #print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
@@ -1387,8 +1387,8 @@ class TF_DF():
if i >= 2: if i >= 2:
self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu) self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu)
if klu.pattern != Chan_KLU_PATTERN.UNKNOWN: #if klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio) #print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio)
return klu_list return klu_list
def _detect_single_reversal_pattern(self, klu): def _detect_single_reversal_pattern(self, klu):
+9
View File
@@ -10,6 +10,15 @@ RUN pip install -r /app/requirements.txt
COPY app /app/app COPY app /app/app
ENV DATA_DIR=/data \
EXCHANGE=binance \
SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT \
TIMEFRAMES=1m,1h,1d,1w,1M \
START_FROM=2022-01-01 \
POLL_FACTOR=0.5
VOLUME ["/data"]
EXPOSE 9000 EXPOSE 9000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000"]
+127 -34
View File
@@ -1,48 +1,141 @@
# Local Data Service (REST + WebSocket) # Local Data ServiceREST + WebSocket
一键部署、跨平台的本地行情数据服务。默认抓取 Binance 永续合约 `BTC/USDT:USDT, ETH/USDT:USDT``1m/5m/15m/1h` K 线,增量写入本地 Parquet 并通过 WebSocket 推送。 本服务基于 FastAPI + ccxt,自动拉取交易所行情、写入本地 Parquet,同时提供 REST 和 WebSocket 数据访问。
自带时间周期聚合能力:只需抓取 `1m / 1h / 1d / 1w / 1M` 等基础周期,即可自动生成 `2m/3m/.../30m``2h/3h/.../16h` 等衍生周期。
## 快速开始(方式B:已安装 Docker) ---
## 1. 环境准备
### 1.1 依赖
- Python ≥ 3.10(本地运行方式需要)
- `pip install -r requirements.txt`(包含 `fastapi`, `uvicorn`, `ccxt`, `pandas`, `pyarrow`, `technical` 等)
- 或者直接使用仓库内的 `docker-compose.yml`
### 1.2 关键环境变量
| 变量 | 说明 | 默认 |
| --- | --- | --- |
| `DATA_DIR` | 本地 Parquet 存储目录 | `/data` |
| `EXCHANGE` | 交易所标识(目前支持 binance) | `binance` |
| `SYMBOLS` | 逗号分隔的交易对列表 | `BTC/USDT:USDT,ETH/USDT:USDT` |
| `TIMEFRAMES` | 基础抓取周期,逗号分隔 | `1m,1h,1d,1w,1M` |
| `START_FROM` | 首次启动回补的起始 UTC 时间(ISO 字符串或毫秒时间戳) | `2022-01-01` |
| `POLL_FACTOR` | 拉取间隔因子,实际间隔 = 周期毫秒 × factor | `0.5` |
| `BACKOFF_BASE / BACKOFF_MAX` | 异常重试的指数退避参数 | `2.0 / 30.0` |
> 衍生周期列表由程序自动推导,无需手动写入 `TIMEFRAMES`。
---
## 2. 启动与关闭
### 2.1 Docker 方式
```bash ```bash
cd user_data/Chan/datasvc cd user_data/Chan/datasvc
docker compose up -d docker compose up -d # 启动
docker compose logs -f # 查看日志
docker compose down # 关闭
``` ```
- REST: http://localhost:9000/api/candles?symbol=BTC/USDT:USDT&tf=1m ### 2.2 本地运行(无 Docker
- WS: ws://localhost:9000/ws?symbol=BTC/USDT:USDT&tf=1m&since=1690000000000
- Swagger: http://localhost:9000/docs
## 环境变量(docker-compose.yml
- EXCHANGE: 交易所,默认 binance
- SYMBOLS: 逗号分隔交易对
- TIMEFRAMES: 逗号分隔周期
- START_DAYS: 首次启动回补最近 N 天
- POLL_FACTOR: 轮询因子,间隔=周期毫秒*factor
- DATA_DIR: 容器内数据目录(已映射到 `./data`
## 数据位置
- 本地缓存:`user_data/Chan/datasvc/data/{timeframe}/{symbol}.parquet`
## 常用命令
```bash ```bash
docker compose logs -f export DATA_DIR=./data
export SYMBOLS="BTC/USDT:USDT"
export TIMEFRAMES="1m,1h,1d"
docker compose down cd /Users/jack/Project/freqtrade
uvicorn user_data.Chan.datasvc.app.main:app --reload
``` ```
## 接口说明 关闭时 Ctrl+C 即可,服务会自动取消后台抓取任务并释放资源。
- GET /api/candles
- 参数:symbol, tf, start(ms), end(ms)
- 返回:[{timestamp, open, high, low, close, volume}]
- WS /ws
- 参数:symbol, tf, since(ms)
- 消息:
- snapshot: 初始快照数组
- upsert: 单根K线增量(尾部修正)
## 注意 ---
- 默认未带交易所 API Key,仅公共行情。
- 如需更多交易对/周期,修改 `docker-compose.yml` 后重启。
## 3. 数据存储与聚合
### 3.1 基础周期
只会为 `TIMEFRAMES` 声明的基础周期创建抓取任务(例如 `1m / 1h / 1d`)。
### 3.2 衍生周期
启动后自动维护以下聚合:
| 基础周期 | 自动生成 |
| --- | --- |
| `1m` | `2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m` |
| `1h` | `2h, 3h, 4h, 6h, 8h, 12h, 16h` |
| `1d` | `2d, 3d, 4d, 5d, 6d` |
| `1w` | `2w` |
| `1M` | `2M, 3M, 6M` |
聚合过程通过 `technical.util.resample_to_interval` 完成,写入同一 Parquet 数据目录。
所有周期都可以被 REST/WS 访问。
### 3.3 数据目录
```
{DATA_DIR}/{timeframe}/{symbol}.parquet
```
---
## 4. 接口调用
### 4.1 健康检查
```
GET /health
```
返回运行状态、基础/衍生周期列表、各抓取任务的最新进度与错误计数,便于监控。
### 4.2 REST API
```
GET /api/candles?symbol=BTC/USDT:USDT&tf=2h&start=1700000000000&end=1700003600000
```
参数说明:
- `symbol`:交易对(必须在 `SYMBOLS` 列表中)
- `tf`:时间周期(支持基础或衍生)
- `start` / `end`:毫秒时间戳,可选
返回示例:
```json
[
{"timestamp": 1700000000000, "open": 36000.0, "high": 36120.0, "low": 35980.0, "close": 36050.0, "volume": 125.4},
...
]
```
### 4.3 WebSocket
```
ws://localhost:8000/ws?symbol=ETH/USDT:USDT&tf=15m&since=1700000000000
```
- 首次连接:收到 `snapshot` 消息(快照数组)
- 后续增量:收到 `upsert` 消息(最新几根K线),以及周期性 `ping`
消息示例:
```json
{"topic":"candles.ETH/USDT:USDT.15m","type":"snapshot","data":[{"t":1700000000000,"o":2000.0,"h":2005.0,"l":1995.0,"c":2002.5,"v":312.7}, ...]}
{"topic":"candles.ETH/USDT:USDT.15m","type":"upsert","data":{"t":1700000900000,"o":2002.5,"h":2006.0,"l":2000.0,"c":2004.0,"v":120.8}}
```
---
## 5. 停机与维护
- **正常关闭**`docker compose down` 或 Ctrl+C。服务会等待所有抓取任务结束并关闭 `ccxt` 客户端。
- **异常恢复**:若网络异常,服务会自动指数退避重试;可通过 `/health``consecutive_errors``last_error` 排查。
- **数据清理**:直接删除 `DATA_DIR` 下对应的 Parquet 文件即可,下次启动会重新回补。
---
## 6. 常见问题
1. **缺少 `technical` 模块**
聚合周期会跳过,并在日志中提示;先执行 `pip install technical` 再重启。
2. **接收不到某个周期的数据**
确认该周期在 `TIMEFRAMES` 或自动聚合列表中;若是衍生周期,需要确保对应基础周期已在运行。
3. **如何新增交易对/周期**
修改环境变量或 docker-compose 配置后,重启服务即可;Parquet 文件会按需生成。
---
欢迎结合自身策略或可视化前端直接消费本地数据服务。若要集成到其他项目,可直接引用 `/api/candles` 的 JSON 响应或订阅 `/ws` 的实时推送。
+361 -46
View File
@@ -1,15 +1,24 @@
import os import os
import asyncio import asyncio
import json import json
import logging
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple, Union
import ccxt import ccxt
import ccxt.async_support as ccxt_async
import pandas as pd import pandas as pd
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query 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
try:
from technical.util import resample_to_interval # type: ignore
except ImportError: # pragma: no cover - 环境缺失依赖时自动降级
resample_to_interval = None # type: ignore
from .storage import ( from .storage import (
ensure_storage, ensure_storage,
read_candles, read_candles,
@@ -18,12 +27,86 @@ from .storage import (
) )
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("datasvc")
RESAMPLE_AVAILABLE = resample_to_interval is not None
RESAMPLE_WARNING_EMITTED = False
AGGREGATION_PLAN: Dict[str, List[str]] = {
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m"],
"1h": ["2h", "3h", "4h", "6h", "8h", "12h", "16h"],
"1d": ["2d", "3d", "4d", "5d", "6d"],
"1w": ["2w"],
"1M": ["2M", "3M", "6M"],
}
CandleRow = List[Union[int, float]]
def _split_env_list(value: str) -> List[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _unique_preserve(values: List[str]) -> List[str]:
seen = set()
ordered: List[str] = []
for item in values:
if item not in seen:
ordered.append(item)
seen.add(item)
return ordered
def timeframe_to_minutes(tf: str) -> Optional[int]:
if not tf:
return None
unit = tf[-1]
try:
value = int(tf[:-1])
except ValueError:
return None
multiplier = {
"m": 1,
"h": 60,
"d": 1440,
"w": 10080,
"M": 43200, # 30 天近似
}.get(unit)
if multiplier is None:
return None
return value * multiplier
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 = [s.strip() for s in os.environ.get("SYMBOLS", "BTC/USDT:USDT,ETH/USDT:USDT").split(",") if s.strip()] SYMBOLS = _split_env_list(os.environ.get("SYMBOLS", "BTC/USDT:USDT,ETH/USDT:USDT"))
TIMEFRAMES = [t.strip() for t in os.environ.get("TIMEFRAMES", "1m,5m,15m,1h").split(",") if t.strip()] if not SYMBOLS:
START_FROM = os.environ.get("START_FROM", "2025-01-01") # 首次启动拉取起始日期(UTC SYMBOLS = ["BTC/USDT:USDT"]
_default_timeframes = ["1m", "1h", "1d", "1w", "1M"]
requested_timeframes = _split_env_list(os.environ.get("TIMEFRAMES", ",".join(_default_timeframes)))
if not requested_timeframes:
requested_timeframes = _default_timeframes
FETCH_TIMEFRAMES = _unique_preserve(requested_timeframes)
AVAILABLE_TIMEFRAMES = list(FETCH_TIMEFRAMES)
for base_tf in FETCH_TIMEFRAMES:
for derived_tf in AGGREGATION_PLAN.get(base_tf, []):
if derived_tf not in AVAILABLE_TIMEFRAMES:
AVAILABLE_TIMEFRAMES.append(derived_tf)
DERIVED_TIMEFRAMES = [tf for tf in AVAILABLE_TIMEFRAMES if tf not in FETCH_TIMEFRAMES]
AGGREGATION_TARGETS = {tf: AGGREGATION_PLAN.get(tf, []) for tf in FETCH_TIMEFRAMES}
START_FROM = os.environ.get("START_FROM", "2022-01-01") # 首次启动拉取起始日期(UTC
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_MAX = float(os.environ.get("BACKOFF_MAX", "30.0"))
VALID_SYMBOLS = set(SYMBOLS)
VALID_TIMEFRAMES = set(AVAILABLE_TIMEFRAMES)
ensure_storage(DATA_DIR) ensure_storage(DATA_DIR)
@@ -38,18 +121,24 @@ app.add_middleware(
def tf_to_ms(tf: str) -> int: def tf_to_ms(tf: str) -> int:
table = { minutes = timeframe_to_minutes(tf)
"1m": 60_000, if minutes is None:
"3m": 3 * 60_000, logger.warning("无法解析时间周期,默认使用 60 秒", extra={"timeframe": tf})
"5m": 5 * 60_000, return 60_000
"15m": 15 * 60_000, return minutes * 60_000
"30m": 30 * 60_000,
"1h": 60 * 60_000,
"2h": 2 * 60 * 60_000, def ensure_symbol_timeframe(symbol: str, timeframe: str) -> None:
"4h": 4 * 60 * 60_000, if symbol not in VALID_SYMBOLS:
"1d": 24 * 60 * 60_000, raise HTTPException(
} status_code=status.HTTP_400_BAD_REQUEST,
return table.get(tf, 60_000) detail=f"symbol 必须为 {sorted(VALID_SYMBOLS)} 之一。",
)
if timeframe not in VALID_TIMEFRAMES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"tf 必须为 {sorted(VALID_TIMEFRAMES)} 之一。",
)
def parse_start_from_ms(val: str) -> int: def parse_start_from_ms(val: str) -> int:
@@ -63,10 +152,10 @@ def parse_start_from_ms(val: str) -> int:
except Exception: except Exception:
pass pass
try: try:
dt = datetime.fromisoformat(val) # 允许 '2025-01-01' 或 '2025-01-01T00:00:00' dt = datetime.fromisoformat(val) # 允许 '2022-01-01' 或 '2022-01-01T00:00:00'
except Exception: except Exception:
# 回退到固定日期 # 回退到固定日期
dt = datetime(2025, 1, 1) dt = datetime(2022, 1, 1)
return int(dt.timestamp() * 1000) return int(dt.timestamp() * 1000)
@@ -104,52 +193,272 @@ class Hub:
hub = Hub() hub = Hub()
fetch_tasks: List[asyncio.Task] = []
def resample_and_store(symbol: str, base_timeframe: str, derived_timeframes: List[str]) -> List[Tuple[str, List[CandleRow]]]:
if not RESAMPLE_AVAILABLE or not derived_timeframes:
return []
base_df = read_candles(DATA_DIR, symbol, base_timeframe, None, None)
if base_df.empty:
return []
base_df = base_df.copy()
if "date" not in base_df.columns:
base_df["date"] = pd.to_datetime(base_df["timestamp"], unit="ms", utc=True)
base_df = base_df.sort_values("timestamp")
updates: List[Tuple[str, List[List[float]]]] = []
for target_tf in derived_timeframes:
minutes = timeframe_to_minutes(target_tf)
if minutes is None:
logger.warning("无法解析聚合周期", extra={"target_timeframe": target_tf})
continue
try:
derived_df = resample_to_interval(base_df, minutes) # type: ignore[misc]
except Exception:
logger.exception(
"聚合周期计算失败",
extra={"symbol": symbol, "base_timeframe": base_timeframe, "target_timeframe": target_tf},
)
continue
if derived_df is None or derived_df.empty:
continue
derived_df = derived_df.copy()
if "timestamp" not in derived_df.columns:
if "date" in derived_df.columns:
dates = pd.to_datetime(derived_df["date"], utc=True, errors="coerce")
derived_df["timestamp"] = (dates.view("int64") // 1_000_000)
elif isinstance(derived_df.index, pd.DatetimeIndex):
idx = derived_df.index
if idx.tz is None:
idx = idx.tz_localize("UTC")
else:
idx = idx.tz_convert("UTC")
derived_df["timestamp"] = (idx.view("int64") // 1_000_000)
if "timestamp" not in derived_df.columns:
logger.warning(
"聚合结果缺少 timestamp 列,已跳过",
extra={"target_timeframe": target_tf},
)
continue
derived_df = derived_df.dropna(subset=["timestamp", "open", "high", "low", "close", "volume"])
if derived_df.empty:
continue
derived_df["timestamp"] = derived_df["timestamp"].astype("int64")
derived_df = derived_df.sort_values("timestamp")
last_ts = get_last_timestamp(DATA_DIR, symbol, target_tf)
if last_ts is not None:
derived_df = derived_df[derived_df["timestamp"] > last_ts]
if derived_df.empty:
continue
numpy_rows = derived_df[["timestamp", "open", "high", "low", "close", "volume"]].to_numpy()
records: List[CandleRow] = []
for ts, o, h, l, c, v in numpy_rows:
records.append(
[
int(ts),
float(o),
float(h),
float(l),
float(c),
float(v),
]
)
if not records:
continue
upsert_candles(DATA_DIR, symbol, target_tf, records)
updates.append((target_tf, records[-3:] if len(records) > 3 else records))
return updates
@dataclass
class FetchState:
symbol: str
timeframe: str
started_at: datetime = field(default_factory=datetime.utcnow)
last_fetch_at: Optional[datetime] = None
last_candle_ts: Optional[int] = None
consecutive_errors: int = 0
last_error: Optional[str] = None
def to_payload(self) -> dict:
def serialize_dt(dt: Optional[datetime]) -> Optional[str]:
if not dt:
return None
return dt.replace(microsecond=0).isoformat() + "Z"
return {
"symbol": self.symbol,
"timeframe": self.timeframe,
"started_at": serialize_dt(self.started_at),
"last_fetch_at": serialize_dt(self.last_fetch_at),
"last_candle_ts": self.last_candle_ts,
"consecutive_errors": self.consecutive_errors,
"last_error": self.last_error,
}
fetch_states: Dict[Tuple[str, str], FetchState] = {}
def build_exchange(): def build_exchange():
if EXCHANGE.lower() == "binance": if EXCHANGE.lower() == "binance":
return ccxt.binance({"enableRateLimit": True}) return ccxt_async.binance({"enableRateLimit": True})
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):
"""持续增量抓取并广播。""" """持续增量抓取并广播。"""
derived_timeframes = AGGREGATION_TARGETS.get(timeframe, [])
global RESAMPLE_WARNING_EMITTED
if derived_timeframes and not RESAMPLE_AVAILABLE and not RESAMPLE_WARNING_EMITTED:
logger.warning(
"缺少 technical.util.resample_to_interval 模块,聚合时间周期生成已跳过",
extra={"timeframe": timeframe},
)
RESAMPLE_WARNING_EMITTED = True
exchange = build_exchange() exchange = build_exchange()
tf_ms = tf_to_ms(timeframe) tf_ms = tf_to_ms(timeframe)
start_since = parse_start_from_ms(START_FROM) start_since = parse_start_from_ms(START_FROM)
last_ts = get_last_timestamp(DATA_DIR, symbol, timeframe) last_ts = get_last_timestamp(DATA_DIR, symbol, timeframe)
since = max(start_since, (last_ts + tf_ms) if last_ts else start_since) since = max(start_since, (last_ts + tf_ms) if last_ts else start_since)
backoff = 1.0
state_key = (symbol, timeframe)
fetch_states[state_key] = FetchState(symbol=symbol, timeframe=timeframe, last_candle_ts=last_ts)
while True: logger.info("启动拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
try:
candles = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000) try:
if candles: while True:
upsert_candles(DATA_DIR, symbol, timeframe, candles) try:
for row in candles[-3:]: candles = await exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
payload = { if candles:
"topic": f"candles.{symbol}.{timeframe}", upsert_candles(DATA_DIR, symbol, timeframe, candles)
"type": "upsert", derived_updates: List[Tuple[str, List[CandleRow]]] = []
"data": { if derived_timeframes and RESAMPLE_AVAILABLE:
"t": row[0], derived_updates = await asyncio.to_thread(
"o": row[1], resample_and_store,
"h": row[2], symbol,
"l": row[3], timeframe,
"c": row[4], derived_timeframes,
"v": row[5], )
}, for row in candles[-3:]:
} payload = {
await hub.publish(symbol, timeframe, payload) "topic": f"candles.{symbol}.{timeframe}",
since = candles[-1][0] + tf_ms "type": "upsert",
await asyncio.sleep(max(1.0, tf_ms * POLL_FACTOR / 1000.0)) "data": {
except Exception: "t": row[0],
await asyncio.sleep(3.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:
logger.info("取消拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
state = fetch_states.get(state_key)
if state:
state.last_error = "cancelled"
raise
finally:
with suppress(Exception):
await exchange.close()
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()
for s in SYMBOLS: for s in SYMBOLS:
for tf in TIMEFRAMES: for tf in FETCH_TIMEFRAMES:
asyncio.create_task(fetch_loop(s, tf)) task = asyncio.create_task(fetch_loop(s, tf), name=f"fetch::{s}::{tf}")
fetch_tasks.append(task)
@app.on_event("shutdown")
async def on_shutdown():
if not fetch_tasks:
return
logger.info("正在停止拉取任务")
tasks = list(fetch_tasks)
for task in tasks:
task.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
logger.warning("任务停止时出现异常:%s", result)
fetch_tasks.clear()
@app.get("/health")
async def health():
now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
return {
"status": "ok",
"time": now,
"exchange": EXCHANGE,
"symbols": SYMBOLS,
"base_timeframes": FETCH_TIMEFRAMES,
"derived_timeframes": DERIVED_TIMEFRAMES,
"timeframes": AVAILABLE_TIMEFRAMES,
"tasks": [state.to_payload() for state in fetch_states.values()],
}
@app.get("/api/candles") @app.get("/api/candles")
@@ -160,6 +469,7 @@ def api_candles(
end: Optional[int] = Query(None, description="结束时间戳(ms)"), end: Optional[int] = Query(None, description="结束时间戳(ms)"),
): ):
try: try:
ensure_symbol_timeframe(symbol, tf)
df = read_candles(DATA_DIR, symbol, tf, start, end) df = read_candles(DATA_DIR, symbol, tf, start, end)
records = df.to_dict("records") if not df.empty else [] records = df.to_dict("records") if not df.empty else []
return JSONResponse(records) return JSONResponse(records)
@@ -169,6 +479,9 @@ def api_candles(
@app.websocket("/ws") @app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket, symbol: str, tf: str, since: Optional[int] = None): async def ws_endpoint(websocket: WebSocket, symbol: str, tf: str, since: Optional[int] = None):
if symbol not in VALID_SYMBOLS or tf not in VALID_TIMEFRAMES:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="invalid symbol/timeframe")
return
await hub.subscribe(websocket, symbol, tf) await hub.subscribe(websocket, symbol, tf)
try: try:
snap = read_candles(DATA_DIR, symbol, tf, since, None) snap = read_candles(DATA_DIR, symbol, tf, since, None)
@@ -201,7 +514,9 @@ def root():
"service": "Local Data Service", "service": "Local Data Service",
"exchange": EXCHANGE, "exchange": EXCHANGE,
"symbols": SYMBOLS, "symbols": SYMBOLS,
"timeframes": TIMEFRAMES, "base_timeframes": FETCH_TIMEFRAMES,
"derived_timeframes": DERIVED_TIMEFRAMES,
"timeframes": AVAILABLE_TIMEFRAMES,
} }
+2 -2
View File
@@ -7,8 +7,8 @@ services:
environment: environment:
- EXCHANGE=binance - EXCHANGE=binance
- SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT - SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT
- TIMEFRAMES=1m,5m,15m,1h - TIMEFRAMES=1m,1h,1d,1w,1M
- START_FROM=2025-01-01 - START_FROM=2022-01-01
- POLL_FACTOR=0.5 - POLL_FACTOR=0.5
- DATA_DIR=/data - DATA_DIR=/data
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
+1
View File
@@ -4,4 +4,5 @@ ccxt==4.4.27
pandas==2.2.2 pandas==2.2.2
pyarrow==16.1.0 pyarrow==16.1.0
orjson==3.10.3 orjson==3.10.3
technical==1.5.0
+232 -79
View File
@@ -1,6 +1,10 @@
from flask import Flask, render_template, jsonify, request from flask import Flask, render_template, jsonify, request
from collections import OrderedDict
import json
import logging
import ccxt import ccxt
import pandas as pd import pandas as pd
import requests
from datetime import datetime, timedelta from datetime import datetime, timedelta
import sys import sys
import os import os
@@ -41,31 +45,170 @@ exchange = ccxt.binance({
# 初始化A股数据获取器 # 初始化A股数据获取器
china_stock = ChinaStockData() china_stock = ChinaStockData()
# 时间周期映射 logger = logging.getLogger(__name__)
TIMEFRAMES = {
'1m': '1分钟',
'3m': '3分钟',
'5m': '5分钟',
'15m': '15分钟',
'30m': '30分钟',
'1h': '1小时',
'2h': '2小时',
'4h': '4小时',
'6h': '6小时',
'8h': '8小时',
'12h': '12小时',
'1d': '日线',
'3d': '3日线',
'1w': '周线',
'1M': '月线',
}
# 常见交易对 DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9000"))
SYMBOLS = [
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
("1m", "1分钟"),
("3m", "3分钟"),
("5m", "5分钟"),
("15m", "15分钟"),
("30m", "30分钟"),
("1h", "1小时"),
("2h", "2小时"),
("4h", "4小时"),
("6h", "6小时"),
("8h", "8小时"),
("12h", "12小时"),
("1d", "日线"),
("3d", "3日线"),
("1w", "周线"),
("1M", "月线"),
])
DEFAULT_SYMBOLS = [
'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT', 'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT',
'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT' 'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT'
] ]
TIMEFRAMES = DEFAULT_TIMEFRAME_LABELS.copy()
SYMBOLS = DEFAULT_SYMBOLS.copy()
DATA_SERVICE_AVAILABLE = False
SERVICE_METADATA_LAST_REFRESH = 0
def timeframe_to_minutes(tf: str):
"""将时间周期转换为分钟数,用于排序。"""
if not tf:
return None
unit = tf[-1]
try:
value = int(tf[:-1])
except (ValueError, TypeError):
return None
multiplier = {
'm': 1,
'h': 60,
'd': 1440,
'w': 10080,
'M': 43200, # 30天近似
}.get(unit)
if multiplier is None:
return None
return value * multiplier
def format_timeframe_label(tf: str) -> str:
"""将时间周期转换为可读标签。"""
if not tf:
return tf
unit = tf[-1]
try:
value = int(tf[:-1])
except (ValueError, TypeError):
return tf
if unit == 'm':
return f"{value}分钟"
if unit == 'h':
return f"{value}小时"
if unit == 'd':
return "日线" if value == 1 else f"{value}日线"
if unit == 'w':
return "周线" if value == 1 else f"{value}周线"
if unit == 'M':
return "月线" if value == 1 else f"{value}月线"
return tf
def build_timeframe_labels(timeframes):
ordered = sorted(
timeframes,
key=lambda tf: timeframe_to_minutes(tf) if timeframe_to_minutes(tf) is not None else float('inf'),
)
labels = OrderedDict()
for tf in ordered:
labels[tf] = format_timeframe_label(tf)
return labels
def _parse_time_input(value):
if value in (None, '', 0):
return None
try:
return int(float(value))
except (ValueError, TypeError):
return None
def refresh_data_service_metadata(force=False):
"""刷新数据服务提供的交易对与周期元信息。"""
global DATA_SERVICE_AVAILABLE, TIMEFRAMES, SYMBOLS, SERVICE_METADATA_LAST_REFRESH
now = time.time()
if not force and DATA_SERVICE_AVAILABLE and now - SERVICE_METADATA_LAST_REFRESH < 60:
return True
try:
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=5)
resp.raise_for_status()
payload = resp.json()
service_symbols = payload.get("symbols") or payload.get("symbol_list") or []
base_timeframes = payload.get("timeframes") or payload.get("base_timeframes") or []
derived = payload.get("derived_timeframes") or []
service_timeframes = list(base_timeframes)
for tf in derived:
if tf not in service_timeframes:
service_timeframes.append(tf)
if service_symbols:
SYMBOLS[:] = service_symbols
if service_timeframes:
TIMEFRAMES.clear()
TIMEFRAMES.update(build_timeframe_labels(service_timeframes))
DATA_SERVICE_AVAILABLE = True
SERVICE_METADATA_LAST_REFRESH = now
return True
except Exception as exc:
logger.warning("无法加载数据服务元信息: %s", exc)
if not DATA_SERVICE_AVAILABLE:
TIMEFRAMES.clear()
TIMEFRAMES.update(DEFAULT_TIMEFRAME_LABELS)
SYMBOLS[:] = DEFAULT_SYMBOLS
DATA_SERVICE_AVAILABLE = False
return False
def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=None):
params = {"symbol": symbol, "tf": timeframe}
if start_ms is not None:
params["start"] = int(start_ms)
if end_ms is not None:
params["end"] = int(end_ms)
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
if not data:
return None
df = pd.DataFrame(data)
if df.empty or "timestamp" not in df.columns:
return None
numeric_cols = ["open", "high", "low", "close", "volume"]
df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce")
df = df.dropna(subset=["timestamp"])
df["timestamp"] = df["timestamp"].astype("int64")
for col in numeric_cols:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=numeric_cols)
df = df.sort_values("timestamp")
if limit and len(df) > limit:
df = df.tail(limit)
df = df.reset_index(drop=True)
df["date"] = pd.to_datetime(df["timestamp"], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
return df
# 模块加载时尝试预取一次元信息,但失败不阻塞后续流程
refresh_data_service_metadata(force=True)
# A股热门股票 # A股热门股票
A_STOCK_SYMBOLS = china_stock.get_popular_stocks() A_STOCK_SYMBOLS = china_stock.get_popular_stocks()
@@ -89,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(symbol, timeframe, limit=1000, start_time=None, end_time=None): def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=1000, start_time=None, end_time=None):
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据""" """获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
try: try:
# 初始化参数 # 初始化参数
@@ -202,6 +345,30 @@ def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=
except Exception as e: except Exception as e:
return None return None
def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=None):
"""优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。"""
start_ms = _parse_time_input(start_time)
end_ms = _parse_time_input(end_time)
refresh_data_service_metadata()
if DATA_SERVICE_AVAILABLE:
try:
df = _fetch_kl_from_datasvc(
symbol=symbol,
timeframe=timeframe,
start_ms=start_ms,
end_ms=end_ms,
limit=limit,
)
if df is not None and not df.empty:
return df
except Exception as exc:
logger.warning("数据服务请求失败,准备回退至交易所 API:%s", exc)
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=1000, start_time=None, end_time=None):
"""获取A股K线数据""" """获取A股K线数据"""
try: try:
@@ -733,66 +900,18 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
def is_smaller_timeframe(tf1, tf2): def is_smaller_timeframe(tf1, tf2):
"""判断时间周期tf1是否小于tf2""" """判断时间周期tf1是否小于tf2"""
# 定义时间周期的分钟数映射 tf1_value = timeframe_to_minutes(tf1)
tf_values = { tf2_value = timeframe_to_minutes(tf2)
'1m': 1,
'3m': 3,
'5m': 5,
'15m': 15,
'30m': 30,
'1h': 60,
'2h': 120,
'4h': 240,
'6h': 360,
'8h': 480,
'12h': 720,
'1d': 1440,
'3d': 4320,
'1w': 10080,
'1M': 43200
}
# 获取时间周期对应的分钟数
tf1_value = tf_values.get(tf1)
tf2_value = tf_values.get(tf2)
# 如果某个时间周期不在映射中,返回False
if tf1_value is None or tf2_value is None: if tf1_value is None or tf2_value is None:
return False return False
# 返回tf1是否小于tf2
return tf1_value < tf2_value return tf1_value < tf2_value
def is_smaller_or_equal_timeframe(tf1, tf2): def is_smaller_or_equal_timeframe(tf1, tf2):
"""判断时间周期tf1是否小于等于tf2""" """判断时间周期tf1是否小于等于tf2"""
# 定义时间周期的分钟数映射 tf1_value = timeframe_to_minutes(tf1)
tf_values = { tf2_value = timeframe_to_minutes(tf2)
'1m': 1,
'3m': 3,
'5m': 5,
'15m': 15,
'30m': 30,
'1h': 60,
'2h': 120,
'4h': 240,
'6h': 360,
'8h': 480,
'12h': 720,
'1d': 1440,
'3d': 4320,
'1w': 10080,
'1M': 43200
}
# 获取时间周期对应的分钟数
tf1_value = tf_values.get(tf1)
tf2_value = tf_values.get(tf2)
# 如果某个时间周期不在映射中,返回False
if tf1_value is None or tf2_value is None: if tf1_value is None or tf2_value is None:
return False return False
# 返回tf1是否小于等于tf2
return tf1_value <= tf2_value return tf1_value <= tf2_value
def clean_dataframe_for_json(df): def clean_dataframe_for_json(df):
@@ -900,12 +1019,15 @@ def classify_trend_stage(df):
def load_crypto_symbols(limit=200): def load_crypto_symbols(limit=200):
"""加载常见USDT永续合约交易对,返回列表""" """加载常见USDT永续合约交易对,返回列表"""
refresh_data_service_metadata()
if SYMBOLS:
return SYMBOLS[:limit]
try: try:
markets = exchange.load_markets() markets = exchange.load_markets()
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s] symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
return symbols[:limit] return symbols[:limit]
except Exception: except Exception:
return SYMBOLS return DEFAULT_SYMBOLS[:limit]
@app.route('/api/trend_filter', methods=['GET']) @app.route('/api/trend_filter', methods=['GET'])
@@ -1030,10 +1152,38 @@ def trend_detail():
@app.route('/') @app.route('/')
def index(): def index():
"""主页""" """主页"""
return render_template('index.html', refresh_data_service_metadata()
timeframes=TIMEFRAMES, timeframe_items = list(TIMEFRAMES.items())
symbols=SYMBOLS, timeframe_keys = [item[0] for item in timeframe_items]
a_stock_symbols=A_STOCK_SYMBOLS) symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in TIMEFRAMES), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in TIMEFRAMES and timeframe_keys:
default_main = timeframe_keys[0]
if timeframe_keys:
try:
idx = timeframe_keys.index(default_main)
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
except ValueError:
default_element = timeframe_keys[0]
else:
default_element = default_main
default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '')
return render_template(
'index.html',
timeframes=TIMEFRAMES,
symbols=symbols,
a_stock_symbols=A_STOCK_SYMBOLS,
default_main_timeframe=default_main,
default_element_timeframe=default_element,
default_symbol=default_symbol,
timeframe_keys_json=json.dumps(timeframe_keys),
data_service_available=DATA_SERVICE_AVAILABLE,
)
@app.route('/api/analyze') @app.route('/api/analyze')
def analyze(): def analyze():
@@ -1417,13 +1567,16 @@ def analyze():
@app.route('/api/symbols') @app.route('/api/symbols')
def get_symbols(): def get_symbols():
"""获取可用交易对""" """获取可用交易对"""
refresh_data_service_metadata()
if SYMBOLS:
return jsonify(SYMBOLS)
try: try:
markets = exchange.load_markets() markets = exchange.load_markets()
# 合约交易对通常是以USDT结尾的永续合约 # 合约交易对通常是以USDT结尾的永续合约
symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol] symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol]
return jsonify(symbols) return jsonify(symbols)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}) return jsonify(DEFAULT_SYMBOLS)
@app.route('/api/a_stocks') @app.route('/api/a_stocks')
def get_a_stocks(): def get_a_stocks():
+1
View File
@@ -2,6 +2,7 @@ flask>=2.0.1
ccxt>=4.4.70 ccxt>=4.4.70
pandas>=1.3.3 pandas>=1.3.3
numpy>=1.21.2 numpy>=1.21.2
requests>=2.31.0
plotly>=5.3.1 plotly>=5.3.1
matplotlib>=3.4.3 matplotlib>=3.4.3
pytz>=2021.1 pytz>=2021.1
+42 -38
View File
@@ -16,6 +16,25 @@
<script defer src="{{ url_for('static', filename='js/indicators.js') }}"></script> <script defer src="{{ url_for('static', filename='js/indicators.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/charts.js') }}"></script> <script defer src="{{ url_for('static', filename='js/charts.js') }}"></script>
<!-- TradingView Widget END --> <!-- TradingView Widget END -->
<script>
window.AVAILABLE_TIMEFRAMES = {{ timeframe_keys_json | safe }};
window.DEFAULT_MAIN_TIMEFRAME = "{{ default_main_timeframe }}";
window.DEFAULT_ELEMENT_TIMEFRAME = "{{ default_element_timeframe }}";
window.timeframeToMs = function(tf) {
if (!tf) return null;
var unit = tf.slice(-1);
var value = parseInt(tf.slice(0, -1), 10);
if (isNaN(value)) return null;
var unitMap = {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
M: 30 * 24 * 60 * 60 * 1000
};
return unitMap[unit] ? value * unitMap[unit] : null;
};
</script>
<style> <style>
body { body {
font-family: "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; font-family: "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
@@ -765,7 +784,7 @@
<label for="symbol" class="form-label">交易对:</label> <label for="symbol" class="form-label">交易对:</label>
<select id="symbol" class="form-select"> <select id="symbol" class="form-select">
{% for symbol in symbols %} {% for symbol in symbols %}
<option value="{{ symbol }}" {% if symbol == 'BTC/USDT:USDT' %}selected{% endif %}>{{ symbol }}</option> <option value="{{ symbol }}" {% if symbol == default_symbol %}selected{% endif %}>{{ symbol }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -861,7 +880,7 @@
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;"> <select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %} {% for value, label in timeframes.items() %}
<option value="{{ value }}" {% if value == '5m' %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if value == default_main_timeframe %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -899,7 +918,7 @@
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;"> <select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %} {% for value, label in timeframes.items() %}
<option value="{{ value }}" {% if value == '1m' %}selected{% endif %}>{{ label }}</option> <option value="{{ value }}" {% if value == default_element_timeframe %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -1144,11 +1163,7 @@
// 周期变化时,自动填充当前时间回溯300根K线的时间范围 // 周期变化时,自动填充当前时间回溯300根K线的时间范围
$('#trendTimeframe').on('change', function(){ $('#trendTimeframe').on('change', function(){
const tf = $(this).val(); const tf = $(this).val();
const tfToMs = { const step = window.timeframeToMs(tf) || (60*60*1000);
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
};
const step = tfToMs[tf] || (60*60*1000);
const now = new Date(); const now = new Date();
const endMs = now.getTime(); const endMs = now.getTime();
const startMs = endMs - 300 * step; const startMs = endMs - 300 * step;
@@ -1177,11 +1192,7 @@
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根 // 前端必须提供时间范围:若为空,自动以当前时间回溯300根
if (!start || !end) { if (!start || !end) {
const tfToMs = { const step = window.timeframeToMs(timeframe) || (60*60*1000);
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
};
const step = tfToMs[timeframe] || (60*60*1000);
const now = Date.now(); const now = Date.now();
const startMsAuto = now - 300 * step; const startMsAuto = now - 300 * step;
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16); const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
@@ -1777,30 +1788,17 @@
// 比较两个时间周期的大小 // 比较两个时间周期的大小
function compareTimeframes(tf1, tf2) { function compareTimeframes(tf1, tf2) {
const tfValues = { const v1 = window.timeframeToMs(tf1);
'1m': 1, const v2 = window.timeframeToMs(tf2);
'3m': 3, if (v1 === null || v2 === null) {
'5m': 5, return 0;
'15m': 15, }
'30m': 30, return v1 - v2;
'1h': 60,
'2h': 120,
'4h': 240,
'6h': 360,
'8h': 480,
'12h': 720,
'1d': 1440,
'3d': 4320,
'1w': 10080,
'1M': 43200
};
return tfValues[tf1] - tfValues[tf2];
} }
// 设置比主周期小的最大周期 // 设置比主周期小的最大周期
function setSmallestLargerTimeframe(mainTimeframe) { function setSmallestLargerTimeframe(mainTimeframe) {
const timeframes = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w', '1M']; const timeframes = window.AVAILABLE_TIMEFRAMES || [];
const mainIndex = timeframes.indexOf(mainTimeframe); const mainIndex = timeframes.indexOf(mainTimeframe);
if (mainIndex > 0) { if (mainIndex > 0) {
@@ -1812,7 +1810,7 @@
// 设置小于或等于主周期的时间周期 // 设置小于或等于主周期的时间周期
function setSmallerOrEqualTimeframe(mainTimeframe) { function setSmallerOrEqualTimeframe(mainTimeframe) {
const timeframes = ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w', '1M']; const timeframes = window.AVAILABLE_TIMEFRAMES || [];
const mainIndex = timeframes.indexOf(mainTimeframe); const mainIndex = timeframes.indexOf(mainTimeframe);
// 默认选择相同的时间周期 // 默认选择相同的时间周期
@@ -1973,9 +1971,9 @@
symbol = $('#astockSymbol').val() || '000001'; symbol = $('#astockSymbol').val() || '000001';
} }
const timeframe = $('#timeframe').val() || '5m'; const timeframe = $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '5m';
const timezone = $('#timezone').val() || 'Asia/Shanghai'; const timezone = $('#timezone').val() || 'Asia/Shanghai';
const elementTimeframe = $('#elementTimeframe').val() || '1m'; const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
// 确保时区参数有效 // 确保时区参数有效
console.log('更新图表使用时区:', timezone); console.log('更新图表使用时区:', timezone);
@@ -7166,8 +7164,14 @@
// 初始化交易对下拉菜单 // 初始化交易对下拉菜单
$('#symbol').val('BTC/USDT:USDT'); $('#symbol').val('BTC/USDT:USDT');
$('#astockSymbol').val('000001'); $('#astockSymbol').val('000001');
$('#timeframe').val('5m'); const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val();
$('#elementTimeframe').val('1m'); const elementDefault = window.DEFAULT_ELEMENT_TIMEFRAME || $('#elementTimeframe option:first').val();
if (mainDefault) {
$('#timeframe').val(mainDefault);
}
if (elementDefault) {
$('#elementTimeframe').val(elementDefault);
}
// 测试打印时区偏移量 // 测试打印时区偏移量
console.log('当前时区偏移量 (UTC+8):', getTimezoneOffset('Asia/Shanghai')); console.log('当前时区偏移量 (UTC+8):', getTimezoneOffset('Asia/Shanghai'));