refactor: 缠论引擎迁入 chan/ 分层解耦,指标外置

将核心结构、指标与分析拆到 chan/{core,indicators,analysis,pipeline};
根目录保留兼容 shim;strategies 改为从 chan 包导入;买卖点经 bsp_macd 与 MACD 接合。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Porter
2026-08-03 14:47:13 +08:00
co-authored by Cursor
parent 2e905e7238
commit 2c1232555e
90 changed files with 9067 additions and 8535 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 ..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 ..core.ChanKLU import ChanKLU
from ..core.ChanKLC import ChanKLC
from ..core.ChanBI import ChanBI
from ..core.ChanSBI import ChanSBI
from ..core.ChanSEG import ChanSEG
from ..core.ChanZS import ChanZS
from ..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 ..pipeline.ChanLun 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))
+274
View File
@@ -0,0 +1,274 @@
from ..core.ChanKLU import ChanKLU
from ..core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
from .ChanMACDSeg import ChanMACDSeg
from .ChanMACDUnitTF import ChanMACDUnitTF
from .ChanMACDHistSet import ChanMACDHistSet
class ChanMACD():
def __init__(self, klu_list: list[ChanKLU]):
self.klu_list = klu_list
self.seg_list = []
self.unittf_list = []
self.histset_list = []
# 状态标记列表
self.high_position_list = [] # 高位列表
self.high_empty_list = [] # 高位空列表
self.return_zero_list = [] # 归零轴列表
self.cross0_up_list = [] # 向上穿越零轴列表
self.cross0_down_list = [] # 向下穿越零轴列表
# 计算段 / UnitTF / HistSet 及状态标记
self.cal_macd_state()
self.get_klu_sd_list()
def get_klu_sd(self):
if self.klu_list:
sd = self.klu_list[-1].separate_div
if sd > 1:
print(self.klu_list[-1].time, sd)
return True
return False
def get_klu_sd_list(self):
sd_list = []
if self.klu_list:
for klu in self.klu_list:
hist = klu.macdhist
signal = False
if klu.pre and klu.next:
if klu.signal > 0:
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
else:
signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal
sd = klu.separate_div
if sd > 1 and ((hist > 0 and hist < 200) or (hist < 0 and hist > -200)):
sd_list.append(klu.time)
#print(klu.time, sd)
return sd_list
def cal_macd_state(self):
last_seg = None
last_unittf = None
last_histset = None
last_klu = None
for klu in self.klu_list:
# initialise first histset
if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0:
continue
if last_histset is None:
if klu.macdhist > 0:
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(last_histset)
else:
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(last_histset)
else:
# initialise first seg and unittf
if last_seg is None:
# create histset afterwards
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
if klu.macdhist > 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
else:
if klu.macdhist < 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_klu.signal >= 0 and klu.signal < 0:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(last_unittf)
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf)
self.seg_list.append(last_seg)
elif last_klu.signal <= 0 and klu.signal > 0:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(last_unittf)
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf)
self.seg_list.append(last_seg)
# after the first seg and unittf
else:
# create histset afterwards
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
if klu.macdhist > 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_unittf:
last_unittf.add_histset(last_histset)
else:
if klu.macdhist < 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_unittf:
last_unittf.add_histset(last_histset)
if last_klu.signal >= 0 and klu.signal < 0:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.set_end_klu(last_klu)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_unittf = unittf
elif last_klu.signal <= 0 and klu.signal > 0:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.set_end_klu(last_klu)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_unittf = unittf
elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal:
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.add_unittf(unittf)
last_unittf = unittf
last_seg.add_klu(klu)
else:
if not last_unittf.is_end:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
last_klu = klu
klu.cal_macd_state()
#print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close)
return self.klu_list
def cal_macd(self):
last_seg = None
last_unittf = None
last_histset = None
histset = None
last_klu = None
for klu in self.klu_list:
klu.cal_macd_state()
print(klu.time, klu.macd_state)
# 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元
if last_seg is None:
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
# 初始化首个直方图集合(根据当前柱体正负)
if klu.macdhist >= 0:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset = histset
# 初始化首段
seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf)
self.seg_list.append(seg)
last_seg = seg
# 初始化首个UnitTF
unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset)
self.unittf_list.append(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
# 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根
last_klu = klu
continue
# 3) 直方图集合(基于当前 unittf)
if klu.macdhist >= 0:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
last_histset.add_klu(klu)
else:
# 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu:
last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
if last_histset:
last_histset.set_next(histset)
last_histset = histset
if last_unittf:
last_unittf.add_histset(histset)
else:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
last_histset.add_klu(klu)
else:
# 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu:
last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
if last_histset:
last_histset.set_next(histset)
last_histset = histset
if last_unittf:
last_unittf.add_histset(histset)
# 2) 过零切段(使用KLU中的穿越状态)
if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or
klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN):
# 结束旧 unittf
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
# 新的单位时间周期
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
# 收尾旧段
last_seg.set_end_klu(last_klu)
# 新段方向取反
new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_seg.add_unittf(unittf)
else:
# 4) UnitTF 状态机:用黄线Signal的归零轴
if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1:
#print(klu.time, klu.macd_state)
if klu.macd_state == Chan_MACD_STATE.RZ_UP:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
else:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
else:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
last_klu = klu
last_histset.set_end_klu(last_klu)
last_unittf.set_end_klu(last_klu, None)
last_seg.set_end_klu(last_klu)
return self.klu_list
+117
View File
@@ -0,0 +1,117 @@
from ..core.ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
class ChanMACDHistSet():
def __init__(self, index, start_time, start_klu, pre_histset, dir):
self.index = index
self.start_time = start_time
self.end_time = None
self.klu_list = []
self.klu_list.append(start_klu)
self.histset_dir = dir
self.next = None
self.pre = pre_histset
self.peak_klu = None
self.area = start_klu.macdhist
self.unittf_div = Chan_MACDUNITTF_DIV.UNDIV
self.middle_klu = None
self.div_count = 0
self.last_klu = start_klu
self.start_klu = start_klu
self.peak_div_list = []
self.middle_area = 0
self.total_macdhist = 0
def set_next(self, next_histset):
self.next = next_histset
def set_pre(self, pre_histset):
self.pre = pre_histset
def set_middle_klu(self, middle_klu):
self.middle_klu = middle_klu
#self.middle_area = abs(middle_klu.macdhist)
#self.middle_klu = None
def set_unittf_div(self, unittf_div):
self.unittf_div = unittf_div
def add_klu(self, klu):
klu.set_histset(self)
self.klu_list.append(klu)
self.area += abs(klu.macdhist)
if self.middle_klu:
self.middle_area += abs(klu.macdhist)
if self.middle_klu and self.middle_klu.index + 1 == klu.index:
self.low_klu = None
self.peak_klu = None
self.div_count = 0
self.peak_div_list = []
else:
if self.last_klu:
self.cal_macdhist_klu(klu)
self.last_klu = klu
def cal_macdhist_klu(self, klu):
if self.middle_klu:
if klu.index >= self.middle_klu.index + 2:
if klu.pre.pre:
if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist):
if self.peak_klu:
if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu.pre
#self.div_count = 0
#self.peak_div_list = []
else:
if klu.pre.macd * klu.pre.macdhist > 0:
self.peak_div_list.append(klu.pre)
self.div_count += 1
klu.pre.continue_div = True
else:
self.peak_klu = klu.pre
else:
if len(self.klu_list) >= 3:
if klu.pre.pre:
if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist):
if self.peak_klu:
if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu.pre
#self.div_count = 0
#self.peak_div_list = []
else:
if klu.pre.macd * klu.pre.macdhist > 0 and klu.pre.signal * klu.pre.macdhist > 0:
self.peak_div_list.append(klu.pre)
self.div_count += 1
klu.pre.continue_div = True
else:
self.peak_klu = klu.pre
def set_end_klu(self, end_klu):
self.end_klu = end_klu
self.end_time = end_klu.time
#if len(self.peak_div_list) > 0:
#klu = self.peak_div_list[-1]
#if klu.macd * klu.macdhist > 0:
#end_klu.continue_div = True
#print(end_klu.time, "Continue Div")
if self.start_klu.index == end_klu.index:
self.peak_klu = self.start_klu
if self.start_klu.index + 1 == end_klu.index:
if abs(self.start_klu.macdhist) > abs(end_klu.macdhist):
self.peak_klu = self.start_klu
else:
self.peak_klu = end_klu
if len(self.klu_list) >= 3 and self.peak_klu == None:
self.peak_klu = self.klu_list[0]
for klu in self.klu_list:
if abs(klu.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu
peak_str = ""
state_str = ""
for peak_div in self.peak_div_list:
peak_str += f"{peak_div.time}, "
state_str += f"{peak_div.macd_state}, "
total_macdhist = 0
first_klu = self.klu_list[0]
last_klu = self.klu_list[-1]
if (first_klu.macd > 0 and last_klu.macd > 0 and first_klu.macdhist > 0) or (first_klu.macd < 0 and last_klu.macd < 0 and first_klu.macdhist < 0):
for klu in self.klu_list:
self.total_macdhist += klu.macdhist
if abs(self.total_macdhist) < 150:
#print(self.end_time, "Total MACDHist: ", self.total_macdhist)
last_klu.separate_div = 99999
#if self.peak_klu and len(self.peak_div_list) > 0:
#print("Continue Div: ",self.start_time, "Peak:", self.peak_klu.time, "Div: ", peak_str, state_str)
+49
View File
@@ -0,0 +1,49 @@
from ..core.ChanEnum import Chan_MACDSEG_DIR
class ChanMACDSeg():
def __init__(self, index, start_time, start_klu, pre_seg, seg_dir, start_unittf):
self.index = index
self.start_time = start_time
self.end_time = None
self.start_klu = start_klu
self.end_klu = None
self.klu_list = []
self.klu_list.append(start_klu)
self.unittf_list = []
self.seg_dir = seg_dir
self.pre = pre_seg
self.next = None
self.high_klu = start_klu
self.low_klu = start_klu
self.ref_klu = None
self.unittf_list.append(start_unittf)
def set_next(self, next_seg):
self.next = next_seg
def set_pre(self, pre_seg):
self.pre = pre_seg
def add_klu(self, klu):
if klu:
self.klu_list.append(klu)
klu.set_seg(self)
if self.seg_dir == Chan_MACDSEG_DIR.ABOVE:
if klu.macdhist > self.high_klu.macdhist:
self.high_klu = klu
else:
if klu.macdhist < self.low_klu.macdhist:
self.low_klu = klu
else:
if klu.macdhist < self.high_klu.macdhist:
self.high_klu = klu
else:
if klu.macdhist > self.low_klu.macdhist:
self.low_klu = klu
if self.high_klu.index != self.start_klu.index:
self.ref_klu = self.high_klu
def add_unittf(self, unittf):
self.unittf_list.append(unittf)
unittf.set_next(self)
def set_end_klu(self, end_klu):
self.add_klu(end_klu)
self.end_klu = end_klu
self.end_time = end_klu.time
+141
View File
@@ -0,0 +1,141 @@
from ..core.ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
class ChanMACDUnitTF():
def __init__(self, index, start_time, start_klu, pre_unittf, unittf_dir, start_type, start_histset):
self.index = index
self.start_time = start_time
self.end_time = None
self.start_klu = start_klu
self.end_klu = None
self.klu_list = []
self.klu_list.append(start_klu)
self.histset_list = []
self.histset_list.append(start_histset)
self.div_count = 0
start_histset.set_middle_klu(start_klu)
self.next = None
self.pre = pre_unittf
self.unittf_dir = unittf_dir
self.start_type = start_type
self.end_type = None
self.peak_klu = None
self.div_type = Chan_MACDUNITTF_DIV.UNDIV
self.div_peak_list = []
self.is_end = False
def set_next(self, next_unittf):
self.next = next_unittf
def set_pre(self, pre_unittf):
self.pre = pre_unittf
def add_histset(self, histset):
self.histset_list.append(histset)
def add_klu(self, klu):
self.klu_list.append(klu)
self.cal_peak_div()
self.cal_macd_state()
def cal_peak_div(self):
self.div_count = 0
self.div_peak_list = []
self.peak_klu = None
if len(self.histset_list) == 0:
return
if len(self.histset_list) == 1:
self.div_type = self.histset_list[0].unittf_div
self.peak_klu = self.histset_list[0].peak_klu
else:
for index in range(0, len(self.histset_list)):
histset = self.histset_list[index]
if self.same_dir(histset):
#if histset.peak_klu:
#print("Unittf: ", self.start_klu.time, len(self.histset_list), histset.peak_klu.time)
if self.peak_klu:
if histset.peak_klu:
if abs(histset.peak_klu.macdhist) >= abs(self.peak_klu.macdhist):
self.peak_klu = histset.peak_klu
self.div_type = Chan_MACDUNITTF_DIV.UNDIV
self.div_count = 0
else:
self.div_type = Chan_MACDUNITTF_DIV.DISCRETE
self.div_count += 1
self.div_peak_list.append(histset.peak_klu)
#print("Unittf: ", self.start_klu.time)
if histset.peak_klu.macd > 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
histset.peak_klu.set_separate_div(self.div_count)
elif histset.peak_klu.macd < 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
histset.peak_klu.set_separate_div(self.div_count)
else:
if histset.peak_klu:
self.peak_klu = histset.peak_klu
def cal_macd_state(self):
if len(self.klu_list) > 0:
last_klu = self.klu_list[0]
macd_peak_klu = None
signal_peak_klu = None
for index in range(1, len(self.klu_list)):
klu = self.klu_list[index]
if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0:
continue
if self.start_type == Chan_MACDUNITTF_TYPE.CROSS0 or self.start_type == Chan_MACDUNITTF_TYPE.NEAR0:
if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE:
if macd_peak_klu is None:
if last_klu.macd < klu.macd:
if last_klu.signal < last_klu.macdhist:
last_klu.set_macd_state(Chan_MACD_STATE.UP)
else:
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
else:
macd_peak_klu = last_klu
last_klu.set_macd_state(Chan_MACD_STATE.PEAK)
elif klu.macd > macd_peak_klu.macd:
macd_peak_klu = None
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
elif last_klu.signal < klu.signal:
last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY)
elif signal_peak_klu is None:
signal_peak_klu = last_klu
last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY)
elif klu.signal > signal_peak_klu.signal:
signal_peak_klu = None
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
elif last_klu.macd < last_klu.signal:
last_klu.set_macd_state(Chan_MACD_STATE.RETURN_ZERO)
if self.return_zero(last_klu, klu):
self.end_type = Chan_MACDUNITTF_TYPE.NEAR0
self.is_end = True
self.end_klu = klu
self.end_time = klu.time
klu.set_macd_state(Chan_MACD_STATE.NEAR0)
#print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state, klu.macd_state)
break
#print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state)
last_klu = klu
def return_zero(self, last_klu, klu):
return_zero = False
if last_klu.close < last_klu.ema52 and klu.close > klu.ema52:
return_zero = True
return_zero = False
return return_zero
def same_dir(self, histset):
if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE:
return histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE
else:
return histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER
def set_end_klu(self, end_klu, end_type):
self.end_type = end_type
self.end_klu = end_klu
self.end_time = end_klu.time
self.is_end = True
self.cal_macd_state()
div_time = ""
for div in self.div_peak_list:
div_time += f"{div.time}, "
histset_time = ""
for histset in self.histset_list:
histset_time += f"{histset.start_time}, "
#if self.peak_klu and self.div_type == Chan_MACDUNITTF_DIV.DISCRETE:
#print("Cross Div: ", self.start_klu.time, self.peak_klu.time, self.div_count, self.div_type, self.unittf_dir, div_time, len(self.histset_list))
+478
View File
@@ -0,0 +1,478 @@
import sys
import os
from typing import Dict, List
from functools import reduce
from pandas import DataFrame
from datetime import datetime, timedelta, timezone
# 外部 chan.py 与本仓库包名 `chan` 在 macOS 大小写不敏感磁盘上冲突。
# 临时卸下本包后再导入上游,再恢复本包。
_EXT_ROOT = os.path.abspath("/Users/jack/Project/chan.py")
def _load_external_chan():
saved = {}
for key in list(sys.modules):
low = key.lower()
if low == "chan" or low.startswith("chan."):
saved[key] = sys.modules.pop(key)
inserted = False
if _EXT_ROOT not in sys.path:
sys.path.insert(0, _EXT_ROOT)
inserted = True
try:
from Chan import CChan as _CChan
from BuySellPoint.BS_Point import CBS_Point as _CBS_Point
from ChanConfig import CChanConfig as _CChanConfig
from Common.CEnum import (
AUTYPE as _AUTYPE,
DATA_SRC as _DATA_SRC,
KL_TYPE as _KL_TYPE,
DATA_FIELD as _DATA_FIELD,
BSP_TYPE as _BSP_TYPE,
FX_TYPE as _FX_TYPE,
BI_DIR as _BI_DIR,
KLINE_DIR as _KLINE_DIR,
SEG_DIR as _SEG_DIR,
)
from KLine.KLine_Unit import CKLine_Unit as _CKLine_Unit
from Common.CTime import CTime as _CTime
from Common.func_util import kltype_lt_day as _kltype_lt_day, str2float as _str2float
from Bi.Bi import CBi as _CBi
return {
"CChan": _CChan,
"CBS_Point": _CBS_Point,
"CChanConfig": _CChanConfig,
"AUTYPE": _AUTYPE,
"DATA_SRC": _DATA_SRC,
"KL_TYPE": _KL_TYPE,
"DATA_FIELD": _DATA_FIELD,
"BSP_TYPE": _BSP_TYPE,
"FX_TYPE": _FX_TYPE,
"BI_DIR": _BI_DIR,
"KLINE_DIR": _KLINE_DIR,
"SEG_DIR": _SEG_DIR,
"CKLine_Unit": _CKLine_Unit,
"CTime": _CTime,
"kltype_lt_day": _kltype_lt_day,
"str2float": _str2float,
"CBi": _CBi,
}
finally:
# 清除上游以 Chan/chan 注册的模块,避免污染本包
for key in list(sys.modules):
low = key.lower()
if low == "chan" or low.startswith("chan."):
sys.modules.pop(key, None)
sys.modules.update(saved)
if inserted and _EXT_ROOT in sys.path:
try:
sys.path.remove(_EXT_ROOT)
except ValueError:
pass
_ext = _load_external_chan()
CChan = _ext["CChan"]
CBS_Point = _ext["CBS_Point"]
CChanConfig = _ext["CChanConfig"]
AUTYPE = _ext["AUTYPE"]
DATA_SRC = _ext["DATA_SRC"]
KL_TYPE = _ext["KL_TYPE"]
DATA_FIELD = _ext["DATA_FIELD"]
BSP_TYPE = _ext["BSP_TYPE"]
FX_TYPE = _ext["FX_TYPE"]
BI_DIR = _ext["BI_DIR"]
KLINE_DIR = _ext["KLINE_DIR"]
SEG_DIR = _ext["SEG_DIR"]
CKLine_Unit = _ext["CKLine_Unit"]
CTime = _ext["CTime"]
kltype_lt_day = _ext["kltype_lt_day"]
str2float = _ext["str2float"]
CBi = _ext["CBi"]
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 ..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 .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()
+19
View File
@@ -0,0 +1,19 @@
"""分析层:结构 + 指标的接合(MACD 状态、买卖点确认、Zone 等)。"""
from .bsp_macd import (
ConfirmedBSP,
bi_macd_area,
check_bi_div,
check_bi_pair_div,
confirm_bsp,
)
from .ChanMACD import ChanMACD
__all__ = [
"ChanMACD",
"ConfirmedBSP",
"bi_macd_area",
"check_bi_div",
"check_bi_pair_div",
"confirm_bsp",
]
+113
View File
@@ -0,0 +1,113 @@
"""买卖点 × MACD:几何候选在 core,背驰确认在此接合。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, List, Optional, Sequence
from ..core.ChanBI import ChanBI
from ..core.ChanBSP import ChanBSP
from ..core.ChanEnum import Chan_BI_DIR, Chan_BSP_DIR, Chan_BSP_TYPE
from ..indicators.store import IndicatorStore
def bi_macd_area(bi: ChanBI, store: Optional[IndicatorStore] = None) -> float:
"""笔内同向 macdhist 累积面积。优先用 IndicatorStore,否则回退 klu.macdhist。"""
area = 0.0
for klc in bi.klc_list:
for klu in klc.klu_list:
if store is not None:
hist = store.get(klu.idx, "macdhist", 0) or 0
else:
hist = getattr(klu, "macdhist", 0) or 0
try:
hist = float(hist)
except (TypeError, ValueError):
hist = 0.0
if bi.dir == Chan_BI_DIR.UP and hist > 0:
area += hist
elif bi.dir == Chan_BI_DIR.DOWN and hist < 0:
area -= hist
return area
def check_bi_div(
zs,
leave_bi: ChanBI,
store: Optional[IndicatorStore] = None,
) -> bool:
"""一类买卖点背驰:离开笔相对进入笔同向 MACD 柱面积收敛。"""
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
if not enter_bi or enter_bi.dir != leave_bi.dir:
return False
leave_area = abs(bi_macd_area(leave_bi, store))
enter_area = abs(bi_macd_area(enter_bi, store))
return leave_area < enter_area
def check_bi_pair_div(
leave_bi: ChanBI,
compare_bi: ChanBI,
store: Optional[IndicatorStore] = None,
) -> bool:
"""两笔同向力度比较(离开笔面积 < 比较笔)。"""
leave_area = abs(bi_macd_area(leave_bi, store))
compare_area = abs(bi_macd_area(compare_bi, store))
return leave_area < compare_area
@dataclass
class ConfirmedBSP:
"""几何买卖点 + MACD 确认结果。"""
bsp: ChanBSP
div_confirmed: bool
hist_area: float = 0.0
compare_hist_area: float = 0.0
score: float = 0.0
macd_state: Any = None
@property
def type(self) -> Chan_BSP_TYPE:
return self.bsp.type
@property
def dir(self) -> Chan_BSP_DIR:
return self.bsp.dir
@property
def bi(self) -> ChanBI:
return self.bsp.bi
def confirm_bsp(
geo_bsp_list: Sequence[ChanBSP],
store: Optional[IndicatorStore] = None,
require_div_for_types: Optional[Sequence[Chan_BSP_TYPE]] = None,
) -> List[ConfirmedBSP]:
"""
将几何 BSP 升格为 ConfirmedBSP。
默认:B1/S1/T1 需要背驰确认;B3/S3 几何即可,div_confirmed=True。
"""
if require_div_for_types is None:
require_div_for_types = (
Chan_BSP_TYPE.B1,
Chan_BSP_TYPE.S1,
)
out: List[ConfirmedBSP] = []
for bsp in geo_bsp_list:
area = bi_macd_area(bsp.bi, store)
need_div = bsp.type in require_div_for_types
if need_div and bsp.zs is not None:
div_ok = check_bi_div(bsp.zs, bsp.bi, store)
else:
div_ok = True
out.append(
ConfirmedBSP(
bsp=bsp,
div_confirmed=div_ok,
hist_area=area,
score=1.0 if div_ok else 0.0,
)
)
return out
+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()
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分型强度检测使用示例
该文件展示如何使用ChanKLC类中新增的分型强度检测功能
"""
from ..core.ChanKLC import ChanKLC
from ..core.ChanEnum import Chan_FX_TYPE
from ..core import ChanKLU
def demo_fx_strength_detection():
"""
演示分型强度检测功能
"""
print("=== 分型强度检测功能演示 ===\n")
# 假设我们有一个已经确定为分型的KLC对象
# 这里仅为演示,实际使用中KLC对象应该通过正常流程创建
print("1. 分型强度计算方法:")
print(" - calculate_fx_strength(): 返回0-100的强度分数")
print(" - get_fx_strength_level(): 返回强度等级描述")
print(" - is_strong_fx(threshold): 判断是否为强分型")
print()
print("2. 强度评分维度 (总分100分):")
print(" - 价格差异强度: 40分 (与相邻K线的价格差异)")
print(" - 突破历史点位: 20分 (是否突破重要高低点)")
print(" - 成交量确认: 15分 (分型形成时的成交量)")
print(" - RSI背离确认: 15分 (价格与RSI的背离)")
print(" - MACD背离确认: 10分 (价格与MACD的背离)")
print()
print("3. 强度等级分类:")
print(" - 极强: 80-100分")
print(" - 强: 60-79分")
print(" - 中等: 40-59分")
print(" - 弱: 20-39分")
print(" - 极弱: 0-19分")
print()
print("4. 在特征数据中的应用:")
print(" 分型强度会自动集成到get_feature_data()方法返回的特征中:")
print(" - klc_fx_strength: 强度分数")
print(" - klc_fx_strength_level: 强度等级")
print(" - klc_is_strong_fx: 是否为强分型(布尔值)")
print(" - klc_fx_strength_extreme: 是否为极强分型")
print(" - klc_fx_strength_strong: 是否为强分型")
print(" - klc_fx_strength_medium: 是否为中等分型")
print(" - klc_fx_strength_weak: 是否为弱分型")
print(" - klc_fx_strength_very_weak: 是否为极弱分型")
print()
def analyze_fx_strength(klc):
"""
分析单个KLC的分型强度
Args:
klc: ChanKLC对象
"""
if klc.fx == Chan_FX_TYPE.UNKNOWN:
print(f"时间: {klc.start_time} - 无分型")
return
fx_type = "顶分型" if klc.fx == Chan_FX_TYPE.TOP else "底分型"
strength = klc.calculate_fx_strength()
strength_level = klc.get_fx_strength_level()
is_strong = klc.is_strong_fx()
print(f"时间: {klc.start_time}")
print(f"分型类型: {fx_type}")
print(f"强度分数: {strength}")
print(f"强度等级: {strength_level}")
print(f"是否强分型: {'' if is_strong else ''}")
print("-" * 30)
def filter_strong_fractals(klc_list, min_strength=60):
"""
筛选强分型
Args:
klc_list: KLC对象列表
min_strength: 最小强度阈值
Returns:
强分型列表
"""
strong_fractals = []
for klc in klc_list:
if klc.fx != Chan_FX_TYPE.UNKNOWN and klc.is_strong_fx(min_strength):
strong_fractals.append(klc)
return strong_fractals
def get_fractal_statistics(klc_list):
"""
获取分型强度统计信息
Args:
klc_list: KLC对象列表
Returns:
统计信息字典
"""
stats = {
'total_fractals': 0,
'top_fractals': 0,
'bottom_fractals': 0,
'extreme_strength': 0, # 极强
'strong_strength': 0, # 强
'medium_strength': 0, # 中等
'weak_strength': 0, # 弱
'very_weak_strength': 0,# 极弱
'avg_strength': 0
}
strengths = []
for klc in klc_list:
if klc.fx != Chan_FX_TYPE.UNKNOWN:
stats['total_fractals'] += 1
if klc.fx == Chan_FX_TYPE.TOP:
stats['top_fractals'] += 1
else:
stats['bottom_fractals'] += 1
strength = klc.calculate_fx_strength()
strengths.append(strength)
if strength >= 80:
stats['extreme_strength'] += 1
elif strength >= 60:
stats['strong_strength'] += 1
elif strength >= 40:
stats['medium_strength'] += 1
elif strength >= 20:
stats['weak_strength'] += 1
else:
stats['very_weak_strength'] += 1
if strengths:
stats['avg_strength'] = sum(strengths) / len(strengths)
return stats
if __name__ == "__main__":
demo_fx_strength_detection()
print("=== 使用建议 ===")
print("1. 在交易策略中,可以只关注强度>=60的分型")
print("2. 极强分型(>=80分)通常是重要的转折点")
print("3. 结合成交量和技术指标背离的分型更可靠")
print("4. 可以用分型强度来设置止损和止盈位置")
print("5. 分型强度可以作为机器学习模型的重要特征")
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
实时K线分型强弱判断示例
解决KLC滞后问题,提供即时的分型信号
"""
from ..core.ChanKLU import ChanKLU
from ..core.ChanEnum import Chan_FX_TYPE
import pandas as pd
from datetime import datetime, timedelta
class RealtimeFxAnalyzer:
"""实时分型分析器"""
def __init__(self):
self.klu_list = []
self.latest_signals = []
def add_kline(self, time, open_price, high, low, close, volume, indicators=None):
"""
添加新的K线数据并进行实时分析
Args:
time: 时间
open_price, high, low, close, volume: K线数据
indicators: 技术指标字典 {'macd': xx, 'rsi': xx, 'ma5': xx, ...}
"""
# 创建新的KLU对象
new_klu = ChanKLU(time, open_price, high, low, close, volume)
# 设置技术指标
if indicators:
new_klu.set_indicators(indicators)
# 设置索引
new_klu.set_idx(len(self.klu_list))
# 建立前后关系链
if len(self.klu_list) >= 1:
prev_klu = self.klu_list[-1]
new_klu.set_pre(prev_klu)
prev_klu.set_next(new_klu)
# 如果有足够的数据,设置前一根K线的next关系
if len(self.klu_list) >= 2:
prev_prev_klu = self.klu_list[-2]
prev_prev_klu.set_next(self.klu_list[-1])
self.klu_list.append(new_klu)
# 实时分析最近的K线分型
self._analyze_recent_fractals()
return new_klu
def _analyze_recent_fractals(self):
"""分析最近的分型情况"""
if len(self.klu_list) < 3:
return
# 检查倒数第二根K线的分型(因为需要左右两根K线确认)
target_idx = len(self.klu_list) - 2
if target_idx >= 1:
target_klu = self.klu_list[target_idx]
# 进行实时分型分析
target_klu.update_realtime_analysis()
# 如果发现分型,记录信号
if target_klu.fx_confirmed:
signal = target_klu.get_fx_signal()
signal_info = {
'time': target_klu.time,
'price': target_klu.close,
'signal_type': signal[0],
'strength': signal[1],
'suggestion': signal[2],
'fx_type': target_klu.fx_type
}
self.latest_signals.append(signal_info)
# 保持最近20个信号
if len(self.latest_signals) > 20:
self.latest_signals.pop(0)
print(f"🔔 分型信号: {signal_info['time']} - {signal_info['signal_type']} "
f"(强度: {signal_info['strength']}) - {signal_info['suggestion']}")
def get_latest_signal(self):
"""获取最新的分型信号"""
return self.latest_signals[-1] if self.latest_signals else None
def get_current_fx_status(self):
"""获取当前分型状态统计"""
if len(self.klu_list) < 10:
return {"status": "数据不足"}
recent_10 = self.klu_list[-10:]
top_fx_count = sum(1 for klu in recent_10 if klu.fx_type == Chan_FX_TYPE.TOP)
bottom_fx_count = sum(1 for klu in recent_10 if klu.fx_type == Chan_FX_TYPE.BOTTOM)
strong_fx_count = sum(1 for klu in recent_10 if klu.fx_strength >= 65)
return {
"最近10根K线": len(recent_10),
"顶分型数量": top_fx_count,
"底分型数量": bottom_fx_count,
"强分型数量": strong_fx_count,
"最新K线时间": recent_10[-1].time,
"最新信号": self.get_latest_signal()
}
def simulate_realtime_trading():
"""模拟实时交易场景"""
print("=== 实时K线分型分析示例 ===\n")
# 创建分析器
analyzer = RealtimeFxAnalyzer()
# 模拟实时K线数据流
base_time = datetime.now()
base_price = 100.0
print("开始接收K线数据...\n")
for i in range(20):
# 模拟价格波动
if i < 5: # 上涨阶段
price_change = 0.5
elif i < 10: # 下跌阶段
price_change = -0.8
elif i < 15: # 震荡阶段
price_change = 0.3 * ((-1) ** i)
else: # 再次上涨
price_change = 0.6
current_price = base_price + price_change
# 构造K线数据
open_price = base_price
high = max(open_price, current_price) + abs(price_change) * 0.2
low = min(open_price, current_price) - abs(price_change) * 0.2
close = current_price
volume = 1000 + i * 50
# 模拟技术指标
indicators = {
'ma5': base_price + (i - 10) * 0.1,
'ma10': base_price + (i - 10) * 0.05,
'rsi': 50 + (i % 7 - 3) * 10,
'macd': (i % 6 - 3) * 0.01,
'macdhist': (i % 4 - 2) * 0.005,
'volume_ratio': 1.0 + (i % 3 - 1) * 0.2
}
# 添加K线数据
kline_time = base_time + timedelta(minutes=i)
analyzer.add_kline(
time=kline_time.strftime("%Y-%m-%d %H:%M:%S"),
open_price=open_price,
high=high,
low=low,
close=close,
volume=volume,
indicators=indicators
)
base_price = current_price
# 每5根K线显示一次状态
if (i + 1) % 5 == 0:
status = analyzer.get_current_fx_status()
print(f"\n--- 第{i+1}根K线后的状态 ---")
for key, value in status.items():
if key != "最新信号":
print(f"{key}: {value}")
if "最新信号" in status and status["最新信号"]:
signal = status["最新信号"]
print(f"最新信号: {signal['signal_type']} (强度: {signal['strength']})")
print()
print("\n=== 所有分型信号汇总 ===")
for signal in analyzer.latest_signals:
print(f"{signal['time']} | {signal['signal_type']} | 强度: {signal['strength']} | {signal['suggestion']}")
def compare_latency():
"""对比KLC和KLU方法的延迟差异"""
print("\n=== 延迟对比分析 ===")
print("假设场景:连续包含关系的K线序列")
print("原始K线: K1, K2(包含K1), K3(包含K2), K4(突破), K5, K6")
print()
print("KLC方法:")
print("- 需要等待K4确认包含关系结束")
print("- KLC1 = [K1+K2+K3], 在K4完成时才确定")
print("- 分型检测: 需要等待KLC1, KLC2, KLC3")
print("- 实际延迟: 可能6-8根原始K线")
print()
print("KLU实时方法:")
print("- 每根K线完成时立即检测")
print("- K3完成时就能检测K2的分型状态")
print("- 实际延迟: 最多1根K线")
print()
print("延迟改善: 从6-8根K线缩短到1根K线")
print("时间价值: 在5分钟K线下,可节省25-40分钟的反应时间")
if __name__ == "__main__":
# 运行模拟
simulate_realtime_trading()
# 显示延迟对比
compare_latency()
+272
View File
@@ -0,0 +1,272 @@
"""
Phase 2: Run ChanPivotClassifier on real data, compute bi_out for each pivot,
export the dataset, and run single-variable statistics.
Usage: python test_classifier.py
"""
import csv
import sys
import os
# Ensure repo root is importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from TF_DF import TF_DF
from ChanPivotClassifier import ChanPivotClassifier
from ChanEnum import Chan_BI_DIR
# Monkey-patch: TF_DF.init_TF_DF calls self.get_zs_list() which was removed.
# Add it back as an alias for get_bi_zs_list.
if not hasattr(TF_DF, 'get_zs_list'):
TF_DF.get_zs_list = lambda self, bi_list, seg_list: self.get_bi_zs_list(bi_list)
def load_csv(path: str) -> list[dict]:
"""Load OHLCV CSV into list of dicts expected by TF_DF."""
import pandas as pd
df = pd.read_csv(path)
df.columns = [c.lower() for c in df.columns]
# TF_DF expects 'date' column
if 'timestamp' in df.columns:
df.rename(columns={'timestamp': 'date'}, inplace=True)
df['date'] = pd.to_datetime(df['date'])
return df
def compute_bi_out(zs, bi_list: list) -> object:
"""
Determine the first bi after the pivot's end_bi that breaks out of the pivot range.
A breakout is: bi.high > zs.gg (up) or bi.low < zs.dd (down).
"""
if zs.end_bi is None or not zs.is_sure:
return None
# Find end_bi position in bi_list
end_idx = None
for i, bi in enumerate(bi_list):
if bi is zs.end_bi or bi.index == zs.end_bi.index:
end_idx = i
break
if end_idx is None:
return None
# Look for the first bi after end_bi that breaks the pivot range
for i in range(end_idx + 1, len(bi_list)):
bi = bi_list[i]
if not bi.is_sure:
continue
# A breakout: goes above gg or below dd
if bi.high > zs.gg or bi.low < zs.dd:
return bi
return None
def run_pipeline(csv_path: str, symbol: str, timeframe: str, interval: int = 1):
"""Full pipeline: CSV → TF_DF → compute bi_out → ChanPivotClassifier."""
print(f"\n{'='*60}")
print(f"Processing: {symbol} {timeframe}")
print(f"{'='*60}")
# Step 1: Load data
df = load_csv(csv_path)
print(f"Loaded {len(df)} rows")
# Step 2: Run TF_DF pipeline
tf_df = TF_DF(df, interval, timeframe)
print(f"KLC count: {len(tf_df.klc_list)}")
print(f"BI count: {len(tf_df.bi_list)}")
# Get bi_zs_list via the seg-based method (matching find_all_bsp)
bi_zs_list = tf_df.cal_bi_zs(tf_df.seg_list)
print(f"Pivot count (raw): {len(bi_zs_list)}")
# Filter to sure pivots with enough internal strokes
sure_pivots = [zs for zs in bi_zs_list if zs.is_sure and len(zs.bi_list) >= 3]
print(f"Pivot count (sure, >=3 strokes): {len(sure_pivots)}")
# Step 3: Compute bi_out for each pivot
for zs in sure_pivots:
zs.bi_out = compute_bi_out(zs, tf_df.bi_list)
bi_out_count = sum(1 for zs in sure_pivots if zs.bi_out is not None)
print(f"Pivots with bi_out: {bi_out_count}/{len(sure_pivots)}")
# Step 4: Run ChanPivotClassifier
classifier = ChanPivotClassifier(sure_pivots, symbol=symbol, timeframe=timeframe)
dataset = classifier.extract()
print(f"Dataset samples: {len(dataset)}")
# Step 5: Export
output_path = f"/tmp/chan_dataset_{symbol.replace('/', '_')}_{timeframe}.json"
count = classifier.export_json(output_path)
print(f"Exported {count} samples to {output_path}")
return dataset
def run_statistics(dataset: list[dict]):
"""Phase 2 statistics: single-variable analysis."""
print(f"\n{'='*60}")
print("Phase 2 — Single-Variable Statistics")
print(f"{'='*60}\n")
if not dataset:
print("No data to analyze.")
return
total = len(dataset)
up = [d for d in dataset if d["label"] == "up"]
down = [d for d in dataset if d["label"] == "down"]
none_ = [d for d in dataset if d["label"] == "none"]
print(f"Total samples: {total}")
print(f" Up: {len(up)} ({len(up)/total*100:.1f}%)")
print(f" Down: {len(down)} ({len(down)/total*100:.1f}%)")
print(f" None: {len(none_)} ({len(none_)/total*100:.1f}%)")
# ================================================================
# Feature 1: contraction vs break direction
# ================================================================
print(f"\n--- Feature: contraction (convergence rate) ---")
for label, subset in [("up", up), ("down", down), ("none", none_)]:
if not subset:
continue
contractions = [d["contraction"] for d in subset]
avg = sum(contractions) / len(contractions)
print(f" {label}: mean contraction = {avg:.4f}")
# Contraction < 0.7 → P(up)?
high_contraction = [d for d in dataset if d["contraction"] < 0.7]
if high_contraction:
up_in_hc = len([d for d in high_contraction if d["label"] == "up"])
down_in_hc = len([d for d in high_contraction if d["label"] == "down"])
print(f"\n Contraction < 0.7 (converging): {len(high_contraction)} samples")
print(f" P(up) = {up_in_hc/len(high_contraction)*100:.1f}%")
print(f" P(down) = {down_in_hc/len(high_contraction)*100:.1f}%")
# Contraction > 1.2 → P(down)?
low_contraction = [d for d in dataset if d["contraction"] > 1.2]
if low_contraction:
up_in_lc = len([d for d in low_contraction if d["label"] == "up"])
down_in_lc = len([d for d in low_contraction if d["label"] == "down"])
print(f"\n Contraction > 1.2 (expanding): {len(low_contraction)} samples")
print(f" P(up) = {up_in_lc/len(low_contraction)*100:.1f}%")
print(f" P(down) = {down_in_lc/len(low_contraction)*100:.1f}%")
# ================================================================
# Feature 2: shift_norm vs break direction
# ================================================================
print(f"\n--- Feature: shift_norm (center drift) ---")
for label, subset in [("up", up), ("down", down), ("none", none_)]:
if not subset:
continue
shifts = [d["shift_norm"] for d in subset]
avg = sum(shifts) / len(shifts)
print(f" {label}: mean shift_norm = {avg:.4f}")
# shift > 0 → P(up)?
shift_up = [d for d in dataset if d["shift_norm"] > 0]
if shift_up:
up_in_su = len([d for d in shift_up if d["label"] == "up"])
down_in_su = len([d for d in shift_up if d["label"] == "down"])
print(f"\n shift_norm > 0 (drifting up): {len(shift_up)} samples")
print(f" P(up) = {up_in_su/len(shift_up)*100:.1f}%")
print(f" P(down) = {down_in_su/len(shift_up)*100:.1f}%")
# shift < 0 → P(down)?
shift_down = [d for d in dataset if d["shift_norm"] < 0]
if shift_down:
up_in_sd = len([d for d in shift_down if d["label"] == "up"])
down_in_sd = len([d for d in shift_down if d["label"] == "down"])
print(f"\n shift_norm < 0 (drifting down): {len(shift_down)} samples")
print(f" P(up) = {up_in_sd/len(shift_down)*100:.1f}%")
print(f" P(down) = {down_in_sd/len(shift_down)*100:.1f}%")
# ================================================================
# Feature 3: duration_norm vs break direction
# ================================================================
print(f"\n--- Feature: duration_norm (relative duration) ---")
for label, subset in [("up", up), ("down", down), ("none", none_)]:
if not subset:
continue
durations = [d["duration_norm"] for d in subset]
avg = sum(durations) / len(durations)
print(f" {label}: mean duration_norm = {avg:.4f}")
# ================================================================
# Combined: contraction < 0.7 AND shift_norm > 0 → P(up)?
# ================================================================
print(f"\n--- Combined signals ---")
converging_up = [d for d in dataset if d["contraction"] < 0.7 and d["shift_norm"] > 0]
if converging_up:
up_in_cu = len([d for d in converging_up if d["label"] == "up"])
down_in_cu = len([d for d in converging_up if d["label"] == "down"])
print(f" Contraction < 0.7 AND shift_norm > 0: {len(converging_up)} samples")
print(f" P(up) = {up_in_cu/len(converging_up)*100:.1f}%")
print(f" P(down) = {down_in_cu/len(converging_up)*100:.1f}%")
converging_down = [d for d in dataset if d["contraction"] < 0.7 and d["shift_norm"] < 0]
if converging_down:
up_in_cd = len([d for d in converging_down if d["label"] == "up"])
down_in_cd = len([d for d in converging_down if d["label"] == "down"])
print(f" Contraction < 0.7 AND shift_norm < 0: {len(converging_down)} samples")
print(f" P(up) = {up_in_cd/len(converging_down)*100:.1f}%")
print(f" P(down) = {down_in_cd/len(converging_down)*100:.1f}%")
return dataset
def extract_symbol(csv_name: str) -> str:
"""Extract symbol from filename like 'BTC_USDT_1d.csv'."""
parts = csv_name.replace(".csv", "").split("_")
if len(parts) >= 2:
return f"{parts[0]}/{parts[1]}"
return csv_name
def extract_timeframe(csv_name: str) -> str:
"""Extract timeframe from filename like 'BTC_USDT_1d.csv'."""
parts = csv_name.replace(".csv", "").split("_")
if len(parts) >= 3:
return parts[2]
return "1d"
if __name__ == "__main__":
import glob
import json
data_dir = "/Users/jack/Project/freqtrade/binance_data"
csv_files = sorted(glob.glob(f"{data_dir}/*_USDT_1h.csv"))
if not csv_files:
print("No data files found.")
sys.exit(1)
print(f"Found {len(csv_files)} data files:")
for f in csv_files:
print(f" {os.path.basename(f)}")
# Batch process all coins
all_data = []
for csv_path in csv_files:
basename = os.path.basename(csv_path)
symbol = extract_symbol(basename)
timeframe = extract_timeframe(basename)
try:
dataset = run_pipeline(csv_path, symbol, timeframe)
all_data.extend(dataset)
except Exception as e:
print(f" ERROR: {symbol}{e}")
# Export combined dataset
combined_path = "/tmp/chan_dataset_all_coins.json"
with open(combined_path, "w", encoding="utf-8") as f:
json.dump(all_data, f, indent=2, ensure_ascii=False, default=str)
print(f"\nCombined dataset: {len(all_data)} samples → {combined_path}")
# Run statistics on combined dataset
run_statistics(all_data)