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

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
+232 -79
View File
@@ -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():
+1
View File
@@ -2,6 +2,7 @@ flask>=2.0.1
ccxt>=4.4.70
pandas>=1.3.3
numpy>=1.21.2
requests>=2.31.0
plotly>=5.3.1
matplotlib>=3.4.3
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/charts.js') }}"></script>
<!-- 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>
body {
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>
<select id="symbol" class="form-select">
{% 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 %}
</select>
</div>
@@ -861,7 +880,7 @@
<div class="form-check form-check-inline">
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% 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 %}
</select>
</div>
@@ -899,7 +918,7 @@
<div class="form-check form-check-inline">
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% 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 %}
</select>
</div>
@@ -1144,11 +1163,7 @@
// 周期变化时,自动填充当前时间回溯300根K线的时间范围
$('#trendTimeframe').on('change', function(){
const tf = $(this).val();
const tfToMs = {
'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 step = window.timeframeToMs(tf) || (60*60*1000);
const now = new Date();
const endMs = now.getTime();
const startMs = endMs - 300 * step;
@@ -1177,11 +1192,7 @@
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根
if (!start || !end) {
const tfToMs = {
'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 step = window.timeframeToMs(timeframe) || (60*60*1000);
const now = Date.now();
const startMsAuto = now - 300 * step;
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
@@ -1777,30 +1788,17 @@
// 比较两个时间周期的大小
function compareTimeframes(tf1, tf2) {
const tfValues = {
'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
};
return tfValues[tf1] - tfValues[tf2];
const v1 = window.timeframeToMs(tf1);
const v2 = window.timeframeToMs(tf2);
if (v1 === null || v2 === null) {
return 0;
}
return v1 - v2;
}
// 设置比主周期小的最大周期
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);
if (mainIndex > 0) {
@@ -1812,7 +1810,7 @@
// 设置小于或等于主周期的时间周期
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);
// 默认选择相同的时间周期
@@ -1973,9 +1971,9 @@
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 elementTimeframe = $('#elementTimeframe').val() || '1m';
const elementTimeframe = $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m';
// 确保时区参数有效
console.log('更新图表使用时区:', timezone);
@@ -7166,8 +7164,14 @@
// 初始化交易对下拉菜单
$('#symbol').val('BTC/USDT:USDT');
$('#astockSymbol').val('000001');
$('#timeframe').val('5m');
$('#elementTimeframe').val('1m');
const mainDefault = window.DEFAULT_MAIN_TIMEFRAME || $('#timeframe option:first').val();
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'));