refactor: 缠论引擎包化与 Web 分层(ECR-001)

将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
from __future__ import annotations
"""
使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。
横盘判定基于以下三项指标(均可通过命令行参数调整):
1. 价格振幅占均价的比例(默认 ≤ 5%
2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%
3. 收盘价标准差占均价的比例(默认 ≤ 1.5%
满足以上全部条件的交易对会被视为长期横盘。
"""
import argparse
import csv
import logging
import math
import statistics
import sys
import time
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import ccxt
# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015
DEFAULT_LIMIT = 100
DEFAULT_TIMEFRAME = "1h"
STABLECOINS = {
"USDT",
"USDC",
"BUSD",
"TUSD",
"USDP",
"DAI",
"FDUSD",
"SUSD",
"UST",
"USTC",
"EUR",
"TRY",
"BFUSD",
"USDE",
"XUSD",
"USD1",
"XUSD"
}
@dataclass
class SidewaysMetrics:
symbol: str
price_range_pct: float
slope_pct: float
std_pct: float
mean_close: float
last_close: float
data_points: int
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)"
)
parser.add_argument(
"--timeframe",
default=DEFAULT_TIMEFRAME,
help="K 线周期(默认:1h",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help="每个交易对获取的 K 线数量(默认:500)",
)
parser.add_argument(
"--range-threshold",
type=float,
default=0.05,
help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%",
)
parser.add_argument(
"--slope-threshold",
type=float,
default=0.0005,
help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%",
)
parser.add_argument(
"--std-threshold",
type=float,
default=0.015,
help="标准差占均价比例阈值(默认:0.015,表示 1.5%%",
)
parser.add_argument(
"--quote",
action="append",
default=[],
help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD",
)
parser.add_argument(
"--symbol",
action="append",
default=[],
help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)",
)
parser.add_argument(
"--max-symbols",
type=int,
default=None,
help="限制最多检测的交易对数量(用于调试)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.35,
help="请求失败后的基础重试等待秒数(默认:0.35)",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="单个交易对请求失败后的最大重试次数(默认:3)",
)
parser.add_argument(
"--include-inactive",
action="store_true",
help="包含已下架/不可交易的交易对(默认不包含)",
)
parser.add_argument(
"--export",
type=str,
default=None,
help="将筛选结果导出为 CSV 文件的路径",
)
parser.add_argument(
"--verbose",
action="store_true",
help="输出更详细的日志信息",
)
return parser.parse_args(argv)
def setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({"enableRateLimit": True})
exchange.options["defaultType"] = "spot"
return exchange
def iter_target_symbols(
exchange: ccxt.binance,
quotes: Sequence[str],
includes: Sequence[str],
include_inactive: bool,
) -> List[str]:
markets = exchange.load_markets()
filtered = []
quote_set = {quote.upper() for quote in quotes}
include_set = {sym.upper() for sym in includes}
for symbol, meta in markets.items():
if not meta.get("spot", False):
continue
if not include_inactive and meta.get("active") is False:
continue
normalized_symbol = symbol.upper()
if include_set and normalized_symbol not in include_set:
continue
parts = symbol.split("/")
if len(parts) != 2:
continue
base_asset, quote_asset = parts[0].upper(), parts[1].upper()
target_quote = quote_set or {"USDT"}
if quote_asset not in target_quote:
continue
if base_asset in STABLECOINS:
continue
filtered.append(symbol)
filtered.sort()
logging.info(
"已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s",
len(filtered),
",".join(sorted(quote_set or {"USDT"})),
",".join(sorted(include_set)) or "",
)
return filtered
def fetch_ohlcv_with_retry(
exchange: ccxt.binance,
symbol: str,
timeframe: str,
limit: int,
retries: int,
base_sleep: float,
) -> List[List[float]]:
attempt = 0
while True:
try:
return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except ccxt.RateLimitExceeded as exc:
wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep)
logging.debug("触发限频,等待 %.2f 秒后重试 %s%s", wait_time, symbol, exc)
time.sleep(wait_time)
except (ccxt.NetworkError, ccxt.ExchangeError) as exc:
attempt += 1
if attempt > retries:
logging.warning("多次获取失败,跳过 %s%s", symbol, exc)
return []
wait_time = base_sleep * attempt
logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc)
time.sleep(wait_time)
def linear_regression_slope(values: Sequence[float]) -> float:
n = len(values)
if n < 2:
return 0.0
mean_x = (n - 1) / 2.0
mean_y = sum(values) / n
numerator = 0.0
denominator = 0.0
for idx, value in enumerate(values):
dx = idx - mean_x
numerator += dx * (value - mean_y)
denominator += dx * dx
if denominator == 0:
return 0.0
return numerator / denominator
def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]:
if not closes:
return None
mean_close = sum(closes) / len(closes)
if math.isclose(mean_close, 0.0):
return None
max_close = max(closes)
min_close = min(closes)
price_range_pct = (max_close - min_close) / mean_close
slope = linear_regression_slope(closes)
slope_pct = slope / mean_close
std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0
std_pct = std_dev / mean_close
return SidewaysMetrics(
symbol=symbol,
price_range_pct=price_range_pct,
slope_pct=slope_pct,
std_pct=std_pct,
mean_close=mean_close,
last_close=closes[-1],
data_points=len(closes),
)
def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool:
return (
metrics.price_range_pct <= range_threshold
and abs(metrics.slope_pct) <= slope_threshold
and metrics.std_pct <= std_threshold
)
def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None:
fieldnames = [
"symbol",
"price_range_pct",
"slope_pct",
"std_pct",
"mean_close",
"last_close",
"data_points",
]
with open(path, "w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for item in results:
writer.writerow(
{
"symbol": item.symbol,
"price_range_pct": f"{item.price_range_pct:.6f}",
"slope_pct": f"{item.slope_pct:.6f}",
"std_pct": f"{item.std_pct:.6f}",
"mean_close": f"{item.mean_close:.8f}",
"last_close": f"{item.last_close:.8f}",
"data_points": item.data_points,
}
)
logging.info("结果已导出至 %s", path)
def run(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
if not args.quote:
args.quote = ["USDT"]
setup_logging(args.verbose)
exchange = create_exchange()
symbols = iter_target_symbols(
exchange=exchange,
quotes=args.quote,
includes=args.symbol,
include_inactive=args.include_inactive,
)
if args.max_symbols is not None:
symbols = symbols[: args.max_symbols]
logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols))
if not symbols:
logging.error("未找到任何满足条件的交易对,请检查过滤条件。")
return 1
sideways_results: List[SidewaysMetrics] = []
total = len(symbols)
for idx, symbol in enumerate(symbols, start=1):
logging.info("(%d/%d) 正在获取 %s%s K 线(limit=%d", idx, total, symbol, args.timeframe, args.limit)
ohlcv = fetch_ohlcv_with_retry(
exchange=exchange,
symbol=symbol,
timeframe=args.timeframe,
limit=args.limit,
retries=args.retries,
base_sleep=args.sleep,
)
if len(ohlcv) < max(100, args.limit // 2):
logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv))
continue
closes = [entry[4] for entry in ohlcv if entry[4] is not None]
metrics = compute_sideways_metrics(closes, symbol)
if not metrics:
continue
if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold):
sideways_results.append(metrics)
logging.info(
"识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
else:
logging.debug(
"未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
if not sideways_results:
logging.warning("未检测到满足定义的长期横盘交易对。")
return 0
sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct))
print("=" * 88)
print(
f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}"
f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%}"
)
print("=" * 88)
header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}"
print(header)
print("-" * len(header))
for item in sideways_results:
print(
f"{item.symbol:15s}"
f" {item.price_range_pct * 100:10.4f}"
f" {item.slope_pct * 100:10.4f}"
f" {item.std_pct * 100:10.4f}"
f" {item.mean_close:14.8f}"
f" {item.last_close:14.8f}"
f" {item.data_points:6d}"
)
if args.export:
export_results(args.export, sideways_results)
logging.info("任务完成。")
return 0
if __name__ == "__main__":
sys.exit(run())
+529
View File
@@ -0,0 +1,529 @@
import sys
import os
#sys.setrecursionlimit(1000000) #例如这里设置为一百万
#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan"))
sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan"))
import numpy as np
from datetime import timedelta
from pandas import DataFrame
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS
from chanlun.core.ChanBSP import ChanBSP
import talib.abstract as ta
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import DateFormatter, date2num
import matplotlib.patches as patches
from technical.util import resample_to_interval
from decimal import Decimal
from chanlun.pipeline.orchestrator import ChanLun
import xgboost as xgb
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
class ChanLunClassifier:
def __init__(self, dataframe: DataFrame):
self.dataframe = dataframe
self.model = None
chan = ChanLun()
def train_model(self, dataframe=None, data_file_path=None, model_file_path='chan_xgb_model.json', use_cv=False, custom_params=None, model_name=None):
"""
使用dataframe前80%的数据训练XGBoost模型
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param data_file_path: 特征数据保存路径,可选
:param model_file_path: 模型保存路径
:param use_cv: 是否使用交叉验证寻找最佳参数
:param custom_params: 自定义模型参数
:return: 训练好的模型
"""
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,前80%用于训练
train_size = int(len(dataframe) * 0.8)
train_df = dataframe.iloc[:train_size].copy()
# 获取训练集特征和标签
save_csv = True if data_file_path else False
X_train, y_train = self.get_feature_data(train_df, save_csv=save_csv, csv_path=data_file_path if data_file_path else 'feature_data.csv')
if len(X_train) == 0:
print("没有提取到足够的特征数据进行训练")
return None
# 保存特征数据的步骤已经移到get_feature_data方法中处理
# 以下是原有代码
#{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 默认XGBoost参数
default_params = {
'objective': 'binary:logistic',
'max_depth': 8,
'eta': 0.01,
'subsample': 0.8,
'colsample_bytree': 0.8,
'eval_metric': 'auc',
'gamma': 0.0,
'min_child_weight': 1,
'alpha': 0, # L1正则化
'lambda': 0.5, # L2正则化
'scale_pos_weight': 1
}
# 使用自定义参数覆盖默认参数
if custom_params:
for key, value in custom_params.items():
default_params[key] = value
params = default_params
dtrain = xgb.DMatrix(X_train, label=y_train)
# 如果使用交叉验证寻找最佳参数
if use_cv:
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.metrics import make_scorer, accuracy_score, f1_score
import numpy as np
# 转换为sklearn兼容格式
xgb_model = xgb.XGBClassifier(
objective=params['objective'],
max_depth=params['max_depth'],
learning_rate=params['eta'],
subsample=params['subsample'],
colsample_bytree=params['colsample_bytree'],
gamma=params['gamma'],
min_child_weight=params['min_child_weight'],
reg_alpha=params['alpha'],
reg_lambda=params['lambda'],
scale_pos_weight=params['scale_pos_weight'],
use_label_encoder=False,
eval_metric='auc'
)
# 参数网格
param_grid = {
'max_depth': [3, 5, 7, 9],
'learning_rate': [0.01, 0.05, 0.1, 0.2],
'subsample': [0.6, 0.8, 1.0],
'colsample_bytree': [0.6, 0.8, 1.0],
'min_child_weight': [1, 3, 5],
'gamma': [0, 0.1, 0.2],
'n_estimators': [50, 100, 200]
}
# 使用随机搜索寻找最佳参数(比网格搜索快)
random_search = RandomizedSearchCV(
estimator=xgb_model,
param_distributions=param_grid,
n_iter=10, # 随机尝试的参数组合数
scoring=make_scorer(f1_score),
cv=5,
verbose=1,
n_jobs=-1,
random_state=42
)
print("进行交叉验证参数搜索...")
random_search.fit(X_train, y_train)
# 获取最佳参数
best_params = random_search.best_params_
print(f"最佳参数: {best_params}")
# 使用最佳参数更新模型参数
params['max_depth'] = best_params['max_depth']
params['eta'] = best_params['learning_rate']
params['subsample'] = best_params['subsample']
params['colsample_bytree'] = best_params['colsample_bytree']
params['min_child_weight'] = best_params['min_child_weight']
params['gamma'] = best_params['gamma']
num_round = best_params['n_estimators']
# 使用最佳参数训练最终模型
self.model = xgb.train(params, dtrain, num_round)
else:
# 标准训练(不使用交叉验证)
# 使用早停机制避免过拟合
# 分割训练集为训练和验证
eval_size = int(len(X_train) * 0.2)
X_eval = X_train[-eval_size:]
y_eval = y_train[-eval_size:]
X_train_part = X_train[:-eval_size]
y_train_part = y_train[:-eval_size]
dtrain_part = xgb.DMatrix(X_train_part, label=y_train_part)
deval = xgb.DMatrix(X_eval, label=y_eval)
# 评估列表
evallist = [(dtrain_part, 'train'), (deval, 'eval')]
# 训练模型,使用早停
num_round = 1000 # 设置较大的轮数,让早停机制决定何时停止
self.model = xgb.train(
params,
dtrain_part,
num_round,
evallist,
early_stopping_rounds=50, # 50轮内评估指标无改善则停止
verbose_eval=True
)
# 使用全部训练数据重新训练最终模型,使用最佳轮数
# best_rounds = self.model.best_ntree_limit
# 兼容新版本的XGBoost
if hasattr(self.model, 'best_ntree_limit'):
best_rounds = self.model.best_ntree_limit
elif hasattr(self.model, 'best_iteration'):
best_rounds = self.model.best_iteration
elif hasattr(self.model, 'best_ntree_idx'):
best_rounds = self.model.best_ntree_idx
else:
# 如果都不存在,使用默认值
best_rounds = num_round
print(f"最佳轮数: {best_rounds}")
# 使用全部训练数据和最佳轮数训练最终模型
self.model = xgb.train(params, dtrain, best_rounds)
# 保存模型
if model_file_path:
self.model.save_model(model_name + model_file_path)
# 特征重要性分析
if hasattr(self.model, 'get_score'):
importance = self.model.get_score(importance_type='gain')
print("\n特征重要性 (gain):")
for key, value in sorted(importance.items(), key=lambda x: x[1], reverse=True):
print(f"{key}: {value}")
return self.model
def load_model(self, model_name=None, model_file_path='chan_xgb_model.json'):
if model_name:
self.model = xgb.Booster()
self.model.load_model(model_name + model_file_path)
else:
self.model = xgb.Booster()
self.model.load_model(model_file_path)
def find_best_params(self, dataframe=None, save_csv=False, csv_path_prefix='param_', model_name=None):
"""
寻找最佳参数组合
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path_prefix: CSV文件保存路径前缀,会自动添加参数信息
:return: 最佳参数
"""
# 不同参数组合
param_combinations = [
# 低学习率,深树
{'eta': 0.01, 'max_depth': 8, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0, 'min_child_weight': 1},
# 中等学习率,中等树深度
{'eta': 0.05, 'max_depth': 5, 'subsample': 0.7, 'colsample_bytree': 0.7, 'gamma': 0.1, 'min_child_weight': 3},
# 高学习率,浅树
{'eta': 0.1, 'max_depth': 3, 'subsample': 0.6, 'colsample_bytree': 0.6, 'gamma': 0.2, 'min_child_weight': 5},
# 正则化较强 best here
{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 正则化较弱
{'eta': 0.08, 'max_depth': 6, 'subsample': 0.9, 'colsample_bytree': 0.9, 'gamma': 0, 'min_child_weight': 1, 'alpha': 0, 'lambda': 0.5},
]
best_score = 0
best_params = None
best_model = None
for i, params in enumerate(param_combinations):
print(f"\n尝试参数组合: {params}")
# 生成CSV文件名,包含一些参数信息
param_info = f"eta{params['eta']}_depth{params['max_depth']}"
train_csv_path = f"{csv_path_prefix}train_{param_info}.csv" if save_csv else None
model = self.train_model(dataframe=dataframe, data_file_path=train_csv_path, custom_params=params, model_name=model_name)
# 分割数据集,后20%用于测试
if dataframe is None:
dataframe = self.dataframe
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
test_csv_path = f"{csv_path_prefix}test_{param_info}.csv" if save_csv else None
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=test_csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
continue
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算F1分数
f1 = f1_score(y_test, y_pred, zero_division=0)
print(f"F1分数: {f1:.4f}")
if f1 > best_score:
best_score = f1
best_params = params
best_model = model
print(f"\n最佳参数组合 (F1={best_score:.4f}):")
print(best_params)
self.model = best_model
return best_params
def get_feature_data(self, dataframe, save_csv=False, csv_path='feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
klc_count = 0
print('Processing data...')
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
#bi = bi_list[bi_index]
#if klc.end_klu and bi.end_klc and klc.start_klu.index >= bi.start_klc.start_klu.index and klc.end_klu.index <= bi.end_klc.end_klu.index:
#klc.set_bi(bi)
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 判断这个bi是否赚钱(这里简单定义为:如果bi的结束价格高于起始价格,则标记为1,否则为0)
# 这个标签定义可以根据实际需求修改
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
#print(bi.start_time, bi.start_klc.start_time, bi.dir)
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
klc_count += 1
percent = klc_count/len(sample_list)*100
if percent % 10 == 0:
print('Data processed:', percent, '%')
for index, key in enumerate(feature_keys):
print(index, key, feature_data[0][index])
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"特征数据已保存到 {csv_path}")
# 在return前添加
positive_count = np.sum(labels)
print(f"正样本数量: {positive_count}, 负样本数量: {len(labels) - positive_count}")
print("Trainning data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def get_validate_feature_data(self, dataframe, save_csv=False, csv_path='validate_feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
seg_list = self.chan.get_seg_list(bi_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
bi = bi_list[bi_index]
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
seg = seg_list[bi_index]
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"验证特征数据已保存到 {csv_path}")
print("Validating data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def validate_model(self, dataframe=None, save_csv=False, csv_path='validate_feature_data.csv'):
"""
使用dataframe后20%的数据验证模型
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 验证结果
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,后20%用于测试
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
return None
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = self.model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算评估指标
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, zero_division=0)
recall = recall_score(y_test, y_pred, zero_division=0)
f1 = f1_score(y_test, y_pred, zero_division=0)
# 打印评估报告
print("模型评估结果:")
print(f"准确率: {accuracy:.4f}")
print(f"精确率: {precision:.4f}")
print(f"召回率: {recall:.4f}")
print(f"F1分数: {f1:.4f}")
print("\n分类报告:")
print(classification_report(y_test, y_pred, zero_division=0))
return {
'accuracy': accuracy,
'precision': precision,
'recall': recall,
'f1': f1,
'y_test': y_test,
'y_pred': y_pred,
'y_pred_prob': y_pred_prob
}
def predict(self, klc):
"""
使用训练好的模型预测单个KLC
:param klc: 需要预测的ChanKLC对象
:return: 预测结果(概率值)
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
# 提取特征
features = klc.get_feature_data()
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 转换为模型输入格式
dtest = xgb.DMatrix(np.array([feature_vec]))
# 预测
return self.get_decimal(self.model.predict(dtest)[0])
def get_decimal(self, value):
return Decimal("{:.4f}".format(value))
+402
View File
@@ -0,0 +1,402 @@
import sys
import os
#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py"))
sys.path.append(os.path.abspath("/Users/jack/Project/chan.py"))
from Chan import CChan
from BuySellPoint.BS_Point import CBS_Point
from ChanConfig import CChanConfig
from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR
from KLine.KLine_Unit import CKLine_Unit
from Common.CTime import CTime
from Common.func_util import kltype_lt_day, str2float
from Bi.Bi import CBi
from typing import Dict, List
from functools import reduce
from pandas import DataFrame
from datetime import datetime, timedelta, timezone
def GetColumnNameFromFieldList(fileds: str):
_dict = {
"time": DATA_FIELD.FIELD_TIME,
"open": DATA_FIELD.FIELD_OPEN,
"high": DATA_FIELD.FIELD_HIGH,
"low": DATA_FIELD.FIELD_LOW,
"close": DATA_FIELD.FIELD_CLOSE,
"volume": DATA_FIELD.FIELD_VOLUME
}
return [_dict[x] for x in fileds.split(",")]
class ChanPY():
k_type = KL_TYPE.K_5M
config = CChanConfig({
"bi_strict": True,
"bi_algo": "normal",
"trigger_step": True,
"skip_step": 0,
"divergence_rate": float("inf"),
"bsp2_follow_1": False,
"bsp3_follow_1": False,
"min_zs_cnt": 1,
"bs1_peak": False,
"macd_algo": "peak",
"bs_type": '1,2,3a,1p,2s,3b',
"print_warning": True,
"zs_algo": "normal",
})
chan = CChan(
code="BTC/USDT:USDT",
data_src=DATA_SRC.CCXT,
lv_list=[k_type],
config=config,
autype=AUTYPE.QFQ,
)
klu_list = []
bsps = []
chanIn = True
#def __init__(self, dataframe):
#self.klu_list = self.get_kl_data(dataframe)
#for klu in self.klu_list:
#self.chan.trigger_load({self.k_type: [klu]})
def add_klu(self, klu):
if klu:
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def add_klu_from_dataframe(self, dataframe):
if len(dataframe) > len(self.klu_list) and len(dataframe) - len(self.klu_list) == 1:
klu = self.get_last_klu(dataframe)
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def parse_time_column(self, inp):
if len(inp) == 10:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = minute = 0
elif len(inp) == 17:
year = int(inp[:4])
month = int(inp[4:6])
day = int(inp[6:8])
hour = int(inp[8:10])
minute = int(inp[10:12])
elif len(inp) == 19:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = int(inp[11:13])
minute = int(inp[14:16])
else:
raise Exception(f"unknown time column from TradingView:{inp}")
return CTime(year, month, day, hour, minute, auto=not kltype_lt_day(self.k_type))
def create_item_dict(self, data, column_name):
for i in range(len(data)):
data[i] = self.parse_time_column(data[i]) if i == 0 else str2float(data[i])
return dict(zip(column_name, data))
def get_last_klu(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
item = dataframe.iloc[-1]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
#time_obj = date.fromtimestamp(date)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(len(dataframe)-1)
return klu
def get_kl_data(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
klu_list = []
for i in range(0, len(dataframe)):
item = dataframe.iloc[i]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
#time_obj = date.fromtimestamp(date)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(i)
klu_list.append(klu)
return klu_list
def get_bsp_type(self, bsp_type, is_buy):
if is_buy:
if bsp_type == BSP_TYPE.T1:
return 1
if bsp_type == BSP_TYPE.T1P:
return 2
if bsp_type == BSP_TYPE.T2:
return 3
if bsp_type == BSP_TYPE.T2S:
return 4
if bsp_type == BSP_TYPE.T3A:
return 5
if bsp_type == BSP_TYPE.T3B:
return 6
else:
if bsp_type == BSP_TYPE.T1:
return -1
if bsp_type == BSP_TYPE.T1P:
return -2
if bsp_type == BSP_TYPE.T2:
return -3
if bsp_type == BSP_TYPE.T2S:
return -4
if bsp_type == BSP_TYPE.T3A:
return -5
if bsp_type == BSP_TYPE.T3B:
return -6
def get_bsps(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
updown = []
bi_sure = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_updown = -1
bi_list_pre_len = 0
pre_bi = None
zs_list_pre_len = 0
pre_zs = None
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
else:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
bsp_list_pre_len = len(bsp_list)
#Check zs -----------------------------------
zs_list = kl_datas.zs_list
if len(zs_list) > 0:
zs = zs_list[-1]
#if zs_list_pre_len > len(zs_list):
#print("No zs", zs.begin.time)
#if len(zs_list) > zs_list_pre_len:
#print(zs.begin.time, zs.end.time, zs.end.idx, zs.high, zs.low, zs.peak_high, zs.peak_low)
zs_list_pre_len = len(zs_list)
pre_zs = zs
#Check Bi -----------------------------------
if len(bi_list) > 0:
last_bi = bi_list[-1]
if len(bi_list) == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(1)
last_updown = 1
else:
updown.append(-1)
last_updown = -1
else:
if last_updown == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(0)
else:
updown.append(-1)
last_updown = -1
else:
if last_bi.dir == BI_DIR.DOWN:
updown.append(0)
else:
updown.append(1)
last_updown = 1
else:
updown.append(0)
bi_list = kl_datas.bi_list
if len(bi_list) > 0:
last_bi = bi_list[-1]
#if bi_list_pre_len > len(bi_list):
#print("Bi ", klu.time, pre_bi.idx, pre_bi.is_sure, bi_list[-1].idx, bi_list[-1].is_sure)
if last_bi.is_sure:
bi_sure.append(1)
#print(klu.time, last_bi.is_sure)
else:
bi_sure.append(0)
pre_bi = bi_list[-1]
bi_list_pre_len = len(bi_list)
else:
bi_sure.append(0)
#if bsps[-1] != 0 or updown[-1] != 0:
#print(klu.time, bsps[-1], updown[-1], bi_list[-1].is_sure)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
for index in range(0, len(bsps)):
if not (abs(bsps[index]) == 1 or abs(bsps[index]) == 2):
bsps[index] = 0
else:
if bsps[index] == 2:
bsps[index] = 1
else:
if bsps[index] == -2:
bsps[index] = -1
else:
bsps[index] = 0
#print(bsps)
#print(updown)
kl_datas = self.chan.kl_datas[self.k_type]
#for zs in kl_datas.zs_list:
#print(zs.begin.time, zs.end.time)
return bsps, updown, bi_sure
def get_bsp_state1(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
self.chan.trigger_load({self.k_type: kl_data})
bsp_list = self.chan.get_bsp()
bsp_index = 0
for klu in kl_data:
if bsp_index >= len(bsp_list):
bsp_index = len(bsp_list) - 1
bsp = bsp_list[bsp_index]
if klu.idx == bsp.klu.idx:
bsp_type = self.get_bsp_type(bsp.type[0], bsp.is_buy)
if abs(bsp_type) == 1 or abs(bsp_type) == 10:
bsps.append(1)
else:
bsps.append(0)
bsp_index = bsp_index + 1
else:
bsps.append(0)
self.chanIn = False
else:
klu = CKLine_Unit(self.create_item_dict(self.get_last_item_data(dataframe), GetColumnNameFromFieldList(fields)), autofix=True)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
return bsps
def get_bsp_state(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_bsp_index = 0
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1:
self.bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
self.bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
self.bsps.append(0)
bsp_list_pre_len = len(bsp_list)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
last_bsp = bsp_list[-1]
if last_bsp.klu.idx == klu.idx:
self.bsps.append(self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy))
else:
self.bsps.append(0)
for index in range(0, len(self.bsps)):
if not (abs(self.bsps[index]) == 1 or abs(self.bsps[index]) == 2):
self.bsps[index] = 0
else:
if self.bsps[index] == 2:
self.bsps[index] = 10
else:
if self.bsps[index] == -2:
self.bsps[index] = -10
else:
if self.bsps[index] == 1:
self.bsps[index] = 1
else:
if self.bsps[index] == -1:
self.bsps[index] = -1
else:
self.bsps[index] = 0
return self.bsps
+292
View File
@@ -0,0 +1,292 @@
"""
中枢结构特征提取 + 标签化
Market Structure Dataset Builder — Phase 1
定位: 训练数据集构建工具,不是交易信号生成器。
Feature 描述中枢内部结构,Label 记录中枢后实际演化。
"""
import math
import json
from typing import Optional
from chanlun.core.ChanEnum import Chan_BI_DIR
class ChanPivotClassifier:
"""
中枢结构特征提取 + 标签化
输入: bi_zs_list (list[ChanBIZS])
输出: 结构化数据集 (list[dict])
"""
DATASET_VERSION = "pivot_v1"
FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"]
LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]}
def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""):
self.bi_zs_list = bi_zs_list
self.symbol = symbol
self.timeframe = timeframe
# ------------------------------------------------------------------
# Feature extraction
# ------------------------------------------------------------------
@staticmethod
def calc_duration(zs) -> int:
"""持续时间: 第一笔首K → 最后一笔末K 的 index 差"""
bi_list = zs.bi_list
start_idx = bi_list[0].start_klc.index
end_idx = bi_list[-1].end_klc.index
return end_idx - start_idx
@staticmethod
def calc_contraction(zs) -> float:
"""收敛率: 后窗口振幅均值 / 前窗口振幅均值"""
bi_list = zs.bi_list
if len(bi_list) < 4:
return 1.0
n = min(3, len(bi_list) // 2)
first_ranges = [bi.high - bi.low for bi in bi_list[:n]]
last_ranges = [bi.high - bi.low for bi in bi_list[-n:]]
first_mean = sum(first_ranges) / len(first_ranges)
last_mean = sum(last_ranges) / len(last_ranges)
if first_mean == 0:
return 1.0
return last_mean / first_mean
@staticmethod
def calc_shift(zs) -> tuple[float, float]:
"""重心漂移: 前后半段重心均值差 (原始值, 归一化值)"""
bi_list = zs.bi_list
mid = len(bi_list) // 2
first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]]
last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]]
shift_raw = (
sum(last_centers) / len(last_centers)
- sum(first_centers) / len(first_centers)
)
zs_height = zs.zg - zs.zd
if zs_height == 0:
shift_norm = 0.0
else:
shift_norm = shift_raw / zs_height
return shift_raw, shift_norm
@staticmethod
def compute_duration_norm(duration_raw: int, historical_durations: list) -> float:
"""用历史窗口均值归一化 duration"""
if not historical_durations:
return 1.0
avg = sum(historical_durations) / len(historical_durations)
if avg == 0:
return 1.0
return duration_raw / avg
@staticmethod
def compute_features(zs, historical_durations: Optional[list] = None):
"""计算单个中枢的全部结构特征(实时友好)"""
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
if historical_durations is not None and len(historical_durations) > 0:
duration_norm = ChanPivotClassifier.compute_duration_norm(
duration_raw, historical_durations
)
else:
duration_norm = 1.0
return {
"duration_raw": duration_raw,
"duration_norm": round(duration_norm, 4),
"contraction": round(contraction, 4),
"shift_raw": round(shift_raw, 6),
"shift_norm": round(shift_norm, 4),
"zs_height": round(zs.zg - zs.zd, 6),
}
# ------------------------------------------------------------------
# Label computation
# ------------------------------------------------------------------
@staticmethod
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict:
"""计算标签: up / down / none + 连续置信度"""
bi_out = zs.bi_out
if bi_out is None:
return {
"label": "none",
"label_confidence": 0.0,
"label_detail": {
"bi_out_dir": "none",
"score_breakout": 0.0,
"score_shift": 0.0,
"score_contraction": 0.0,
},
}
zs_height = zs.zg - zs.zd
if zs_height == 0:
zs_height = 1e-8
# ---- 向上突破分数 ----
if bi_out.dir == Chan_BI_DIR.UP:
raw_breakout = (bi_out.high - zs.gg) / zs_height
score_breakout_up = self._clamp(raw_breakout)
score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0))
score_contraction_up = max(0.0, 1.0 - contraction)
else:
score_breakout_up = 0.0
score_shift_up = 0.0
score_contraction_up = 0.0
up_score = (
score_breakout_up * 0.5
+ score_shift_up * 0.3
+ score_contraction_up * 0.2
)
# ---- 向下突破分数 ----
if bi_out.dir == Chan_BI_DIR.DOWN:
raw_breakout = (zs.dd - bi_out.low) / zs_height
score_breakout_down = self._clamp(raw_breakout)
score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0))
score_contraction_down = max(0.0, 1.0 - contraction)
else:
score_breakout_down = 0.0
score_shift_down = 0.0
score_contraction_down = 0.0
down_score = (
score_breakout_down * 0.5
+ score_shift_down * 0.3
+ score_contraction_down * 0.2
)
# ---- 判定 ----
threshold = 0.15
if up_score > down_score and up_score > threshold:
label = "up"
confidence = up_score
detail = {
"bi_out_dir": "up",
"score_breakout": round(score_breakout_up, 4),
"score_shift": round(score_shift_up, 4),
"score_contraction": round(score_contraction_up, 4),
}
elif down_score > up_score and down_score > threshold:
label = "down"
confidence = down_score
detail = {
"bi_out_dir": "down",
"score_breakout": round(score_breakout_down, 4),
"score_shift": round(score_shift_down, 4),
"score_contraction": round(score_contraction_down, 4),
}
else:
label = "none"
confidence = max(up_score, down_score)
bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down"
detail = {
"bi_out_dir": bi_dir,
"score_breakout": round(max(score_breakout_up, score_breakout_down), 4),
"score_shift": round(max(score_shift_up, score_shift_down), 4),
"score_contraction": round(max(score_contraction_up, score_contraction_down), 4),
}
return {
"label": label,
"label_confidence": round(confidence, 4),
"label_detail": detail,
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def extract(self) -> list[dict]:
"""主入口:对每个中枢提取 3 特征 + 1 标签"""
# 第一遍:计算原始值
raw = []
for i, zs in enumerate(self.bi_zs_list):
if not zs.is_sure or len(zs.bi_list) < 3:
continue
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
raw.append({
"zs": zs,
"zs_index": i,
"duration_raw": duration_raw,
"contraction": contraction,
"shift_raw": shift_raw,
"shift_norm": shift_norm,
"zs_height": zs.zg - zs.zd,
})
# 第二遍:组装输出 + 计算 label
result = []
for r in raw:
zs = r["zs"]
historical = [x["duration_raw"] for x in raw]
duration_norm = ChanPivotClassifier.compute_duration_norm(
r["duration_raw"], historical
)
label_info = self._compute_label(zs, r["contraction"], r["shift_norm"])
# 时间处理
start_time = None
end_time = None
if hasattr(zs, "start_time") and zs.start_time is not None:
start_time = str(zs.start_time)
if hasattr(zs, "end_time") and zs.end_time is not None:
end_time = str(zs.end_time)
result.append({
"dataset_version": self.DATASET_VERSION,
"feature_schema": self.FEATURE_SCHEMA,
"label_schema": self.LABEL_SCHEMA,
"symbol": self.symbol,
"timeframe": self.timeframe,
"zs_index": r["zs_index"],
"zs_start_time": start_time,
"zs_end_time": end_time,
"duration_norm": round(duration_norm, 4),
"contraction": round(r["contraction"], 4),
"shift_norm": round(r["shift_norm"], 4),
"label": label_info["label"],
"label_confidence": label_info["label_confidence"],
"label_detail": label_info["label_detail"],
"duration_raw": r["duration_raw"],
"shift_raw": round(r["shift_raw"], 6),
"zs_height": round(r["zs_height"], 6),
})
return result
def export_json(self, path: str):
"""导出为 JSON 文件"""
data = self.extract()
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
return len(data)
+145
View File
@@ -0,0 +1,145 @@
"""
实时中枢特征跟踪器
Real-time Pivot Feature Tracker
定位: 观察者 — 不修改管线,只观察 bi_zs_list 中当前中枢的特征变化。
每次管线重算后调用 update(),检测 bi_count 是否增长,若增长则重新计算
shift / contraction / duration。
"""
from collections import deque
from typing import Optional
from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier
class ChanPivotMonitor:
"""
实时追踪当前中枢的结构特征。
update() 每次管线重算后调用,对比 bi_count 判断是否有新笔加入中枢。
若 bi_count 增长则重新计算 3 个结构特征并返回最新值。
"""
def __init__(self, window_size: int = 10):
self._window_size = window_size
self._duration_history: deque[int] = deque(maxlen=window_size)
self._current_zs_id: Optional[tuple] = None
self._current_bi_count: int = 0
self._current_is_sure: bool = False
self._current_state: Optional[dict] = None
self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID(上限 200)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def update(self, bi_zs_list: list) -> Optional[dict]:
"""
主入口:检测当前中枢特征变化。
参数:
bi_zs_list: 当前管线产出的笔中枢列表
返回:
特征 dict(有变化时),无变化返回 None
"""
if not bi_zs_list:
self._current_zs_id = None
self._current_bi_count = 0
self._current_is_sure = False
self._current_state = None
return None
zs = self._find_current_zs(bi_zs_list)
if zs is None:
return None
zs_id = self._make_zs_id(zs)
bi_count = len(zs.bi_list)
is_sure = zs.is_sure
# 无变化 → 跳过
if (zs_id == self._current_zs_id
and bi_count == self._current_bi_count
and is_sure == self._current_is_sure):
return None
# 中枢切换 → 将旧中枢 duration 加入窗口
if zs_id != self._current_zs_id:
self._maybe_add_to_history()
self._current_zs_id = zs_id
self._current_bi_count = bi_count
self._current_is_sure = is_sure
features = ChanPivotClassifier.compute_features(
zs, list(self._duration_history)
)
self._current_state = {
"zs_id": zs_id,
"zs_index": zs.index,
"zs_dir": str(zs.dir),
"bi_count": bi_count,
"is_sure": zs.is_sure,
"zg": round(zs.zg, 6),
"zd": round(zs.zd, 6),
"gg": round(zs.gg, 6),
"dd": round(zs.dd, 6),
**features,
"start_time": str(t) if (t := getattr(zs, "start_time", None)) else None,
}
# 中枢刚变为已确认时,将其 duration 加入滚动窗口
if is_sure and zs_id not in self._duration_added_for_zs:
self._add_duration(features["duration_raw"])
self._duration_added_for_zs.add(zs_id)
return self._current_state
def get_current(self) -> Optional[dict]:
"""返回当前中枢的最新特征"""
return self._current_state
def get_duration_history(self) -> list[int]:
"""返回用于归一化的 duration 滚动窗口"""
return list(self._duration_history)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
@staticmethod
def _make_zs_id(zs) -> tuple:
"""生成中枢的稳定标识(基于首笔首K线时间戳,不随 DataFrame 窗口偏移而变化)"""
bi0 = zs.bi_list[0]
return (bi0.start_klc.start_time,)
@staticmethod
def _find_current_zs(bi_zs_list: list):
"""
找到当前活跃中枢:
优先取最后一个 is_sure=False(形成中)的中枢,
没有则取最后一个 is_sure=True 的中枢。
"""
forming = None
last_sure = None
for zs in bi_zs_list:
if len(zs.bi_list) < 3:
continue
if not zs.is_sure:
forming = zs
else:
last_sure = zs
return forming if forming is not None else last_sure
def _add_duration(self, duration_raw: int):
"""将已确认中枢的 duration 加入滚动窗口"""
self._duration_history.append(duration_raw)
def _maybe_add_to_history(self):
"""旧中枢切换前,若已确认且未记录过,则将其 duration 加入窗口"""
if (self._current_state and self._current_state["is_sure"]
and self._current_zs_id not in self._duration_added_for_zs):
self._add_duration(self._current_state["duration_raw"])
self._duration_added_for_zs.add(self._current_zs_id)
+566
View File
@@ -0,0 +1,566 @@
"""
结构价值区 (Structure Zone) 系统
将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) 和 EMA52 统一表示为带强度评分的价值区对象。
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from datetime import datetime
# ============================================================
# Dataclasses
# ============================================================
@dataclass
class RawZonePoint:
"""内部中间结构:从 Chan 中枢提取的单个价格点"""
price: float
timeframe: str # '5m', '1h', '4h' 等
structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52'
boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52'
source_zs_id: int # 来源 ZS 在列表中的 index(调试用)
is_sure: bool # 来源 ZS 是否已完成
candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算)
@dataclass
class StructureZone:
"""统一的价值区对象"""
id: int
lower: float
upper: float
center: float # (lower + upper) / 2
width_pct: float # (upper - lower) / center * 100
zone_type: str # 'support' | 'resistance' | 'neutral'
timeframes: List[str] # 参与形成此区间的时间周期
structure_types: List[str] # 参与形成的结构类型
boundary_types: List[str] # 参与形成的边界类型
overlap_count: int # 聚类中的原始点数
touch_count: int # MVP: 等于 overlap_count
recency_score: float # 0.0 - 1.0, 1.0 = 最近
ema52_distance_pct: float # 到最近 EMA52 的距离百分比
ema52_aligned: bool # 是否有 EMA52 落在区间内
strength_score: float # 0-100 综合评分
confidence: float # 0.0 - 1.0
first_seen: Optional[str] # 最早的 candle_time
last_seen: Optional[str] # 最晚的 candle_time
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class StructureZoneConfig:
"""StructureZone 提取与评分配置"""
cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比)
min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间
max_zones: int = 20 # 返回的最大区间数
recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数)
zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m'])
kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线
structure_weights: Dict[str, float] = field(default_factory=lambda: {
'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为
'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗
'ema52': 0.4, # EMA — 趋势参考,弱于结构
})
# ============================================================
# Extraction
# ============================================================
def extract_raw_points_from_tf_df(
tf_df_dict: Dict[str, Any],
ema_symbols: List[str],
config: StructureZoneConfig,
) -> List[RawZonePoint]:
"""
从 ChanLun.tf_df_dict 中提取所有原始价格点。
仅处理 config.zone_timeframes 中存在的时间周期。
"""
points: List[RawZonePoint] = []
for tf_name in config.zone_timeframes:
if tf_name not in tf_df_dict:
continue
tf_df = tf_df_dict[tf_name]
# 1. 笔中枢 (ChanBIZS)
try:
if hasattr(tf_df, 'seg_list') and tf_df.seg_list:
bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list)
if bi_zs_result:
_extract_from_zs_objects(
points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf
)
except Exception:
pass
# 2. 线段中枢 (ChanZS)
try:
zs_list = getattr(tf_df, 'zs_list', None)
if zs_list:
_extract_from_zs_objects(
points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf
)
except Exception:
pass
# 3. EMA52 值
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema_val = tf_df_dict[tf_name].get_ema52()
if ema_val is not None and ema_val > 0:
points.append(RawZonePoint(
price=float(ema_val),
timeframe=tf_name,
structure_type='ema52',
boundary_type='EMA52',
source_zs_id=-1,
is_sure=True,
candle_time=None,
))
except Exception:
pass
return points
def _extract_from_zs_objects(
points: List[RawZonePoint],
tf_name: str,
structure_type: str,
zs_list,
kl_limit: int,
):
"""从 ZS 链表中提取 ZD/ZG/GG/DD 点"""
count = 0
node = zs_list
while hasattr(node, 'next'):
node = node.next
# 从链表头开始遍历
head = zs_list
# 收集所有节点
all_nodes = []
cur = head
while cur is not None and hasattr(cur, 'next'):
all_nodes.append(cur)
cur = cur.next
# 只取最近 kl_limit 根K线内的 ZS
all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes
for idx, zs in enumerate(all_nodes):
if not getattr(zs, 'is_sure', False):
continue
try:
zg = float(zs.zg)
zd = float(zs.zd)
gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg
dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd
end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None
except (ValueError, TypeError, AttributeError):
continue
if zg <= 0 or zd <= 0:
continue
zs_id = getattr(zs, 'index', idx)
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type,
boundary_type='ZG', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type,
boundary_type='ZD', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type,
boundary_type='GG', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type,
boundary_type='DD', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
def extract_raw_points_from_serialized(
analyses: Dict[str, Dict],
ema52_dict: Dict[str, Optional[float]],
config: StructureZoneConfig,
) -> List[RawZonePoint]:
"""
从已序列化的分析结果中提取价格点(用于 web API,避免重复计算)。
analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...}
ema52_dict: {'5m': 123.45, '15m': None, ...}
"""
points: List[RawZonePoint] = []
for tf_name in config.zone_timeframes:
if tf_name not in analyses:
continue
analysis = analyses[tf_name]
# 笔中枢
bi_zs_items = analysis.get('bi_zs_list', [])
for idx, zs in enumerate(bi_zs_items):
if not zs.get('is_sure', False):
continue
try:
zg = float(zs['zg']); zd = float(zs['zd'])
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
end_time = zs.get('end_time')
except (ValueError, KeyError):
continue
if zg <= 0 or zd <= 0:
continue
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='ZG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='ZD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='GG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='DD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
# 线段中枢
zs_items = analysis.get('zs_list', [])
for idx, zs in enumerate(zs_items):
if not zs.get('is_sure', False):
continue
try:
zg = float(zs['zg']); zd = float(zs['zd'])
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
end_time = zs.get('end_time')
except (ValueError, KeyError):
continue
if zg <= 0 or zd <= 0:
continue
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='ZG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='ZD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='GG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='DD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
# EMA52
for tf_name in config.zone_timeframes:
ema_val = ema52_dict.get(tf_name)
if ema_val is not None and ema_val > 0:
points.append(RawZonePoint(
price=float(ema_val),
timeframe=tf_name,
structure_type='ema52',
boundary_type='EMA52',
source_zs_id=-1,
is_sure=True,
candle_time=None,
))
return points
# ============================================================
# Clustering
# ============================================================
def cluster_raw_points(
points: List[RawZonePoint],
config: StructureZoneConfig,
) -> List[List[RawZonePoint]]:
"""
贪心单通聚类:将价格相近的 RawZonePoint 归为一组。
仅在 1D 价格轴上操作,O(n log n)。
"""
if not points:
return []
sorted_points = sorted(points, key=lambda p: p.price)
clusters: List[List[RawZonePoint]] = []
for p in sorted_points:
placed = False
for cluster in reversed(clusters):
# 检查是否可以放入当前聚类(与聚类均价比较)
avg_price = sum(pt.price for pt in cluster) / len(cluster)
if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct:
cluster.append(p)
placed = True
break
if not placed:
clusters.append([p])
# 过滤点数不足的聚类
return [c for c in clusters if len(c) >= config.min_overlap_for_zone]
# ============================================================
# Scoring & Building
# ============================================================
def build_structure_zones(
clusters: List[List[RawZonePoint]],
current_price: float,
ema52_values: Dict[str, Optional[float]],
latest_candle_time: Optional[str],
config: StructureZoneConfig,
) -> List[StructureZone]:
"""
从聚类构建 StructureZone 列表,计算所有字段和评分。
"""
zones: List[StructureZone] = []
# 收集所有 EMA52 值
ema_prices = [v for v in ema52_values.values() if v is not None and v > 0]
for zone_id, cluster in enumerate(clusters):
prices = [p.price for p in cluster]
lower = min(prices)
upper = max(prices)
center = (lower + upper) / 2
width_pct = (upper - lower) / center * 100 if center > 0 else 0.0
# 区间类型
if upper < current_price:
zone_type = 'support' # 区间在当前价格下方 → 支撑
elif lower > current_price:
zone_type = 'resistance' # 区间在当前价格上方 → 阻力
else:
zone_type = 'neutral' # 区间跨越当前价格
timeframes = sorted(set(p.timeframe for p in cluster))
structure_types = sorted(set(p.structure_type for p in cluster))
boundary_types = sorted(set(p.boundary_type for p in cluster))
overlap_count = len(cluster)
# Recency
times = [p.candle_time for p in cluster if p.candle_time]
first_seen = min(times) if times else None
last_seen = max(times) if times else None
recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars)
# EMA52 alignment
ema52_distance_pct = 999.0
ema52_aligned = False
if ema_prices:
distances = [abs(center - ep) / ep * 100 for ep in ema_prices]
ema52_distance_pct = round(min(distances), 2)
ema52_aligned = any(lower <= ep <= upper for ep in ema_prices)
# Strength score
strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct)
# Confidence
confidence = _calc_confidence(overlap_count, len(timeframes), cluster)
zones.append(StructureZone(
id=zone_id + 1,
lower=round(lower, 2),
upper=round(upper, 2),
center=round(center, 2),
width_pct=round(width_pct, 2),
zone_type=zone_type,
timeframes=timeframes,
structure_types=structure_types,
boundary_types=boundary_types,
overlap_count=overlap_count,
touch_count=overlap_count, # MVP: 等于 overlap_count
recency_score=round(recency_score, 3),
ema52_distance_pct=ema52_distance_pct,
ema52_aligned=ema52_aligned,
strength_score=round(strength_score, 1),
confidence=round(confidence, 2),
first_seen=first_seen,
last_seen=last_seen,
))
# 按强度降序排列
zones.sort(key=lambda z: z.strength_score, reverse=True)
# 截断
if config.max_zones > 0 and len(zones) > config.max_zones:
zones = zones[:config.max_zones]
return zones
def _calc_recency(
last_seen: Optional[str],
latest_time: Optional[str],
halflife_bars: int,
) -> float:
"""计算 recency 分数:越近越高"""
if not last_seen or not latest_time:
return 0.5
try:
# 尝试解析 ISO 格式时间
from dateutil import parser
t_last = parser.parse(last_seen)
t_latest = parser.parse(latest_time)
offset_seconds = (t_latest - t_last).total_seconds()
if offset_seconds < 0:
return 1.0
# 假设每根K线平均 5 分钟
bar_seconds = 300
offset_bars = offset_seconds / bar_seconds
# 指数衰减: 2 ^ (-offset / halflife)
score = 2.0 ** (-offset_bars / halflife_bars)
return float(score)
except Exception:
return 0.5
def _calc_strength(
cluster: List[RawZonePoint],
config: StructureZoneConfig,
recency_score: float,
ema52_aligned: bool,
ema52_distance_pct: float,
width_pct: float,
) -> float:
"""计算综合强度评分 (0-100)"""
# 组件 1: 结构类型多样性 (0-40)
structure_type_counts: Dict[str, int] = {}
for p in cluster:
structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1
total = sum(structure_type_counts.values())
structure_score = 0.0
for st, count in structure_type_counts.items():
weight = config.structure_weights.get(st, 0.5)
structure_score += weight * count
structure_score = min(structure_score / max(1, total), 1.0)
c1 = structure_score * 40
# 组件 2: 多周期确认 (0-25)
tf_set = set(p.timeframe for p in cluster)
tf_diversity = len(tf_set)
c2 = min(tf_diversity / 5, 1.0) * 25
# 组件 3: 区间紧密度 (0-15) — 越窄越强
tightness = max(0.0, 1.0 - (width_pct / 3.0))
c3 = tightness * 15
# 组件 4: Recency (0-10)
c4 = recency_score * 10
# 组件 5: EMA52 共振 (0-10)
if ema52_aligned:
ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0))
c5 = ema_proximity * 10
else:
c5 = 0.0
return c1 + c2 + c3 + c4 + c5
def _calc_confidence(
overlap_count: int,
tf_count: int,
cluster: List[RawZonePoint],
) -> float:
"""计算置信度 (0-1)"""
base = min(overlap_count / 6.0, 0.85)
# 多周期加分
tf_bonus = min(tf_count / 5.0, 0.1)
# 是否所有点都来自 sure 的 ZS
all_sure = all(p.is_sure for p in cluster)
sure_bonus = 0.05 if all_sure else 0.0
return min(base + tf_bonus + sure_bonus, 1.0)
# ============================================================
# Top-level pipeline
# ============================================================
def analyze_structure_zones(
tf_df_dict: Dict[str, Any],
ema_symbols: List[str],
current_price: Optional[float] = None,
config: Optional[StructureZoneConfig] = None,
) -> List[StructureZone]:
"""
一站式分析:提取 → 聚类 → 评分 → 返回排序后的 StructureZone 列表。
"""
if config is None:
config = StructureZoneConfig()
# 提取
raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config)
if not raw_points:
return []
# 获取当前价格
if current_price is None:
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema_val = tf_df_dict[tf_name].get_ema52()
if ema_val and ema_val > 0:
current_price = float(ema_val)
break
except Exception:
pass
if current_price is None:
current_price = 0.0
# EMA52 值
ema52_values = {}
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52()
except Exception:
ema52_values[tf_name] = None
# 最晚时间
latest_time = None
times = [p.candle_time for p in raw_points if p.candle_time]
if times:
latest_time = max(times)
# 聚类
clusters = cluster_raw_points(raw_points, config)
# 构建 & 评分
return build_structure_zones(clusters, current_price, ema52_values, latest_time, config)
def analyze_structure_zones_from_serialized(
analyses: Dict[str, Dict],
ema52_dict: Dict[str, Optional[float]],
current_price: float,
config: Optional[StructureZoneConfig] = None,
) -> List[StructureZone]:
"""
从已序列化的分析结果构建 StructureZone(用于 web API)。
"""
if config is None:
config = StructureZoneConfig()
raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config)
if not raw_points:
return []
# 最晚时间
latest_time = None
times = [p.candle_time for p in raw_points if p.candle_time]
if times:
latest_time = max(times)
# EMA52 值(用于 alignment 检测)
ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0}
clusters = cluster_raw_points(raw_points, config)
return build_structure_zones(clusters, current_price, ema_values, latest_time, config)
+448
View File
@@ -0,0 +1,448 @@
import ccxt
import pandas as pd
import numpy as np
import mplfinance as mpf
from talib import MACD, SMA
from datetime import datetime, timedelta
import logging
import datetime as dt
# Configure logging
logging.basicConfig(
filename='chanlun_trading.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Configuration (user to modify)
BINANCE_API_KEY = 'your_api_key' # Replace with your Binance API key
BINANCE_API_SECRET = 'your_api_secret' # Replace with your Binance API secret
SIMULATION_MODE = True # Set to False for live trading
# 1. Fetch K-line data from Binance (multi-timeframe support)
def fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500):
try:
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
since = exchange.parse8601((datetime.now(dt.UTC) - timedelta(days=7)).isoformat())
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since, limit)
df = pd.DataFrame(ohlcv, columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume'])
df['Date'] = pd.to_datetime(df['Date'], unit='ms')
df.set_index('Date', inplace=True)
logging.info(f"Fetched {len(df)} K-lines for {symbol} ({timeframe})")
return df
except Exception as e:
logging.error(f"Failed to fetch data: {e}")
raise
# 2. K-line merging (vectorized)
def merge_kline(df):
try:
df = df.copy()
merged_data = []
trend = np.sign(df['Close'].diff().shift(-1)) # 1: up, -1: down, 0: neutral
# Detect inclusion
is_included = ((df['High'].shift(-1) <= df['High']) & (df['Low'].shift(-1) >= df['Low'])) | \
((df['High'].shift(-1) >= df['High']) & (df['Low'].shift(-1) <= df['Low']))
i = 0
while i < len(df) - 1:
if is_included.iloc[i]:
current_k = df.iloc[i]
next_k = df.iloc[i + 1]
high = max(current_k['High'], next_k['High'])
low = min(current_k['Low'], next_k['Low'])
open_price = current_k['Open']
close_price = next_k['Close'] if trend.iloc[i] >= 0 else next_k['Close']
volume = current_k['Volume'] + next_k['Volume']
merged_data.append({
'Date': next_k.name,
'Open': open_price,
'High': high,
'Low': low,
'Close': close_price,
'Volume': volume
})
i += 2
else:
current_k = df.iloc[i]
merged_data.append({
'Date': current_k.name,
'Open': current_k['Open'],
'High': current_k['High'],
'Low': current_k['Low'],
'Close': current_k['Close'],
'Volume': current_k['Volume']
})
i += 1
if i == len(df) - 1:
last_k = df.iloc[i]
merged_data.append({
'Date': last_k.name,
'Open': last_k['Open'],
'High': last_k['High'],
'Low': last_k['Low'],
'Close': last_k['Close'],
'Volume': last_k['Volume']
})
merged_df = pd.DataFrame(merged_data)
merged_df['Date'] = pd.to_datetime(merged_df['Date'])
merged_df.set_index('Date', inplace=True)
logging.info(f"Merged K-lines: {len(df)} -> {len(merged_df)}")
return merged_df
except Exception as e:
logging.error(f"K-line merging failed: {e}")
raise
# 3. Detect fractals (vectorized)
def detect_fractals(df):
try:
df = df.copy()
df['is_top'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & \
(df['High'] > df['High'].shift(2)) & (df['High'] > df['High'].shift(-2))
df['is_bottom'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & \
(df['Low'] < df['Low'].shift(2)) & (df['Low'] < df['Low'].shift(-2))
df['is_top'] = df['is_top'].fillna(False)
df['is_bottom'] = df['is_bottom'].fillna(False)
logging.info(f"Detected {df['is_top'].sum()} top fractals and {df['is_bottom'].sum()} bottom fractals")
return df
except Exception as e:
logging.error(f"Fractal detection failed: {e}")
raise
# 4. Detect strokes
def detect_strokes(df):
try:
strokes = []
last_fractal = None
last_price = None
last_index = None
for i in range(len(df)):
if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]:
current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom'
current_price = df['High'].iloc[i] if current_fractal == 'top' else df['Low'].iloc[i]
if last_fractal is None:
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
continue
if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \
(last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price):
strokes.append({
'start_time': last_index,
'end_time': df.index[i],
'start_price': last_price,
'end_price': current_price,
'type': 'down' if current_fractal == 'bottom' else 'up',
'volume': df['Volume'].loc[last_index:df.index[i]].sum()
})
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
logging.info(f"Detected {len(strokes)} strokes")
return strokes
except Exception as e:
logging.error(f"Stroke detection failed: {e}")
raise
# 5. Detect segments
def detect_segments(strokes):
try:
segments = []
if len(strokes) < 3:
return segments
i = 0
while i < len(strokes) - 2:
stroke1, stroke2, stroke3 = strokes[i], strokes[i+1], strokes[i+2]
if stroke1['type'] == 'up' and stroke2['type'] == 'down' and stroke3['type'] == 'up':
if stroke3['end_price'] > stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'up'
})
i += 3
else:
i += 1
elif stroke1['type'] == 'down' and stroke2['type'] == 'up' and stroke3['type'] == 'down':
if stroke3['end_price'] < stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'down'
})
i += 3
else:
i += 1
else:
i += 1
logging.info(f"Detected {len(segments)} segments")
return segments
except Exception as e:
logging.error(f"Segment detection failed: {e}")
raise
# 6. Detect pivots (midlines)
def detect_pivots(strokes):
try:
pivots = []
if len(strokes) < 3:
return pivots
for i in range(len(strokes) - 2):
s1, s2, s3 = strokes[i:i+3]
high = min(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
low = max(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
if high > low:
pivots.append({
'start_time': s1['start_time'],
'end_time': s3['end_time'],
'high': high,
'low': low
})
logging.info(f"Detected {len(pivots)} pivots")
return pivots
except Exception as e:
logging.error(f"Pivot detection failed: {e}")
raise
# 7. Analyze higher timeframe (30m)
def analyze_higher_timeframe(df_30m):
try:
df_30m = detect_fractals(df_30m)
strokes_30m = detect_strokes(df_30m)
if not strokes_30m:
return 'neutral'
last_stroke = strokes_30m[-1]
logging.info(f"30m trend: {last_stroke['type']}")
return last_stroke['type']
except Exception as e:
logging.error(f"Higher timeframe analysis failed: {e}")
raise
# 8. Back-divergence detection (enhanced)
def detect_back_divergence(df, strokes, higher_trend):
try:
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
sma20 = SMA(df['Close'], timeperiod=20)
df['macd'] = macd
df['hist'] = hist
df['sma20'] = sma20
df['buy_signal'] = False
df['sell_signal'] = False
stroke_metrics = []
for stroke in strokes:
start_idx = df.index.get_loc(stroke['start_time'])
end_idx = df.index.get_loc(stroke['end_time'])
hist_segment = df['hist'].iloc[start_idx:end_idx+1]
price_change = abs(stroke['end_price'] - stroke['start_price'])
hist_area = sum(abs(h) for h in hist_segment if not np.isnan(h))
volume = stroke['volume']
stroke_metrics.append({
'start_time': stroke['start_time'],
'end_time': stroke['end_time'],
'type': stroke['type'],
'price_change': price_change,
'hist_area': hist_area,
'volume': volume
})
for i in range(2, len(stroke_metrics)):
current_stroke = stroke_metrics[i]
prev_stroke = stroke_metrics[i-2]
if current_stroke['type'] != prev_stroke['type']:
continue
current_end_idx = df.index.get_loc(current_stroke['end_time'])
# Uptrend back-divergence (sell signal)
if current_stroke['type'] == 'up':
price_increase = df['High'].loc[current_stroke['end_time']] > df['High'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_top_fractal = df['is_top'].loc[current_stroke['end_time']]
hist_positive = df['hist'].iloc[current_end_idx] > 0 or \
(df['hist'].iloc[current_end_idx] < 0 and df['hist'].iloc[current_end_idx-1] > 0)
sma_trend = df['Close'].iloc[current_end_idx] > df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['up', 'neutral']
if price_increase and hist_decrease and volume_decrease and is_top_fractal and \
hist_positive and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'sell_signal'] = True
# Downtrend back-divergence (buy signal)
elif current_stroke['type'] == 'down':
price_decrease = df['Low'].loc[current_stroke['end_time']] < df['Low'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_bottom_fractal = df['is_bottom'].loc[current_stroke['end_time']]
hist_negative = df['hist'].iloc[current_end_idx] < 0 or \
(df['hist'].iloc[current_end_idx] > 0 and df['hist'].iloc[current_end_idx-1] < 0)
sma_trend = df['Close'].iloc[current_end_idx] < df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['down', 'neutral']
if price_decrease and hist_decrease and volume_decrease and is_bottom_fractal and \
hist_negative and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'buy_signal'] = True
logging.info(f"Detected {df['buy_signal'].sum()} buy signals and {df['sell_signal'].sum()} sell signals")
return df
except Exception as e:
logging.error(f"Back-divergence detection failed: {e}")
raise
# 9. Execute trade
def execute_trade(exchange, symbol, signal, amount=0.001):
try:
if SIMULATION_MODE:
msg = f"[SIMULATION] {'Buy' if signal == 'buy' else 'Sell'} {amount} {symbol} at {datetime.now(dt.UTC)}"
print(msg)
logging.info(msg)
return
if signal == 'buy':
order = exchange.create_market_buy_order(symbol, amount)
msg = f"Buy order executed: {order}"
print(msg)
logging.info(msg)
elif signal == 'sell':
order = exchange.create_market_sell_order(symbol, amount)
msg = f"Sell order executed: {order}"
print(msg)
logging.info(msg)
except Exception as e:
msg = f"Trade execution failed: {e}"
print(msg)
logging.error(msg)
# 10. Plot chart
def plot_chart(df, strokes, segments, pivots):
try:
# Initialize additional plots
apds = []
alines = [] # For line segments
# Plot strokes as line segments
for stroke in strokes:
alines.append([(stroke['start_time'], stroke['start_price']),
(stroke['end_time'], stroke['end_price'])])
# Plot segments as line segments
for segment in segments:
alines.append([(segment['start_time'], segment['start_price']),
(segment['end_time'], segment['end_price'])])
# Plot pivots as horizontal lines
for pivot in pivots:
alines.append([(pivot['start_time'], pivot['high']),
(pivot['end_time'], pivot['high'])])
alines.append([(pivot['start_time'], pivot['low']),
(pivot['end_time'], pivot['low'])])
# Add alines to plot (single color for simplicity, can customize)
if alines:
apds.append(mpf.make_addplot(
None, # No y-data needed for alines
alines=alines,
type='line',
color=['blue' if i < len(strokes) else 'purple' if i < len(strokes) + len(segments) else 'orange'
for i in range(len(alines))],
linestyle=['--' if i < len(strokes) else '-' if i < len(strokes) + len(segments) else ':'
for i in range(len(alines))]
))
# Plot buy/sell signals
buy_signals = df[df['buy_signal']]['Close']
sell_signals = df[df['sell_signal']]['Close']
apds.append(mpf.make_addplot(buy_signals, type='scatter', markersize=100, marker='^', color='green'))
apds.append(mpf.make_addplot(sell_signals, type='scatter', markersize=100, marker='v', color='red'))
# Plot K-line chart
mpf.plot(df, type='candle', addplot=apds, title='Chanlun Advanced Analysis', style='yahoo')
logging.info("Chart plotted successfully")
except Exception as e:
logging.error(f"Chart plotting failed: {e}")
raise
# 11. Main function
def main():
try:
# Initialize exchange
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
# Fetch data
df_5m = fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500)
df_30m = fetch_binance_data(symbol='BTC/USDT', timeframe='30m', limit=200)
# Merge 5m K-lines
df_5m = merge_kline(df_5m)
# Detect fractals, strokes, segments, pivots
df_5m = detect_fractals(df_5m)
strokes = detect_strokes(df_5m)
segments = detect_segments(strokes)
pivots = detect_pivots(strokes)
# Analyze 30m trend
higher_trend = analyze_higher_timeframe(df_30m)
print(f"30m Trend: {higher_trend}")
# Detect back-divergence
df_5m = detect_back_divergence(df_5m, strokes, higher_trend)
# Plot chart
plot_chart(df_5m, strokes, segments, pivots)
# Output and execute trades
print("Buy Signals:")
buy_signals = df_5m[df_5m['buy_signal']][['Close']]
print(buy_signals)
for idx, row in buy_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'buy', amount=0.001)
print("Sell Signals:")
sell_signals = df_5m[df_5m['sell_signal']][['Close']]
print(sell_signals)
for idx, row in sell_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'sell', amount=0.001)
logging.info("Main function completed successfully")
except Exception as e:
logging.error(f"Main function failed: {e}")
raise
if __name__ == "__main__":
main()
View File
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分型强度检测配置文件
用于调整分型强度计算的各项参数和权重
"""
class FxStrengthConfig:
"""分型强度检测配置类"""
def __init__(self):
# ===== 权重配置 (总分100分) =====
self.price_difference_weight = 40 # 价格差异强度权重
self.breakthrough_weight = 20 # 突破历史点位权重
self.volume_weight = 15 # 成交量确认权重
self.rsi_divergence_weight = 15 # RSI背离权重
self.macd_divergence_weight = 10 # MACD背离权重
# ===== 价格差异参数 =====
self.price_diff_multiplier = 1000 # 价格差异放大倍数
self.max_price_score = 20 # 价格差异最高得分
# ===== 突破检测参数 =====
self.breakthrough_lookback = 10 # 回看K线数量
self.breakthrough_multiplier = 500 # 突破幅度放大倍数
self.max_breakthrough_score = 20 # 突破最高得分
# ===== 成交量参数 =====
self.volume_lookback = 5 # 计算平均成交量的回看期数
self.volume_multiplier = 10 # 成交量放大倍数
self.max_volume_score = 15 # 成交量最高得分
self.min_volume_ratio = 1.0 # 最小成交量比率
# ===== RSI背离参数 =====
self.rsi_divergence_divisor = 2 # RSI背离除数
self.max_rsi_score = 15 # RSI最高得分
# ===== MACD背离参数 =====
self.macd_divergence_multiplier = 100 # MACD背离放大倍数
self.max_macd_score = 10 # MACD最高得分
# ===== 强度等级阈值 =====
self.extreme_threshold = 80 # 极强分型阈值
self.strong_threshold = 60 # 强分型阈值
self.medium_threshold = 40 # 中等分型阈值
self.weak_threshold = 20 # 弱分型阈值
# ===== 其他参数 =====
self.min_strength = 0 # 最小强度分数
self.max_strength = 100 # 最大强度分数
def get_strength_level_name(self, strength):
"""根据强度分数获取等级名称"""
if strength >= self.extreme_threshold:
return "极强"
elif strength >= self.strong_threshold:
return ""
elif strength >= self.medium_threshold:
return "中等"
elif strength >= self.weak_threshold:
return ""
else:
return "极弱"
def is_strong_fractal(self, strength, custom_threshold=None):
"""判断是否为强分型"""
threshold = custom_threshold if custom_threshold is not None else self.strong_threshold
return strength >= threshold
def validate_config(self):
"""验证配置参数的合理性"""
total_weight = (self.price_difference_weight +
self.breakthrough_weight +
self.volume_weight +
self.rsi_divergence_weight +
self.macd_divergence_weight)
if total_weight != 100:
print(f"警告: 权重总和为{total_weight},不等于100")
if not (0 <= self.extreme_threshold <= 100):
print(f"警告: 极强阈值{self.extreme_threshold}不在合理范围内")
if not (self.weak_threshold < self.medium_threshold <
self.strong_threshold < self.extreme_threshold):
print("警告: 强度阈值设置不合理")
return True
def print_config(self):
"""打印当前配置"""
print("=== 分型强度检测配置 ===")
print(f"价格差异权重: {self.price_difference_weight}")
print(f"突破点位权重: {self.breakthrough_weight}")
print(f"成交量权重: {self.volume_weight}")
print(f"RSI背离权重: {self.rsi_divergence_weight}")
print(f"MACD背离权重: {self.macd_divergence_weight}")
print()
print("=== 强度等级阈值 ===")
print(f"极强: >={self.extreme_threshold}")
print(f"强: {self.strong_threshold}-{self.extreme_threshold-1}")
print(f"中等: {self.medium_threshold}-{self.strong_threshold-1}")
print(f"弱: {self.weak_threshold}-{self.medium_threshold-1}")
print(f"极弱: <{self.weak_threshold}")
# 默认配置实例
DEFAULT_CONFIG = FxStrengthConfig()
# 保守配置 (更严格的分型识别)
CONSERVATIVE_CONFIG = FxStrengthConfig()
CONSERVATIVE_CONFIG.price_difference_weight = 50
CONSERVATIVE_CONFIG.breakthrough_weight = 25
CONSERVATIVE_CONFIG.volume_weight = 15
CONSERVATIVE_CONFIG.rsi_divergence_weight = 10
CONSERVATIVE_CONFIG.macd_divergence_weight = 0
CONSERVATIVE_CONFIG.strong_threshold = 70
CONSERVATIVE_CONFIG.extreme_threshold = 85
# 激进配置 (更宽松的分型识别)
AGGRESSIVE_CONFIG = FxStrengthConfig()
AGGRESSIVE_CONFIG.price_difference_weight = 30
AGGRESSIVE_CONFIG.breakthrough_weight = 15
AGGRESSIVE_CONFIG.volume_weight = 20
AGGRESSIVE_CONFIG.rsi_divergence_weight = 20
AGGRESSIVE_CONFIG.macd_divergence_weight = 15
AGGRESSIVE_CONFIG.strong_threshold = 50
AGGRESSIVE_CONFIG.extreme_threshold = 70
# 技术指标重点配置 (重视技术指标背离)
TECHNICAL_CONFIG = FxStrengthConfig()
TECHNICAL_CONFIG.price_difference_weight = 25
TECHNICAL_CONFIG.breakthrough_weight = 15
TECHNICAL_CONFIG.volume_weight = 10
TECHNICAL_CONFIG.rsi_divergence_weight = 25
TECHNICAL_CONFIG.macd_divergence_weight = 25
if __name__ == "__main__":
print("=== 分型强度配置演示 ===\n")
configs = {
"默认配置": DEFAULT_CONFIG,
"保守配置": CONSERVATIVE_CONFIG,
"激进配置": AGGRESSIVE_CONFIG,
"技术指标配置": TECHNICAL_CONFIG
}
for name, config in configs.items():
print(f"=== {name} ===")
config.print_config()
config.validate_config()
print()