添加本地数据源,以后就可以直接用本地数据了
This commit is contained in:
@@ -28,3 +28,4 @@ feature_meta
|
|||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
.DS_Store
|
.DS_Store
|
||||||
交易记录/~$交易规则.docx
|
交易记录/~$交易规则.docx
|
||||||
|
/datasvc/data
|
||||||
|
|||||||
+414
@@ -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
@@ -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:
|
||||||
|
|||||||
+24
@@ -18,6 +18,30 @@ class ChanMACD():
|
|||||||
self.cross0_down_list = [] # 向下穿越零轴列表
|
self.cross0_down_list = [] # 向下穿越零轴列表
|
||||||
# 计算段 / UnitTF / HistSet 及状态标记
|
# 计算段 / UnitTF / HistSet 及状态标记
|
||||||
self.cal_macd_state()
|
self.cal_macd_state()
|
||||||
|
self.get_klu_sd_list()
|
||||||
|
def get_klu_sd(self):
|
||||||
|
if self.klu_list:
|
||||||
|
sd = self.klu_list[-1].separate_div
|
||||||
|
if sd > 1:
|
||||||
|
print(self.klu_list[-1].time, sd)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
def get_klu_sd_list(self):
|
||||||
|
sd_list = []
|
||||||
|
if self.klu_list:
|
||||||
|
for klu in self.klu_list:
|
||||||
|
hist = klu.macdhist
|
||||||
|
singal = False
|
||||||
|
if klu.pre and klu.next:
|
||||||
|
if klu.signal > 0:
|
||||||
|
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
|
||||||
|
else:
|
||||||
|
signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal
|
||||||
|
sd = klu.separate_div
|
||||||
|
if sd > 1 and ((hist > 0 and hist < 20) or (hist < 0 and hist > -20)):
|
||||||
|
sd_list.append(klu.time)
|
||||||
|
print(klu.time, sd)
|
||||||
|
return sd_list
|
||||||
def cal_macd_state(self):
|
def cal_macd_state(self):
|
||||||
last_seg = None
|
last_seg = None
|
||||||
last_unittf = None
|
last_unittf = None
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -1,48 +1,141 @@
|
|||||||
# Local Data Service (REST + WebSocket)
|
# Local Data Service(REST + 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` 的实时推送。
|
||||||
|
|||||||
+341
-26
@@ -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,26 +193,156 @@ 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)
|
||||||
|
|
||||||
|
logger.info("启动拉取任务", extra={"symbol": symbol, "timeframe": timeframe})
|
||||||
|
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
candles = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
|
candles = await exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
|
||||||
if candles:
|
if candles:
|
||||||
upsert_candles(DATA_DIR, symbol, timeframe, 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:]:
|
for row in candles[-3:]:
|
||||||
payload = {
|
payload = {
|
||||||
"topic": f"candles.{symbol}.{timeframe}",
|
"topic": f"candles.{symbol}.{timeframe}",
|
||||||
@@ -138,18 +357,108 @@ async def fetch_loop(symbol: str, timeframe: str):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
await hub.publish(symbol, timeframe, payload)
|
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
|
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))
|
await asyncio.sleep(max(1.0, tf_ms * POLL_FACTOR / 1000.0))
|
||||||
except Exception:
|
except asyncio.CancelledError:
|
||||||
await asyncio.sleep(3.0)
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+231
-78
@@ -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()
|
||||||
|
timeframe_items = list(TIMEFRAMES.items())
|
||||||
|
timeframe_keys = [item[0] for item in timeframe_items]
|
||||||
|
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,
|
timeframes=TIMEFRAMES,
|
||||||
symbols=SYMBOLS,
|
symbols=symbols,
|
||||||
a_stock_symbols=A_STOCK_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():
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user