添加本地数据源,以后就可以直接用本地数据了
This commit is contained in:
+232
-79
@@ -1,6 +1,10 @@
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import logging
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
import os
|
||||
@@ -41,31 +45,170 @@ exchange = ccxt.binance({
|
||||
# 初始化A股数据获取器
|
||||
china_stock = ChinaStockData()
|
||||
|
||||
# 时间周期映射
|
||||
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': '月线',
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 常见交易对
|
||||
SYMBOLS = [
|
||||
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9000"))
|
||||
|
||||
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',
|
||||
'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_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:
|
||||
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线数据,支持分页加载确保获取指定时间范围内的所有数据"""
|
||||
try:
|
||||
# 初始化参数
|
||||
@@ -202,6 +345,30 @@ def get_crypto_kl_data(symbol, timeframe, limit=1000, start_time=None, end_time=
|
||||
except Exception as e:
|
||||
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):
|
||||
"""获取A股K线数据"""
|
||||
try:
|
||||
@@ -733,66 +900,18 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||
|
||||
def is_smaller_timeframe(tf1, tf2):
|
||||
"""判断时间周期tf1是否小于tf2"""
|
||||
# 定义时间周期的分钟数映射
|
||||
tf_values = {
|
||||
'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
|
||||
tf1_value = timeframe_to_minutes(tf1)
|
||||
tf2_value = timeframe_to_minutes(tf2)
|
||||
if tf1_value is None or tf2_value is None:
|
||||
return False
|
||||
|
||||
# 返回tf1是否小于tf2
|
||||
return tf1_value < tf2_value
|
||||
|
||||
def is_smaller_or_equal_timeframe(tf1, tf2):
|
||||
"""判断时间周期tf1是否小于等于tf2"""
|
||||
# 定义时间周期的分钟数映射
|
||||
tf_values = {
|
||||
'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
|
||||
tf1_value = timeframe_to_minutes(tf1)
|
||||
tf2_value = timeframe_to_minutes(tf2)
|
||||
if tf1_value is None or tf2_value is None:
|
||||
return False
|
||||
|
||||
# 返回tf1是否小于等于tf2
|
||||
return tf1_value <= tf2_value
|
||||
|
||||
def clean_dataframe_for_json(df):
|
||||
@@ -900,12 +1019,15 @@ def classify_trend_stage(df):
|
||||
|
||||
def load_crypto_symbols(limit=200):
|
||||
"""加载常见USDT永续合约交易对,返回列表"""
|
||||
refresh_data_service_metadata()
|
||||
if SYMBOLS:
|
||||
return SYMBOLS[:limit]
|
||||
try:
|
||||
markets = exchange.load_markets()
|
||||
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
|
||||
return symbols[:limit]
|
||||
except Exception:
|
||||
return SYMBOLS
|
||||
return DEFAULT_SYMBOLS[:limit]
|
||||
|
||||
|
||||
@app.route('/api/trend_filter', methods=['GET'])
|
||||
@@ -1030,10 +1152,38 @@ def trend_detail():
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""主页"""
|
||||
return render_template('index.html',
|
||||
timeframes=TIMEFRAMES,
|
||||
symbols=SYMBOLS,
|
||||
a_stock_symbols=A_STOCK_SYMBOLS)
|
||||
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,
|
||||
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')
|
||||
def analyze():
|
||||
@@ -1417,13 +1567,16 @@ def analyze():
|
||||
@app.route('/api/symbols')
|
||||
def get_symbols():
|
||||
"""获取可用交易对"""
|
||||
refresh_data_service_metadata()
|
||||
if SYMBOLS:
|
||||
return jsonify(SYMBOLS)
|
||||
try:
|
||||
markets = exchange.load_markets()
|
||||
# 合约交易对通常是以USDT结尾的永续合约
|
||||
symbols = [symbol for symbol in markets.keys() if '/USDT' in symbol and ':USDT' in symbol]
|
||||
return jsonify(symbols)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)})
|
||||
return jsonify(DEFAULT_SYMBOLS)
|
||||
|
||||
@app.route('/api/a_stocks')
|
||||
def get_a_stocks():
|
||||
|
||||
Reference in New Issue
Block a user