Files
Chan/web/app.py
T

2109 lines
81 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from flask import Flask, render_template, jsonify, request, send_from_directory
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
import io
import base64
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from pytz import timezone
import talib.abstract as ta
import numpy as np
# 添加父目录到系统路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ChanLun import ChanLun, TF_DF
from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
from cn_stock_data import ChinaStockData
from ChanMACD import ChanMACD
from ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized
# 添加买卖点枚举类型
class TRADE_POINT_TYPE:
BUY1 = 1 # 一类买点
BUY2 = 2 # 二类买点
BUY3 = 3 # 三类买点
SELL1 = -1 # 一类卖点
SELL2 = -2 # 二类卖点
SELL3 = -3 # 三类卖点
app = Flask(__name__)
macd_factor = 1
smooth_factor = 1
macd_fast_period = 12 * macd_factor
macd_slow_period = 26 * macd_factor
macd_signal_period = 9 * smooth_factor
# 初始化交易所
exchange = ccxt.binance({
'enableRateLimit': True,
'proxies': {
'http': 'http://127.0.0.1:7897',
'https': 'http://127.0.0.1:7897',
},
})
# 初始化 A 股数据获取器(K 线优先请求 A-Share Data Platform,默认 http://103.179.242.166:8000 ,见 /api/v1/klines 文档;ASHARE_DP_URL 覆盖,置空则仅用 AKShare
china_stock = ChinaStockData()
logger = logging.getLogger(__name__)
# 结构价值区缓存: {tf_name: {'data': ..., 'expires': timestamp}}
_zone_cache = {}
def _zone_cache_ttl(tf_name: str) -> int:
"""根据时间周期返回缓存过期时间(秒)"""
minutes = timeframe_to_minutes(tf_name) or 5
if minutes <= 5:
return 120 # 5m及以下: 2分钟
elif minutes <= 15:
return 300 # 15m: 5分钟
elif minutes <= 60:
return 600 # 1h: 10分钟
else:
return 1800 # 4h+: 30分钟
# 加密货币本地/自建行情服务(与 A 股 ASHARE_DP_URL 端口可不同)
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166"))
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', 'WIF/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 compute_timeframe_defaults(labels_ordered):
"""
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
"""
if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered 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
if timeframe_keys:
try:
idx_el = timeframe_keys.index(default_element)
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
except ValueError:
default_sub_sub = timeframe_keys[0]
else:
default_sub_sub = default_element
return default_main, default_element, default_sub_sub, timeframe_keys
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)
if limit is not None:
params["limit"] = limit
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股」时由前端请求 /api/a_stocks 填充全市场(约 5500+
A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}]
def detect_symbol_type(symbol):
"""检测交易对类型:crypto 或 a_stock"""
if '/' in symbol and 'USDT' in symbol:
return 'crypto'
elif len(symbol) == 6 and symbol.isdigit():
return 'a_stock'
else:
return 'unknown'
def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
"""获取K线数据,支持加密货币和A股"""
symbol_type = detect_symbol_type(symbol)
if symbol_type == 'crypto':
return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time)
elif symbol_type == 'a_stock':
return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time)
else:
return None
def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None):
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
try:
# 初始化参数
since = None
if start_time:
try:
since = int(start_time)
except ValueError:
pass
# 结束时间处理
until = None
if end_time:
try:
until = int(end_time)
except ValueError:
pass
# 根据时间周期调整每次请求的数据量
batch_size = 1000 # 默认批次大小
if timeframe in ['1m', '3m', '5m']:
batch_size = 1000 # 分钟级数据减少批次大小
elif timeframe in ['15m', '30m', '1h']:
batch_size = 1000
else:
batch_size = 1500 # 日线及以上可以获取更多
batch_size = 1500 # 默认批次大小
# 初始化存储所有K线数据的列表
all_ohlcv = []
# 初始化当前查询的开始时间
current_since = since
# 添加请求计数和最大限制
request_count = 0
max_requests = 300 # 最大请求次数,防止无限循环
# 分页加载数据
while request_count < max_requests:
request_count += 1
try:
# 获取当前页的数据
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size)
# 如果没有获取到数据,结束循环
if not ohlcv or len(ohlcv) == 0:
break
# 将获取到的数据添加到总列表中
all_ohlcv.extend(ohlcv)
# 获取最后一条数据的时间戳
last_timestamp = ohlcv[-1][0]
# 如果已达到结束时间,结束循环
if until and last_timestamp >= until:
break
# 如果获取的数据条数小于限制数,说明已经获取完所有数据
if len(ohlcv) < batch_size:
break
# 更新下一页的开始时间(加1毫秒避免重复)
current_since = last_timestamp + 1
except Exception as e:
# 如果单个批次失败,继续尝试下一个批次
if current_since:
# 尝试增加时间跳过可能的问题时间点
current_since += 60000 # 跳过1分钟
else:
break
# 防止API请求过于频繁
time.sleep(0.3) # 减少到0.3秒提高效率
# 数据为空的情况
if not all_ohlcv or len(all_ohlcv) == 0:
return None
# 转换为DataFrame
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
df['date'] = pd.to_datetime(df['timestamp'], unit='ms').dt.tz_localize('UTC').dt.tz_convert('Asia/Shanghai')
# 在客户端进行结束时间过滤
if until:
df = df[df['timestamp'] <= until]
# 去除重复数据
df = df.drop_duplicates(subset=['timestamp'])
# 按时间排序
df = df.sort_values('timestamp')
# 限制数据条数的逻辑 - 优先考虑时间范围
if start_time and end_time:
# 如果指定了明确的时间范围,返回该时间范围内的所有数据
if len(df) > 100000: # 防止数据量过大,设置一个合理的上限
df = df.tail(100000).reset_index(drop=True)
elif limit and len(df) > limit:
# 如果没有指定明确时间范围,使用默认的limit限制
df = df.tail(limit).reset_index(drop=True)
# 如果过滤后没有数据,返回None
if len(df) == 0:
return None
return df
except Exception as e:
return None
def get_crypto_kl_data(symbol, timeframe, limit=100000, 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=100000, start_time=None, end_time=None):
"""获取A股K线数据"""
try:
# 处理时间戳参数转换为日期字符串
start_date = None
end_date = None
if start_time:
try:
# 尝试解析时间戳(毫秒)
start_timestamp = int(start_time)
start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d')
except (ValueError, TypeError):
# 如果不是时间戳,尝试解析datetime-local格式 (YYYY-MM-DDTHH:MM)
try:
if 'T' in str(start_time):
# datetime-local格式:2025-05-19T06:07
start_date = str(start_time).split('T')[0] # 只取日期部分
else:
start_date = str(start_time)
except:
start_date = start_time
if end_time:
try:
# 尝试解析时间戳(毫秒)
end_timestamp = int(end_time)
end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d')
except (ValueError, TypeError):
# 如果不是时间戳,尝试解析datetime-local格式
try:
if 'T' in str(end_time):
# datetime-local格式:2025-05-26T06:07
end_date = str(end_time).split('T')[0] # 只取日期部分
else:
end_date = str(end_time)
except:
end_date = end_time
# 如果用户指定了时间范围,优先获取该范围内的所有数据
actual_limit = limit
if start_date and end_date:
actual_limit = None # 不限制数据条数,获取完整时间范围数据
# 调用A股数据获取器
df = china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit)
if df is None:
return None
return df
except Exception as e:
return None
def add_indicators(df):
global macd_fast_period, macd_slow_period, macd_signal_period
macd = ta.MACD(df, fastperiod=macd_fast_period, slowperiod=macd_slow_period, signalperiod=macd_signal_period)
df['macd'] = macd['macd']
df['macdsignal'] = macd['macdsignal']
df['macdhist'] = macd['macdhist']
df['ma5'] = (ta.MA(df, timeperiod=5)).fillna(0)
df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0)
df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0)
df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0)
# 新增 EMA 指标
df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0)
df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0)
df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0)
df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0)
df['ema26'] = (ta.EMA(df, timeperiod=26)).fillna(0)
df['ema13'] = (ta.EMA(df, timeperiod=13)).fillna(0)
df['ema7'] = (ta.EMA(df, timeperiod=7)).fillna(0)
df['ema104'] = (ta.EMA(df, timeperiod=104)).fillna(0)
df['ema156'] = (ta.EMA(df, timeperiod=156)).fillna(0)
df['ema208'] = (ta.EMA(df, timeperiod=208)).fillna(0)
# 常用SMA 24/52
try:
df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0)
df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0)
except Exception:
df['sma24'] = 0
df['sma52'] = 0
df['rsi'] = ta.RSI(df, timeperiod=14)
# 计算布林带 (当前周期 - 20周期,2标准差)
bb = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
df['bb_upper'] = bb['upperband'].fillna(0)
df['bb_middle'] = bb['middleband'].fillna(0)
df['bb_lower'] = bb['lowerband'].fillna(0)
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
#bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
df['bbup30'] = bb30['upperband'].fillna(0)
df['bblow30'] = bb30['lowerband'].fillna(0)
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
#bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
df['bbup302'] = bb302['upperband'].fillna(0)
df['bblow302'] = bb302['lowerband'].fillna(0)
# 计算次周期布林带 (14周期,2标准差)
bb_element = ta.BBANDS(df, timeperiod=14, nbdevup=2.0, nbdevdn=2.0, matype=0)
df['element_bb_upper'] = bb_element['upperband'].fillna(0)
df['element_bb_middle'] = bb_element['middleband'].fillna(0)
df['element_bb_lower'] = bb_element['lowerband'].fillna(0)
df['macd'] = df['macd'].fillna(0)
df['macdsignal'] = df['macdsignal'].fillna(0)
df['macdhist'] = df['macdhist'].fillna(0)
df['ma5'] = df['ma5'].fillna(0)
df['ma10'] = df['ma10'].fillna(0)
df['ma30'] = df['ma30'].fillna(0)
df['ma250'] = df['ma250'].fillna(0)
df['ema5'] = df['ema5'].fillna(0)
df['ema10'] = df['ema10'].fillna(0)
df['ema24'] = df['ema24'].fillna(0)
df['ema52'] = df['ema52'].fillna(0)
df['sma24'] = df['sma24'].fillna(0)
df['sma52'] = df['sma52'].fillna(0)
df['rsi'] = df['rsi'].fillna(0)
df['avg_volume'] = df['volume'].rolling(10).mean()
# 计算量比,避免产生Infinity值
df['volume_ratio'] = df['volume'] / df['avg_volume']
# 填充缺失值(前N根K线)
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
df['avg_volume'] = df['avg_volume'].fillna(0)
# 处理Infinity和-Infinity值
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
# 计算ATR (Average True Range) - 14周期
df['atr'] = ta.ATR(df, timeperiod=14)
df['atr'] = df['atr'].fillna(0)
bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0)
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
df['bb2633upper'] = bb2633['upperband'].fillna(0)
df['bb2633lower'] = bb2633['lowerband'].fillna(0)
df['bbp2633'] = bbp2633.fillna(0)
df['bb2633middle'] = bb2633['middleband'].fillna(0)
return df
def calculate_macd(df):
"""计算MACD指标"""
global macd_fast_period, macd_slow_period, macd_signal_period
exp1 = df['close'].ewm(span=macd_fast_period, adjust=False).mean()
exp2 = df['close'].ewm(span=macd_slow_period, adjust=False).mean()
macd = exp1 - exp2
signal = macd.ewm(span=macd_signal_period, adjust=False).mean()
histogram = macd - signal
return {
'macd': macd.tolist(),
'signal': signal.tolist(),
'histogram': histogram.tolist()
}
def analyze_chan(df, symbol=None, timeframe=None):
"""进行缠论分析"""
chan = TF_DF()
# 初始化多时间周期数据以获取EMA52
ema52_dict = None
# 获取分析结果
klu_list = chan.get_kl_data(df)
klc_list = chan.get_klc_list(klu_list)
bi_list = chan.cal_bi_list(klc_list)
#for index in range(0, 10):
#print(bi_list[index].start_time, bi_list[index].start_klc.end_time, bi_list[index].dir)
seg_list = chan.get_seg_list(bi_list)
zs_list = chan.calculate_seg_zs(seg_list)
# 计算笔中枢(BI中枢)并拍平成列表
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
bi_zs_list = chan.cal_bi_zs(seg_list)
bsp_list = []
if len(bi_zs_list) > 0:
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
#bsp_state_list = chan.get_bsp_state(df)
#for bsp in bsp_list:
#print(bsp.end_time, bsp.type, bsp.dir)
# 添加买卖点识别
for bi in bi_list:
bi.cal_macdhist()
for bi in bi_list:
bi.cal_macd_div()
#print(bi.start_time, bi.macd_hist, bi.macd_div)
# 添加ChanMACD分析
chan_macd = None
chan_macd_data = {}
try:
if klu_list and len(klu_list) > 0:
print(f"获取到KLU列表,长度: {len(klu_list)}")
chan_macd = ChanMACD(klu_list)
chan_macd_data = {
'seg_list': chan_macd.seg_list,
'unittf_list': chan_macd.unittf_list,
'histset_list': chan_macd.histset_list,
'klu_list': chan_macd.klu_list,
'high_position_list': chan_macd.high_position_list,
'high_empty_list': chan_macd.high_empty_list,
'low_position_list': getattr(chan_macd, 'low_position_list', []),
'low_empty_list': getattr(chan_macd, 'low_empty_list', []),
'return_zero_list': chan_macd.return_zero_list,
'cross0_up_list': chan_macd.cross0_up_list,
'cross0_down_list': chan_macd.cross0_down_list
}
print(f"ChanMACD分析完成: seg={len(chan_macd.seg_list)}, unittf={len(chan_macd.unittf_list)}, histset={len(chan_macd.histset_list)}")
else:
print("未能获取KLU列表或列表为空")
chan_macd_data = {
'seg_list': [],
'unittf_list': [],
'histset_list': [],
'high_position_list': [],
'high_empty_list': [],
'return_zero_list': [],
'cross0_up_list': [],
'cross0_down_list': []
}
except Exception as e:
print(f"ChanMACD分析出错: {e}")
import traceback
traceback.print_exc()
chan_macd_data = {
'seg_list': [],
'unittf_list': [],
'histset_list': [],
'high_position_list': [],
'high_empty_list': [],
'low_position_list': [],
'low_empty_list': [],
'return_zero_list': [],
'cross0_up_list': [],
'cross0_down_list': []
}
# 提取K线分型信息
klc_fx_info = []
for klc in klc_list:
if hasattr(klc, 'klc_fx_type') and klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
try:
# 计算分型强度
fx_strength = 0
fx_strength_level = ""
is_strong_fx = False
# 统一使用cal_fx_strength函数
if hasattr(klc, 'cal_fx_strength'):
fx_strength = klc.cal_fx_strength(5)
# 尝试获取分型强度等级
if hasattr(klc, 'get_fx_strength_level'):
fx_strength_level = klc.get_fx_strength_level()
# 尝试判断是否为强分型
if hasattr(klc, 'is_strong_fx'):
is_strong_fx = klc.is_strong_fx()
# 如果分型强度小于1,设为0
if fx_strength < 1:
fx_strength = 0
# KLC 分型框(起止时间+高低价):
# 仅使用 cal_fx_box 通过 display 条件后生成的 klc.fx_box。
# 若无 fx_box,则前端不应绘制分型框。
fx_box = getattr(klc, 'fx_box', None)
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
box_high = getattr(fx_box, 'high', None) if fx_box else None
box_low = getattr(fx_box, 'low', None) if fx_box else None
if klc.bb_out:
klc_fx_info.append({
'time': klc.end_time,
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
'fx_strength': fx_strength, # 分型强度分数 (0-100)
'fx_strength_level': fx_strength_level, # 分型强度等级 (极强/强/中等/弱/极弱)
'is_strong_fx': is_strong_fx, # 是否为强分型
# 虚线分型框信息(给前端画框用)
'start_time': box_start_time,
'end_time': box_end_time,
'high': float(box_high) if box_high is not None else None,
'low': float(box_low) if box_low is not None else None,
})
except Exception as e:
# 如果出错,仍然添加基本信息,但分型强度为0
fx_box = getattr(klc, 'fx_box', None)
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
box_high = getattr(fx_box, 'high', None) if fx_box else None
box_low = getattr(fx_box, 'low', None) if fx_box else None
klc_fx_info.append({
'time': klc.end_time,
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
'fx_strength': 0,
'fx_strength_level': "",
'is_strong_fx': False,
# 虚线分型框信息(给前端画框用)
'start_time': box_start_time,
'end_time': box_end_time,
'high': float(box_high) if box_high is not None else None,
'low': float(box_low) if box_low is not None else None,
})
return {
'klc_list': klc_list,
'klu_list': klu_list, # 添加KLU列表
'bi_list': bi_list,
'seg_list': seg_list,
'zs_list': zs_list,
'bi_zs_list': bi_zs_list, # 添加BI中枢列表
'bsp_list': bsp_list, # 添加买卖点列表
'klc_fx_info': klc_fx_info, # KLC分型信息
'chan_macd': chan_macd_data, # 添加ChanMACD分析数据
'ema52_dict': ema52_dict # 添加多时间周期EMA52数据
}
# 辅助函数,转换缠论方向枚举为整数
def convert_direction(direction):
"""转换方向枚举为数字"""
if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP:
return 1
elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN:
return -1
else:
return 0
def format_time_safely(time_obj, client_tz):
"""安全地格式化时间对象,处理字符串和datetime两种情况"""
if time_obj is None:
return None
if isinstance(time_obj, str):
# 尝试将字符串解析为datetime
try:
from dateutil import parser
time_obj = parser.parse(time_obj)
return time_obj.astimezone(client_tz).isoformat()
except:
return time_obj
else:
# 已经是datetime对象
return time_obj.astimezone(client_tz).isoformat()
def serialize_chan_macd_data(chan_macd_data, client_tz):
"""序列化ChanMACD数据为JSON可序列化格式"""
serialized_data = {
'seg_list': [],
'unittf_list': [],
'histset_list': [],
# 状态标记数据
'high_position_list': [],
'high_empty_list': [],
'low_position_list': [],
'low_empty_list': [],
'return_zero_list': [],
'cross0_up_list': [],
'cross0_down_list': [],
# 新增:输出KLU的继续背驰/分离背驰标志
'klu_list': []
}
# 序列化seg_list
for seg in chan_macd_data.get('seg_list', []):
try:
seg_data = {
'start_time': format_time_safely(seg.start_time, client_tz),
'end_time': format_time_safely(seg.end_time, client_tz) if seg.end_time else None,
'seg_dir': 'ABOVE' if seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else 'UNDER',
'klu_count': len(seg.klu_list) if hasattr(seg, 'klu_list') else 0,
'unittf_count': len(seg.unittf_list) if hasattr(seg, 'unittf_list') else 0,
'histset_count': len(seg.hist_set) if hasattr(seg, 'hist_set') else 0
}
serialized_data['seg_list'].append(seg_data)
except Exception as e:
print(f"序列化seg出错: {e}")
continue
# 序列化unittf_list(兼容新结构与枚举类型)
for unittf in chan_macd_data.get('unittf_list', []):
try:
dir_value = getattr(unittf, 'uinttf_dir', None)
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
start_t = getattr(unittf, 'start_type', None)
start_type = getattr(start_t, 'name', start_t)
end_t = getattr(unittf, 'end_type', None)
end_type = getattr(end_t, 'name', end_t)
peak_abs = getattr(unittf, 'peak_abs', None)
if peak_abs is None:
peak_abs = getattr(unittf, 'peak_hist', None)
length = getattr(unittf, 'length', None)
if length is None:
length = len(unittf.klu_list) if hasattr(unittf, 'klu_list') else None
unittf_data = {
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
'end_type': end_type,
'invalid': getattr(unittf, 'invalid', False),
'peak_abs': peak_abs,
'length': length,
'klu_count': len(unittf.klu_list) if hasattr(unittf, 'klu_list') else 0,
'histset_count': len(unittf.histset_list) if hasattr(unittf, 'histset_list') else 0
}
serialized_data['unittf_list'].append(unittf_data)
except Exception as e:
print(f"序列化unittf出错: {e}")
continue
# 序列化histset_list
for histset in chan_macd_data.get('histset_list', []):
try:
histset_data = {
'start_time': format_time_safely(getattr(histset, 'start_time', None), client_tz),
'end_time': format_time_safely(getattr(histset, 'end_time', None), client_tz),
'histset_dir': 'ABOVE' if histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE else 'UNDER',
'klu_count': len(histset.klu_list) if hasattr(histset, 'klu_list') else 0
}
serialized_data['histset_list'].append(histset_data)
except Exception as e:
print(f"序列化histset出错: {e}")
continue
# 序列化状态标记数据
# 序列化高位列表
for high_pos in chan_macd_data.get('high_position_list', []):
try:
high_pos_data = {
'time': format_time_safely(high_pos['time'], client_tz),
'end_time': format_time_safely(high_pos.get('end_time'), client_tz) if high_pos.get('end_time') else None,
'type': high_pos.get('type', 'start'),
'macd': high_pos.get('macd'),
'signal': high_pos.get('signal'),
'macdhist': high_pos.get('macdhist'),
'end_macd': high_pos.get('end_macd'),
'end_signal': high_pos.get('end_signal'),
'end_macdhist': high_pos.get('end_macdhist')
}
serialized_data['high_position_list'].append(high_pos_data)
except Exception as e:
print(f"序列化high_position出错: {e}")
continue
# 序列化高位空列表
for high_empty in chan_macd_data.get('high_empty_list', []):
try:
high_empty_data = {
'time': format_time_safely(high_empty['time'], client_tz),
'end_time': format_time_safely(high_empty.get('end_time'), client_tz) if high_empty.get('end_time') else None,
'type': high_empty.get('type', 'start'),
'macd': high_empty.get('macd'),
'signal': high_empty.get('signal'),
'macdhist': high_empty.get('macdhist'),
'end_macd': high_empty.get('end_macd'),
'end_signal': high_empty.get('end_signal'),
'end_macdhist': high_empty.get('end_macdhist')
}
serialized_data['high_empty_list'].append(high_empty_data)
except Exception as e:
print(f"序列化high_empty出错: {e}")
continue
# 序列化低位与低位空
for low_pos in chan_macd_data.get('low_position_list', []):
try:
low_pos_data = {
'time': format_time_safely(low_pos['time'], client_tz),
'end_time': format_time_safely(low_pos.get('end_time'), client_tz) if low_pos.get('end_time') else None,
'type': low_pos.get('type', 'start'),
'macd': low_pos.get('macd'),
'signal': low_pos.get('signal'),
'macdhist': low_pos.get('macdhist'),
'end_macd': low_pos.get('end_macd'),
'end_signal': low_pos.get('end_signal'),
'end_macdhist': low_pos.get('end_macdhist')
}
serialized_data['low_position_list'].append(low_pos_data)
except Exception as e:
print(f"序列化low_position出错: {e}")
continue
for low_empty in chan_macd_data.get('low_empty_list', []):
try:
low_empty_data = {
'time': format_time_safely(low_empty['time'], client_tz),
'end_time': format_time_safely(low_empty.get('end_time'), client_tz) if low_empty.get('end_time') else None,
'type': low_empty.get('type', 'start'),
'macd': low_empty.get('macd'),
'signal': low_empty.get('signal'),
'macdhist': low_empty.get('macdhist'),
'end_macd': low_empty.get('end_macd'),
'end_signal': low_empty.get('end_signal'),
'end_macdhist': low_empty.get('end_macdhist')
}
serialized_data['low_empty_list'].append(low_empty_data)
except Exception as e:
print(f"序列化low_empty出错: {e}")
continue
# 序列化归零轴列表
for return_zero in chan_macd_data.get('return_zero_list', []):
try:
return_zero_data = {
'time': format_time_safely(return_zero['time'], client_tz),
'end_time': format_time_safely(return_zero.get('end_time'), client_tz) if return_zero.get('end_time') else None,
'type': return_zero.get('type', 'start'),
'macd': return_zero.get('macd'),
'signal': return_zero.get('signal'),
'macdhist': return_zero.get('macdhist'),
'end_macd': return_zero.get('end_macd'),
'end_signal': return_zero.get('end_signal'),
'end_macdhist': return_zero.get('end_macdhist')
}
serialized_data['return_zero_list'].append(return_zero_data)
except Exception as e:
print(f"序列化return_zero出错: {e}")
continue
# 序列化穿越零轴列表
for cross0_up in chan_macd_data.get('cross0_up_list', []):
try:
cross0_up_data = {
'time': format_time_safely(cross0_up['time'], client_tz),
'type': cross0_up.get('type', 'start'),
'macd': cross0_up.get('macd'),
'signal': cross0_up.get('signal'),
'macdhist': cross0_up.get('macdhist')
}
serialized_data['cross0_up_list'].append(cross0_up_data)
except Exception as e:
print(f"序列化cross0_up出错: {e}")
continue
for cross0_down in chan_macd_data.get('cross0_down_list', []):
try:
cross0_down_data = {
'time': format_time_safely(cross0_down['time'], client_tz),
'type': cross0_down.get('type', 'start'),
'macd': cross0_down.get('macd'),
'signal': cross0_down.get('signal'),
'macdhist': cross0_down.get('macdhist')
}
serialized_data['cross0_down_list'].append(cross0_down_data)
except Exception as e:
print(f"序列化cross0_down出错: {e}")
continue
# 序列化 KLU 列表(仅导出需要的时间与背驰标志)
for klu in chan_macd_data.get('klu_list', []):
try:
serialized_data['klu_list'].append({
'time': format_time_safely(getattr(klu, 'time', None), client_tz),
'continue_div': bool(getattr(klu, 'continue_div', False)),
'separate_div': int(getattr(klu, 'separate_div', 0)) if getattr(klu, 'separate_div', 0) is not None else 0,
'near0_return': int(getattr(klu, 'near0_return', 0)) if getattr(klu, 'near0_return', 0) is not None else 0
})
except Exception as e:
print(f"序列化klu出错: {e}")
continue
return serialized_data
def is_smaller_timeframe(tf1, tf2):
"""判断时间周期tf1是否小于tf2"""
tf1_value = timeframe_to_minutes(tf1)
tf2_value = timeframe_to_minutes(tf2)
if tf1_value is None or tf2_value is None:
return False
return tf1_value < tf2_value
def is_smaller_or_equal_timeframe(tf1, tf2):
"""判断时间周期tf1是否小于等于tf2"""
tf1_value = timeframe_to_minutes(tf1)
tf2_value = timeframe_to_minutes(tf2)
if tf1_value is None or tf2_value is None:
return False
return tf1_value <= tf2_value
def clean_dataframe_for_json(df):
"""清理DataFrame数据用于JSON序列化"""
# 创建副本避免修改原始数据
clean_df = df.copy()
# 替换NaN值为None
clean_df = clean_df.where(pd.notnull(clean_df), None)
return clean_df
# ====== 趋势判定与趋势筛选(币对) ======
def classify_trend_stage(df):
"""根据 EMA 斜率与多空排列判断趋势方向与阶段
返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100)
"""
if df is None or len(df) < 60:
return "sideways", "early", 0
# 使用 EMA5/10/24/52
closes = df['close'].values
ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5)
ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10)
ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24)
ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52)
# 最近N根用于斜率与排列判定
lookback = min(30, len(df) - 1)
if lookback <= 5:
return "sideways", "early", 0
# 简单斜率: 最近k根的线性变化率近似
def slope(arr, k=10):
k = min(k, len(arr) - 1)
if k < 2:
return 0.0
y = arr[-k:]
x = np.arange(k)
# 最小二乘拟合斜率
denom = np.dot(x - x.mean(), x - x.mean())
if denom == 0:
return 0.0
m = np.dot(y - y.mean(), x - x.mean()) / denom
return float(m)
k_slope = 12 # 斜率窗口
s5 = slope(ema5, k_slope)
s10 = slope(ema10, k_slope)
s24 = slope(ema24, k_slope)
s52 = slope(ema52, k_slope)
# 多空排列
last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1]
bull_stack = last5 > last10 > last24 > last52
bear_stack = last5 < last10 < last24 < last52
# 波动性与动量增强: MACD 柱体最近均值
macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram']
hist_recent = macdhist[-lookback:]
hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0
# 方向
if bull_stack and s24 > 0 and s52 > 0:
direction = "bull"
elif bear_stack and s24 < 0 and s52 < 0:
direction = "bear"
else:
# 用价格相对 EMA52 辅助
if closes[-1] > last52 and (s24 + s52) > 0:
direction = "bull"
elif closes[-1] < last52 and (s24 + s52) < 0:
direction = "bear"
else:
direction = "sideways"
# 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛)
dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0
slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化
dist_score = min(50.0, abs(dist52) * 200.0)
hist_score = min(30.0, hist_power * 10.0)
strength = float(min(100.0, slope_score + dist_score + hist_score))
# 简单阶段判定
if direction == "sideways":
stage = "early"
strength = min(strength, 30.0)
else:
# 查看最近 hist 是否在扩大或收敛
if len(hist_recent) >= 6:
recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3]))
else:
recent_growth = 0.0
if recent_growth > 0 and abs(dist52) < 0.05:
stage = "early"
elif recent_growth > 0 and abs(dist52) >= 0.05:
stage = "mid"
else:
stage = "late"
return direction, stage, strength
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 DEFAULT_SYMBOLS[:limit]
@app.route('/api/trend_filter', methods=['GET'])
def trend_filter():
"""趋势筛选接口(币对)
参数:
timeframe: K线周期
start_time, end_time: 毫秒时间戳,可选
direction: bull/bear/sideways 可选
stage: early/mid/late 可选
min_strength: 0-100 可选
symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对
返回符合条件的币对与简要统计
"""
timeframe = request.args.get('timeframe', '1h')
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
want_direction = request.args.get('direction') # 可为 None
want_stage = request.args.get('stage') # 可为 None
try:
min_strength = float(request.args.get('min_strength', '0'))
except ValueError:
min_strength = 0.0
symbols_param = request.args.get('symbols')
if symbols_param:
symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()]
else:
symbols_list = load_crypto_symbols(limit=150)
results = []
for sym in symbols_list:
try:
df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time)
if df is None or len(df) < 60:
continue
df = add_indicators(df)
direction, stage, strength = classify_trend_stage(df)
if want_direction and direction != want_direction:
continue
if want_stage and stage != want_stage:
continue
if strength < min_strength:
continue
last_row = df.iloc[-1]
results.append({
'symbol': sym,
'time': int(last_row['timestamp']),
'close': float(last_row['close']),
'direction': direction,
'stage': stage,
'strength': float(round(strength, 2)),
'ema5': float(last_row['ema5']),
'ema10': float(last_row['ema10']),
'ema24': float(last_row['ema24']),
'ema52': float(last_row['ema52'])
})
except Exception:
continue
# 按强度降序
results.sort(key=lambda x: x['strength'], reverse=True)
return jsonify({
'count': len(results),
'results': results
})
@app.route('/api/trend_detail', methods=['GET'])
def trend_detail():
"""返回单个币对的K线与EMA、用于前端绘制趋势线
参数: symbol, timeframe, start_time, end_time
"""
symbol = request.args.get('symbol')
timeframe = request.args.get('timeframe', '1h')
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
timezone_name = request.args.get('timezone', 'Asia/Shanghai')
if not symbol:
return jsonify({'error': 'symbol不能为空'})
df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
if df is None or len(df) == 0:
return jsonify({'error': '获取数据失败'})
df = add_indicators(df)
direction, stage, strength = classify_trend_stage(df)
# 简单趋势线: 用最近N根收盘价做线性拟合
N = min(80, len(df))
sub = df.tail(N)
y = sub['close'].values
x = np.arange(len(y))
denom = np.dot(x - x.mean(), x - x.mean())
if denom != 0:
m = float(np.dot(y - y.mean(), x - x.mean()) / denom)
b = float(y.mean() - m * x.mean())
else:
m, b = 0.0, float(y[-1])
client_tz = timezone(timezone_name)
return jsonify({
'symbol': symbol,
'timeframe': timeframe,
'timezone': timezone_name,
'direction': direction,
'stage': stage,
'strength': float(round(strength, 2)),
'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'),
'trend_line': {
'offset': int(df.index[-N]),
'slope': m,
'intercept': b,
'length': int(N)
}
})
@app.route('/chan_tv')
def chan_tv():
"""缠论 TradingView 高级图表页面"""
return render_template('chan_tv.html')
@app.route('/charting_library/<path:filename>')
def serve_charting_library(filename):
"""提供 TradingView Charting Library 静态文件"""
return send_from_directory('charting_library', filename)
@app.route('/')
def index():
"""主页"""
refresh_data_service_metadata()
tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()
default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map))
symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS
default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '')
return render_template(
'index.html',
timeframes=tf_map,
symbols=symbols,
a_stock_symbols=A_STOCK_SYMBOLS,
default_main_timeframe=default_main,
default_element_timeframe=default_element,
default_sub_sub_timeframe=default_sub_sub,
default_symbol=default_symbol,
timeframe_keys_json=json.dumps(timeframe_keys),
data_service_available=DATA_SERVICE_AVAILABLE,
)
@app.route('/api/chart_metadata')
def api_chart_metadata():
"""
按数据源返回图表用 K 线周期(中文标签)及主/次/次次默认周期。
crypto:强制刷新 DATA_SERVICE_URL /health 元信息;
a_stock:读取 ASHARE_DP_URL 的 /api/v1/klines/available-freqs,不修改全局加密货币 TIMEFRAMES。
"""
source = (request.args.get('source') or 'crypto').strip().lower()
if source not in ('crypto', 'a_stock'):
source = 'crypto'
try:
if source == 'a_stock':
raw = china_stock.get_available_kline_freqs()
labels_od = build_timeframe_labels(raw)
else:
refresh_data_service_metadata(force=True)
labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy())
default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od)
return jsonify({
'source': source,
'timeframes': {k: v for k, v in labels_od.items()},
'timeframe_keys': keys,
'default_main': default_main,
'default_element': default_element,
'default_sub_sub': default_sub_sub,
})
except Exception as exc:
logger.exception('chart_metadata 失败: %s', exc)
return jsonify({'error': str(exc)}), 500
@app.route('/api/analyze')
def analyze():
"""分析接口"""
symbol = request.args.get('symbol', 'SOL/USDT:USDT')
timeframe = request.args.get('timeframe', '5m')
# 验证交易对不为空
if not symbol or symbol.strip() == '':
return jsonify({'error': '交易对不能为空'})
# 获取时间范围参数
start_time = request.args.get('start_time')
end_time = request.args.get('end_time')
# 获取客户端请求的时区
client_timezone = request.args.get('timezone', 'Asia/Shanghai')
# 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_timeframe')
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
# 获取是否只需要分形元素数据的参数
elements_only_param = request.args.get('elements_only')
elements_only = elements_only_param == 'true'
# 验证小周期是否小于主周期
if element_timeframe and not is_smaller_or_equal_timeframe(element_timeframe, timeframe):
return jsonify({'error': '分形元素时间周期必须小于或等于主图表时间周期'})
# 验证次次周期是否小于等于次周期
if sub_sub_timeframe and element_timeframe and not is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe):
return jsonify({'error': '次次周期必须小于或等于次周期'})
# 获取数据
df = get_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
if df is None:
return jsonify({'error': '获取数据失败'})
if len(df) == 0:
return jsonify({'error': '所选时间范围内没有数据'})
# 使用客户端指定的时区
client_tz = timezone(client_timezone)
# 如果只需要分形元素数据而不需要主周期数据,则初始化一个空结果
result = {
'timezone': client_timezone
}
# 如果不是只需要分形元素数据,则添加主周期数据
if not elements_only:
# 添加技术指标(包括布林带)
df = add_indicators(df)
# 进行缠论分析
analysis_result = analyze_chan(df, symbol, timeframe)
# 计算MACD
macd_data = calculate_macd(df)
# 基于已有 KLC 列表生成趋势标记(不做额外计算)
klc_trend = []
try:
for klc in analysis_result.get('klc_list', []):
trend_val = getattr(klc, 'trend', None)
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
if trend_val is None or t_obj is None:
continue
# 统一成字符串:UP/DOWN/FLAT/UNKNOWN
trend_name = str(trend_val)
if '.' in trend_name:
trend_name = trend_name.split('.')[-1]
time_str = format_time_safely(t_obj, client_tz)
if time_str:
klc_trend.append({'time': time_str, 'trend': trend_name})
except Exception:
klc_trend = []
# 添加主周期分析结果到返回数据
result.update({
'kline_data': clean_dataframe_for_json(df).to_dict('records'),
'klc_list': [{
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
'open': float(klc.open),
'high': float(klc.high),
'low': float(klc.low),
'close': float(klc.close),
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
} for klc in analysis_result['klc_list'] if hasattr(klc, 'end_time') and klc.end_time],
'bi_list': [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None,
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in analysis_result['bi_list'] if bi.is_sure],
# 添加未完成笔列表
'uncompleted_bi_list': [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': bi.end_time, # 未完成笔没有结束时间
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in analysis_result['bi_list'] if not bi.is_sure],
'seg_list': [{
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
} for seg in analysis_result['seg_list'] if seg.is_sure],
# 添加未完成线段列表
'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz),
'zs_list': [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': zs.is_sure # 添加中枢是否完成的标志
} for zs in analysis_result['zs_list'] if zs.is_sure],
# 添加主周期BI中枢列表(已完成)
'bi_zs_list': [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in analysis_result.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)],
# 添加未完成中枢列表
'uncompleted_zs_list': [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': None, # 未完成中枢没有结束时间
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': zs.is_sure # 未完成中枢的is_sure为False
} for zs in analysis_result['zs_list'] if not zs.is_sure],
# 添加未完成BI中枢列表
'uncompleted_bi_zs_list': [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in analysis_result.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)],
'macd': macd_data,
# 添加布林带数据
'bollinger': {
'upper': df['bb_upper'].tolist(),
'middle': df['bb_middle'].tolist(),
'lower': df['bb_lower'].tolist()
},
'element_bollinger': {
'upper': df['element_bb_upper'].tolist(),
'middle': df['element_bb_middle'].tolist(),
'lower': df['element_bb_lower'].tolist()
},
# 添加ATR数据
'atr': df['atr'].tolist(),
# 添加K线分型信息
'klc_fx_info': [{
'time': format_time_safely(point['time'], client_tz),
'start_time': format_time_safely(point['start_time'], client_tz),
'end_time': format_time_safely(point['end_time'], client_tz),
'price': float(point['price']),
'fx_type': point['fx_type'],
'is_bottom': bool(point['is_bottom']),
'fx_strength': float(point['fx_strength']), # 分型强度分数
'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级
'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型
# 分型框(虚线矩形)用到的高低价
'high': float(point['high']) if point.get('high') is not None else None,
'low': float(point['low']) if point.get('low') is not None else None
} for point in analysis_result['klc_fx_info']],
# 添加ChanMACD分析数据
'chan_macd': serialize_chan_macd_data(analysis_result.get('chan_macd', {}), client_tz),
# 添加多时间周期EMA52数据
'ema52_dict': analysis_result.get('ema52_dict', {}),
# 直接输出KLC趋势标记(使用已有trend字段)
'klc_trend': klc_trend,
# 添加主周期买卖点列表
# 注意:部分枚举在转为字符串时可能形如 "Chan_BSP_TYPE.BSP1(1)"
# 这里进行健壮的解析,确保前端拿到的始终是 "BSP1" / "BUY" 这种简洁形式,
# 以便与前端的 BSP_STYLE 键(如 "BSP1_BUY")正确匹配。
'bsp_list': [{
'time': format_time_safely(bsp.end_time, client_tz),
'price': float(bsp.klc.low if 'BUY' in str(bsp.dir) else bsp.klc.high),
# -- 规范化 type 名称,例如:
# "Chan_BSP_TYPE.BSP1" -> "BSP1"
# "Chan_BSP_TYPE.BSP1(1)" -> "BSP1"
# "BSP1" -> "BSP1"
'type': (
lambda raw: (
(raw.split('.')[-1] if '.' in raw else raw).split('(')[0]
)
)(str(bsp.type)),
# -- 规范化 dir 名称,例如:
# "Chan_BSP_DIR.BUY" -> "BUY"
# "Chan_BSP_DIR.BUY(1)" -> "BUY"
# "BUY" -> "BUY"
'dir': (
lambda raw: (
(raw.split('.')[-1] if '.' in raw else raw).split('(')[0]
)
)(str(bsp.dir)),
'is_sure': bool(bsp.is_sure),
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
} for bsp in analysis_result.get('bsp_list', [])]
})
# 如果有指定分形元素时间周期,获取小周期数据
if element_timeframe:
# 获取小周期数据,使用与主周期相同的时间范围
element_df = get_kl_data(symbol, element_timeframe, start_time=start_time, end_time=end_time)
if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df)
# 对小周期数据进行缠论分析
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
# 计算小周期MACD数据
element_macd_data = calculate_macd(element_df)
# 组装小周期 KLC 趋势(仅提取已有 trend,不做重算)
try:
element_klc_trend = []
for klc in element_analysis.get('klc_list', []):
trend_val = getattr(klc, 'trend', None)
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
if trend_val is None or t_obj is None:
continue
trend_name = str(trend_val)
if '.' in trend_name:
trend_name = trend_name.split('.')[-1]
time_str = format_time_safely(t_obj, client_tz)
if time_str:
element_klc_trend.append({'time': time_str, 'trend': trend_name})
except Exception:
element_klc_trend = []
# 添加小周期分析结果到返回数据
result['element_timeframe'] = element_timeframe
result['element_macd'] = element_macd_data # 添加小周期MACD数据
# 添加小周期布林带数据
result['element_bollinger'] = {
'upper': element_df['bb_upper'].tolist(),
'middle': element_df['bb_middle'].tolist(),
'lower': element_df['bb_lower'].tolist()
}
result['element_element_bollinger'] = {
'upper': element_df['element_bb_upper'].tolist(),
'middle': element_df['element_bb_middle'].tolist(),
'lower': element_df['element_bb_lower'].tolist()
}
# 添加小周期ATR数据
result['element_atr'] = element_df['atr'].tolist()
result['element_bi_list'] = [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None,
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in element_analysis['bi_list'] if bi.is_sure]
# 添加次周期未完成笔列表
result['element_uncompleted_bi_list'] = [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': bi.end_time, # 未完成笔没有结束时间
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in element_analysis['bi_list'] if not bi.is_sure]
# 添加小周期K线数据
result['element_kline_data'] = clean_dataframe_for_json(element_df).to_dict('records')
# 添加小周期KLC列表
result['element_klc_list'] = [{
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
'open': float(klc.open),
'high': float(klc.high),
'low': float(klc.low),
'close': float(klc.close),
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
} for klc in element_analysis['klc_list'] if hasattr(klc, 'end_time') and klc.end_time]
result['element_seg_list'] = [{
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
} for seg in element_analysis['seg_list'] if seg.is_sure]
# 添加次周期未完成线段列表
result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz)
result['element_zs_list'] = [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': zs.is_sure # 添加中枢是否完成的标志
} for zs in element_analysis['zs_list'] if zs.end_klc]
result['element_uncompleted_zs_list'] = [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': None, # 未完成中枢没有结束时间
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': zs.is_sure # 未完成中枢的is_sure为False
} for zs in element_analysis['zs_list'] if not zs.is_sure]
# 添加次周期 BI 中枢(已完成/未完成)
result['element_bi_zs_list'] = [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in element_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
result['element_uncompleted_bi_zs_list'] = [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': None,
'zg': zs.zg,
'zd': zs.zd,
'gg': zs.gg,
'dd': zs.dd,
'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in element_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)]
# 添加小周期分型信息
result['element_klc_fx_info'] = [{
'time': format_time_safely(point['time'], client_tz),
'start_time': format_time_safely(point['start_time'], client_tz),
'end_time': format_time_safely(point['end_time'], client_tz),
'price': float(point['price']),
'fx_type': point['fx_type'],
'is_bottom': bool(point['is_bottom']),
'fx_strength': float(point['fx_strength']), # 分型强度分数
'fx_strength_level': str(point['fx_strength_level']), # 分型强度等级
'is_strong_fx': bool(point['is_strong_fx']), # 是否为强分型
# 分型框(虚线矩形)用到的高低价
'high': float(point['high']) if point.get('high') is not None else None,
'low': float(point['low']) if point.get('low') is not None else None
} for point in element_analysis['klc_fx_info']]
# 添加次周期ChanMACD分析数据
result['element_chan_macd'] = serialize_chan_macd_data(element_analysis.get('chan_macd', {}), client_tz)
# 添加小周期 KLC 趋势标记
result['element_klc_trend'] = element_klc_trend
# 添加次周期买卖点列表
result['element_bsp_list'] = [{
'time': format_time_safely(bsp.end_time, client_tz),
'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high),
'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''),
'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''),
'is_sure': bool(bsp.is_sure),
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
} for bsp in element_analysis.get('bsp_list', [])]
# 次次周期:仅当已指定次周期且次次周期有效时获取
if sub_sub_timeframe and is_smaller_or_equal_timeframe(sub_sub_timeframe, element_timeframe):
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
if sub_sub_df is not None and len(sub_sub_df) > 0:
sub_sub_df = add_indicators(sub_sub_df)
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
result['sub_sub_timeframe'] = sub_sub_timeframe
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
result['sub_sub_atr'] = sub_sub_df['atr'].tolist()
result['sub_sub_macd'] = calculate_macd(sub_sub_df)
result['sub_sub_bi_list'] = [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (bi.end_klc.end_time if isinstance(bi.end_klc.end_time, str) else bi.end_klc.end_time.astimezone(client_tz).isoformat()) if bi.end_klc else None,
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in sub_sub_analysis['bi_list'] if bi.is_sure]
result['sub_sub_uncompleted_bi_list'] = [{
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': bi.end_time,
'sure_time': format_time_safely(bi.sure_time, client_tz) if bi.sure_time else None,
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high,
'direction': convert_direction(bi.dir),
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
} for bi in sub_sub_analysis['bi_list'] if not bi.is_sure]
# 次次周期 KLC 列表
result['sub_sub_klc_list'] = [{
'date': klc.end_time if isinstance(klc.end_time, str) else klc.end_time.astimezone(client_tz).isoformat(),
'open': float(klc.open),
'high': float(klc.high),
'low': float(klc.low),
'close': float(klc.close),
'volume': float(klc.volume) if hasattr(klc, 'volume') else 0,
'direction': str(klc.dir).replace('Chan_KLINE_DIR.', ''),
'fx_type': str(klc.fx).replace('Chan_FX_TYPE.', ''),
'klc_fx_type': str(klc.klc_fx_type).replace('Chan_KLC_FX.', ''),
'trend': str(klc.trend).replace('Chan_PRICE_TREND.', '')
} for klc in sub_sub_analysis.get('klc_list', []) if hasattr(klc, 'end_time') and klc.end_time]
result['sub_sub_seg_list'] = [{
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()) if seg.end_bi else None,
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
'direction': convert_direction(seg.dir)
} for seg in sub_sub_analysis['seg_list'] if seg.is_sure]
result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz)
result['sub_sub_zs_list'] = [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure
} for zs in sub_sub_analysis['zs_list'] if zs.end_klc]
result['sub_sub_uncompleted_zs_list'] = [{
'start_time': zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat(),
'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': zs.is_sure
} for zs in sub_sub_analysis['zs_list'] if not zs.is_sure]
result['sub_sub_bi_zs_list'] = [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in sub_sub_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
result['sub_sub_uncompleted_bi_zs_list'] = [{
'start_time': (
(zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat())
if getattr(zs.start_klc, 'end_time', None) else
(zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())
),
'end_time': None, 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, 'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in sub_sub_analysis.get('bi_zs_list', []) if not getattr(zs, 'is_sure', False)]
result['sub_sub_klc_fx_info'] = [{
'time': format_time_safely(point['time'], client_tz),
'start_time': format_time_safely(point['start_time'], client_tz),
'end_time': format_time_safely(point['end_time'], client_tz),
'price': float(point['price']),
'fx_type': point['fx_type'],
'is_bottom': bool(point['is_bottom']),
'fx_strength': float(point['fx_strength']),
'fx_strength_level': str(point['fx_strength_level']),
'is_strong_fx': bool(point['is_strong_fx']),
# 分型框(虚线矩形)用到的高低价
'high': float(point['high']) if point.get('high') is not None else None,
'low': float(point['low']) if point.get('low') is not None else None
} for point in sub_sub_analysis['klc_fx_info']]
result['sub_sub_bsp_list'] = [{
'time': format_time_safely(bsp.end_time, client_tz),
'price': float(bsp.klc.low if str(bsp.dir) == 'Chan_BSP_DIR.BUY' else bsp.klc.high),
'type': str(bsp.type).replace('Chan_BSP_TYPE.', ''),
'dir': str(bsp.dir).replace('Chan_BSP_DIR.', ''),
'is_sure': bool(bsp.is_sure),
'sure_time': format_time_safely(bsp.sure_time, client_tz) if bsp.sure_time else None,
'zs_count': int(bsp.zs_count) if hasattr(bsp, 'zs_count') else 0
} for bsp in sub_sub_analysis.get('bsp_list', [])]
result['sub_sub_chan_macd'] = serialize_chan_macd_data(sub_sub_analysis.get('chan_macd', {}), client_tz)
try:
sub_sub_klc_trend = []
for klc in sub_sub_analysis.get('klc_list', []):
trend_val = getattr(klc, 'trend', None)
t_obj = getattr(klc, 'end_time', None) or getattr(klc, 'start_time', None)
if trend_val is None or t_obj is None:
continue
trend_name = str(trend_val)
if '.' in trend_name:
trend_name = trend_name.split('.')[-1]
time_str = format_time_safely(t_obj, client_tz)
if time_str:
sub_sub_klc_trend.append({'time': time_str, 'trend': trend_name})
result['sub_sub_klc_trend'] = sub_sub_klc_trend
except Exception:
result['sub_sub_klc_trend'] = []
pass
# 结构价值区分析(Structure Zone)—— 按需拉取:仅当 include_structure_zones 为真时执行多周期拉取(默认跳过以减轻负载)
include_zones_param = request.args.get('include_structure_zones', '')
include_structure_zones = str(include_zones_param).lower() in ('1', 'true', 'yes')
if include_structure_zones:
zone_timeframes_str = request.args.get('zone_timeframes', '')
zone_kl_lines = int(request.args.get('zone_kl_lines', 1000))
try:
zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines)
if zone_timeframes_str:
zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()]
analyses = {}
ema52_dict = {}
latest_close = 0.0
now = time.time()
def _fetch_single_tf_zone(tf_name):
"""单个时间周期的结构区数据拉取(线程安全)"""
cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}"
cached = _zone_cache.get(cache_key)
if cached and cached['expires'] > now:
print(f" 结构区缓存命中: {tf_name}")
return {
'tf_name': tf_name,
'analyses': cached['analyses'],
'ema52': cached['ema52'],
'close': cached.get('close', 0.0),
'cached': True,
}
try:
tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines)
if tf_df is None or len(tf_df) == 0:
return None
tf_df = add_indicators(tf_df)
tf_analysis = analyze_chan(tf_df, symbol, tf_name)
zs_serialized = [{
'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None,
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
'is_sure': zs.is_sure
} for zs in tf_analysis.get('zs_list', []) if zs.is_sure]
bi_zs_serialized = [{
'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())),
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
'is_sure': bool(getattr(zs, 'is_sure', False))
} for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0
ema_val = float(last_ema) if last_ema and last_ema > 0 else None
last_close = float(tf_df['close'].iloc[-1])
tf_result = {
'tf_name': tf_name,
'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized},
'ema52': ema_val,
'close': last_close,
'cached': False,
}
# 写入缓存
_zone_cache[cache_key] = {
'analyses': tf_result['analyses'],
'ema52': ema_val,
'close': last_close,
'expires': now + _zone_cache_ttl(tf_name),
}
print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}")
return tf_result
except Exception as e:
print(f" 结构区 {tf_name} 拉取失败: {e}")
return None
with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor:
futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes}
for future in as_completed(futures):
tf_result = future.result()
if tf_result is None:
continue
tf_name = tf_result['tf_name']
analyses[tf_name] = tf_result['analyses']
ema52_dict[tf_name] = tf_result['ema52']
if tf_result['close'] and (not latest_close or latest_close == 0.0):
latest_close = tf_result['close']
structure_zones = analyze_structure_zones_from_serialized(
analyses, ema52_dict, latest_close, config=zone_config
)
result['structure_zones'] = [{
'id': z.id,
'lower': z.lower,
'upper': z.upper,
'center': z.center,
'width_pct': z.width_pct,
'zone_type': z.zone_type,
'timeframes': z.timeframes,
'structure_types': z.structure_types,
'boundary_types': z.boundary_types,
'overlap_count': z.overlap_count,
'touch_count': z.touch_count,
'recency_score': z.recency_score,
'ema52_distance_pct': z.ema52_distance_pct,
'ema52_aligned': z.ema52_aligned,
'strength_score': z.strength_score,
'confidence': z.confidence,
'first_seen': z.first_seen,
'last_seen': z.last_seen,
'metadata': z.metadata,
} for z in structure_zones]
except Exception as e:
print(f"StructureZone 分析出错: {e}")
import traceback
traceback.print_exc()
result['structure_zones'] = []
else:
result['structure_zones'] = []
return jsonify(result)
@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(DEFAULT_SYMBOLS)
@app.route('/api/a_stocks')
def get_a_stocks():
"""获取A股股票列表"""
try:
stock_list = china_stock.get_stock_list()
return jsonify(stock_list)
except Exception as e:
return jsonify({'error': str(e)})
@app.route('/api/popular_a_stocks')
def get_popular_a_stocks():
"""获取热门A股股票"""
try:
return jsonify(china_stock.get_popular_stocks())
except Exception as e:
return jsonify({'error': str(e)})
@app.route('/api/sectors')
def get_sectors():
"""获取所有行业分类"""
try:
sectors = china_stock.get_all_sectors()
return jsonify(sectors)
except Exception as e:
return jsonify({'error': str(e)})
@app.route('/api/stocks_by_sector')
def get_stocks_by_sector():
"""根据行业获取股票"""
try:
sector = request.args.get('sector')
if sector:
stocks = china_stock.get_stock_by_sector(sector)
return jsonify(stocks)
else:
# 返回所有行业的股票分组
all_sectors = china_stock.get_stock_by_sector()
return jsonify(all_sectors)
except Exception as e:
return jsonify({'error': str(e)})
@app.route('/api/search_stock')
def search_stock():
"""搜索股票 - 增强版"""
try:
keyword = request.args.get('keyword', '')
if not keyword:
return jsonify({'error': '搜索关键词不能为空'})
results = china_stock.search_stock(keyword)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)})
@app.route('/api/macd_config', methods=['GET', 'POST'])
def macd_config():
"""获取或设置MACD参数"""
global macd_fast_period, macd_slow_period, macd_signal_period
if request.method == 'GET':
return jsonify({
'fast': macd_fast_period,
'slow': macd_slow_period,
'signal': macd_signal_period
})
else:
data = request.get_json(silent=True) or {}
fast = data.get('fast')
slow = data.get('slow')
signal = data.get('signal')
if fast is not None:
macd_fast_period = int(fast)
if slow is not None:
macd_slow_period = int(slow)
if signal is not None:
macd_signal_period = int(signal)
return jsonify({
'fast': macd_fast_period,
'slow': macd_slow_period,
'signal': macd_signal_period
})
def get_uncompleted_seg_list(seg_list, client_tz):
"""获取未完成线段列表,正确处理倒数第二个和最后一个未完成线段"""
uncompleted_segs = [seg for seg in seg_list if not seg.is_sure]
if len(uncompleted_segs) == 0:
return []
result = []
for i, seg in enumerate(uncompleted_segs):
is_last = (i == len(uncompleted_segs) - 1) # 是否为最后一个未完成线段
seg_data = {
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
'direction': convert_direction(seg.dir)
}
if is_last:
# 最后一个未完成线段:没有结束时间和价格
seg_data['end_time'] = None
seg_data['end_price'] = None
else:
# 倒数第二个及之前的未完成线段:使用实际的结束时间和价格
if seg.end_bi and seg.end_bi.end_klc:
seg_data['end_time'] = seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()
seg_data['end_price'] = seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low
else:
# 如果没有结束笔,设为None
seg_data['end_time'] = None
seg_data['end_price'] = None
result.append(seg_data)
return result
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=8128)