Author SHA1 Message Date
jackyu66git 7e16edd2c9 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:14:20 +08:00
jackyu66gitandCursor 5c10e35b76 refactor(web): 移除主图威科夫选项与叠层
去掉区间/阶段/时间/VP 开关、Cycle 摘要面板及绘制逻辑;分析请求默认 include_wyckoff=0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 01:27:51 +08:00
jackyu66gitandCursor 8c165f11cd fix(web): 未完成笔/线段终点对齐图表最新 K 线
各周期使用对应 kline 数据,终点时间 snap 到 candles,优先使用分析 end_price。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 00:40:41 +08:00
jackyu66gitandCursor 97e77847d0 fix(web): 开关缠论元素保留视窗;分周期 Trend 涨跌配色
本地重绘统一冻结视窗;次/次次周期 Trend 上涨下跌使用独立颜色。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:56:55 +08:00
jackyu66gitandCursor 90499533fb fix(web): 分析/自动刷新后保留 K 线视窗位置
拆分手动分析与自动刷新拉数路径;全量重建用 logical 优先恢复视窗,
增量 recent 用 scroll+barDelta;避免 barSpacing 重锚与重复冻结导致往右跳。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:38:20 +08:00
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00
128 changed files with 19998 additions and 6442 deletions
-4
View File
@@ -40,7 +40,3 @@ feature_meta
.DS_Store .DS_Store
data_provider/._config.json data_provider/._config.json
.gstack/ .gstack/
# ESS gate / engineering-loop working dirs(归档进 docs/runs/
.gates/
loop/
+171
View File
@@ -0,0 +1,171 @@
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
不改 init_TF_DF 的整段语义。笔必须整表重扫:最后一笔 is_sure 允许收回
OWN_CHAN_ZS_001 上 60 天出现 7 次)。笔中枢用 cal_bi_zs_list_pure。
"""
from __future__ import annotations
from datetime import datetime
import pandas as pd
from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
from chanlun.core.ChanKLU import ChanKLU
class IncrementalBuilderMixin:
def init_stream(self, df, interval=1, timeframe=None):
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
if df is None or df.empty:
raise ValueError("DataFrame for stream is empty.")
if "date" not in df.columns:
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
self.timeframe = timeframe
self.interval = interval
if interval == 1:
self.dataframe = df.copy()
else:
self.dataframe = resample_to_interval(df, interval)
self.dataframe = self.add_indicators(self.dataframe)
self.klu_list = []
self.klc_list = []
self.bi_list = []
self.bi_zs_list = []
self.seg_list = []
self.zs_list = []
self.bsp_list = []
self.klc_fx_list = []
self.big_zs_list = []
self._klc_feed_last_klu = None
for i in range(len(self.dataframe)):
self._append_row_at(i, rebuild=False)
self.rebuild_bi_zs()
return self
def append_bar(self, row):
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
self._ensure_stream_state()
item = self._normalize_row(row)
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
return self.replace_last_bar(item)
self._append_item_to_dataframe(item)
self.dataframe = self.add_indicators(self.dataframe)
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
return self
def replace_last_bar(self, row):
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
self._ensure_stream_state()
if not self.klu_list:
return self.append_bar(row)
item = self._normalize_row(row)
idx = self.dataframe.index[-1]
for key, val in item.items():
self.dataframe.at[idx, key] = val
self.dataframe = self.add_indicators(self.dataframe)
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
self._rebuild_klc_from_klu()
self.rebuild_bi_zs()
return self
def rebuild_bi_zs(self):
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
self._reset_klc_bi_marks(self.klc_list)
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
return self.bi_zs_list
def _ensure_stream_state(self):
if not hasattr(self, "klu_list") or self.klu_list is None:
self.klu_list = []
if not hasattr(self, "klc_list") or self.klc_list is None:
self.klc_list = []
if not hasattr(self, "dataframe") or self.dataframe is None:
self.dataframe = DataFrame(
columns=["date", "open", "high", "low", "close", "volume"]
)
if not hasattr(self, "_klc_feed_last_klu"):
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
if not hasattr(self, "bi_zs_list"):
self.bi_zs_list = []
def _rebuild_klc_from_klu(self):
self.klc_list = []
last_klu = None
for klu in self.klu_list:
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
last_klu = klu
self._klc_feed_last_klu = last_klu
def _append_row_at(self, idx, rebuild=True):
item = self.dataframe.iloc[idx]
klu = self._klu_from_item(item, idx)
if self.klu_list:
self.klu_list[-1].set_next(klu)
klu.set_pre(self.klu_list[-1])
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
self._klc_feed_last_klu = klu
self.klu_list.append(klu)
if rebuild:
self.rebuild_bi_zs()
def _klu_from_item(self, item, idx):
klu = ChanKLU(
self._item_time_str(item),
item["open"],
item["high"],
item["low"],
item["close"],
item["volume"],
)
klu.set_idx(idx)
if not hasattr(klu, "ema13"):
klu.ema13 = 0
if "macd" in item:
klu.set_indicators(item)
return klu
def _apply_item_to_klu(self, klu, item):
klu.time = self._item_time_str(item)
klu.open = item["open"]
klu.high = item["high"]
klu.low = item["low"]
klu.close = item["close"]
klu.volume = item["volume"]
klu.range = klu.high - klu.low
klu.body = abs(klu.close - klu.open)
if "macd" in item:
klu.set_indicators(item)
def _reset_klc_bi_marks(self, klc_list):
for klc in klc_list:
klc.fx = Chan_FX_TYPE.UNKNOWN
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
klc.klc_state = Chan_KLC_STATE.UNKNOWN
klc.bi = None
klc.fx_confirmed = False
def _item_time_str(self, item):
date = item["date"]
if hasattr(date, "to_pydatetime"):
date = date.to_pydatetime()
if isinstance(date, datetime):
return date.strftime("%Y-%m-%d %H:%M:%S")
return str(date)
def _row_time_str(self, item):
return self._item_time_str(item)
def _normalize_row(self, row):
if isinstance(row, pd.Series):
return row
return pd.Series(row)
def _append_item_to_dataframe(self, item):
row_df = DataFrame([item])
if self.dataframe is None or self.dataframe.empty:
self.dataframe = row_df
else:
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
+40 -36
View File
@@ -86,7 +86,8 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.UNKNOWN return Chan_FX_TYPE.UNKNOWN
def check_fx(self, klc): def check_fx(self, klc):
if klc.pre and klc.next: # 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
if klc.pre and klc.next and klc.next.end_klu is not None:
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP) klc.set_fx(Chan_FX_TYPE.TOP)
@@ -171,6 +172,43 @@ class KlineBuilderMixin:
def get_kl_data(self, dataframe:DataFrame): def get_kl_data(self, dataframe:DataFrame):
return self.cal_kl_data(dataframe) return self.cal_kl_data(dataframe)
def _push_klu_into_klc_list(self, klc_list, klu, last_klu):
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。"""
if len(klc_list) > 0:
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
included = last_klc.check_klu_included(klu)
if not included:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
last_klc.add_klu(klu)
else:
ddir = Chan_KLINE_DIR.UP
if klu.open > klu.close:
ddir = Chan_KLINE_DIR.DOWN
klc = ChanKLC(klu, 0, ddir)
klc_list.append(klc)
def get_klc_list(self, klu_list): def get_klc_list(self, klu_list):
klc_list = [] klc_list = []
last_klu = None last_klu = None
@@ -198,41 +236,7 @@ class KlineBuilderMixin:
ema_down_list.append(ema_down_count) ema_down_list.append(ema_down_count)
#print(last_klu.time, ema_down_count, "DOWN END") #print(last_klu.time, ema_down_count, "DOWN END")
ema_down_count = 0 ema_down_count = 0
if len(klc_list) > 0: self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
else:
included = last_klc.check_klu_included(klu)
if not included:
ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc)
last_klc.set_next(klc)
klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu)
klc.set_pre_fx()
else:
last_klc.add_klu(klu)
else:
ddir = Chan_KLINE_DIR.UP
if klu.open > klu.close:
ddir = Chan_KLINE_DIR.DOWN
klc = ChanKLC(klu, 0, ddir)
klc_list.append(klc)
last_klu = klu last_klu = klu
klc_list = self.cal_trend(klc_list) klc_list = self.cal_trend(klc_list)
#print(ema52_up_list, ema52_down_list) #print(ema52_up_list, ema52_down_list)
+11 -6
View File
@@ -355,9 +355,12 @@ class ZsBuilderMixin:
return bi_zs_list return bi_zs_list
def get_zs_range(bis): def get_zs_range(bis):
zg = min(bi.high for bi in bis) bis_list = bis[0:3]
zd = max(bi.low for bi in bis) zg = min(bi.high for bi in bis_list)
return zg, zd zd = max(bi.low for bi in bis_list)
dd = min(bi.low for bi in bis_list)
gg = max(bi.high for bi in bis_list)
return zg, zd, dd, gg
def is_bi_overlap_range(bi, zg, zd): def is_bi_overlap_range(bi, zg, zd):
return bi.high >= zd and bi.low <= zg return bi.high >= zd and bi.low <= zg
@@ -375,8 +378,8 @@ class ZsBuilderMixin:
zs.bi_list = list(bis) zs.bi_list = list(bis)
for bi in zs.bi_list: for bi in zs.bi_list:
bi.set_bi_zs(zs) bi.set_bi_zs(zs)
zs.set_gg(max(bi.high for bi in zs.bi_list)) #zs.set_gg(max(bi.high for bi in zs.bi_list))
zs.set_dd(min(bi.low for bi in zs.bi_list)) #zs.set_dd(min(bi.low for bi in zs.bi_list))
zs.classify_zs() zs.classify_zs()
last_zs = None last_zs = None
@@ -394,7 +397,7 @@ class ZsBuilderMixin:
start_idx += 1 start_idx += 1
continue continue
zg, zd = get_zs_range([bi1, bi2, bi3]) zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
if zg <= zd: if zg <= zd:
start_idx += 1 start_idx += 1
continue continue
@@ -420,6 +423,8 @@ class ZsBuilderMixin:
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg) zs.set_zg(zg)
zs.set_zd(zd) zs.set_zd(zd)
zs.set_dd(dd)
zs.set_gg(gg)
set_zs_bi_list(zs, bis_for_zs) set_zs_bi_list(zs, bis_for_zs)
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time) zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
+9
View File
@@ -178,6 +178,15 @@ class ChanLun():
def cal_bi_zs_list(self, bi_list): def cal_bi_zs_list(self, bi_list):
#return self.tf_df.cal_bi_zs(bi_list) #return self.tf_df.cal_bi_zs(bi_list)
return self.tf_df.cal_bi_zs_list(bi_list) return self.tf_df.cal_bi_zs_list(bi_list)
def cal_bi_zs_list_pure(self, bi_list):
return self.tf_df.cal_bi_zs_list_pure(bi_list)
def init_stream(self, dataframe, interval=1, timeframe=None):
self.tf_df.init_stream(dataframe, interval, timeframe)
return self.tf_df
def append_bar(self, row):
return self.tf_df.append_bar(row)
def replace_last_bar(self, row):
return self.tf_df.replace_last_bar(row)
def get_bi_zs_list(self, bi_list): def get_bi_zs_list(self, bi_list):
return self.tf_df.get_bi_zs_list(bi_list) return self.tf_df.get_bi_zs_list(bi_list)
def get_decimal(self, value): def get_decimal(self, value):
+4 -1
View File
@@ -31,12 +31,13 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD from chanlun.indicators.ChanMACD import ChanMACD
from chanlun.pipeline.builders.bi import BiBuilderMixin from chanlun.pipeline.builders.bi import BiBuilderMixin
from chanlun.pipeline.builders.bsp import BspBuilderMixin from chanlun.pipeline.builders.bsp import BspBuilderMixin
from chanlun.pipeline.builders.incremental import IncrementalBuilderMixin
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
from chanlun.pipeline.builders.kline import KlineBuilderMixin from chanlun.pipeline.builders.kline import KlineBuilderMixin
from chanlun.pipeline.builders.seg import SegBuilderMixin from chanlun.pipeline.builders.seg import SegBuilderMixin
from chanlun.pipeline.builders.zs import ZsBuilderMixin from chanlun.pipeline.builders.zs import ZsBuilderMixin
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin): class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin):
def __init__(self, df=None, interval=0, timeframe=None): def __init__(self, df=None, interval=0, timeframe=None):
if df is not None: if df is not None:
self.init_TF_DF(df, interval, timeframe) self.init_TF_DF(df, interval, timeframe)
@@ -59,12 +60,14 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
self.klc_list = [] self.klc_list = []
self.bi_list = [] self.bi_list = []
self.zs_list = [] self.zs_list = []
self.bi_zs_list = []
self.bsp_list = [] self.bsp_list = []
self.seg_list = [] self.seg_list = []
self.klc_fx_list = [] self.klc_fx_list = []
self.klu_list = self.cal_kl_data(self.dataframe) self.klu_list = self.cal_kl_data(self.dataframe)
self.klc_list = self.get_klc_list(self.klu_list) self.klc_list = self.get_klc_list(self.klu_list)
self.bi_list = self.cal_bi_list(self.klc_list) self.bi_list = self.cal_bi_list(self.klc_list)
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list)
self.seg_list = self.get_seg_list(self.bi_list) self.seg_list = self.get_seg_list(self.bi_list)
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list) self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
self.big_zs_list = self.get_big_zs_list(self.zs_list) self.big_zs_list = self.get_big_zs_list(self.zs_list)
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
import pandas as pd
_CHAN = Path(__file__).resolve().parents[2]
if str(_CHAN) not in sys.path:
sys.path.insert(0, str(_CHAN))
from chanlun.pipeline.timeframe import TF_DF # noqa: E402
def _zigzag_df(n=160, step=8):
dates = pd.date_range("2024-01-01", periods=n, freq="5min")
rows = []
price = 100.0
for i, date in enumerate(dates):
up = (i // step) % 2 == 0
if up:
o = price
c = price + 1.5
h = c + 0.3
l = o - 0.2
else:
o = price
c = price - 1.5
h = o + 0.2
l = c - 0.3
price = c
rows.append(
{
"date": date,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": 1.0,
}
)
return pd.DataFrame(rows)
def _sure_bi_key(bi):
return (str(bi.start_time), bi.dir.name, round(float(bi.high), 6), round(float(bi.low), 6))
def _zs_key(zs):
return (
str(zs.start_time),
round(float(zs.zg), 6),
round(float(zs.zd), 6),
len(zs.bi_list),
)
class TestIncremental(unittest.TestCase):
def test_init_stream_matches_batch_push(self):
df = _zigzag_df()
stream = TF_DF()
stream.init_stream(df, 1, "5m")
batch = TF_DF()
indexed = batch.add_indicators(df.copy())
klu = batch.cal_kl_data(indexed)
klc = []
last = None
for k in klu:
batch._push_klu_into_klc_list(klc, k, last)
last = k
batch.klc_list = klc
batch.rebuild_bi_zs()
self.assertEqual(len(stream.klu_list), len(klu))
self.assertEqual(len(stream.klc_list), len(klc))
self.assertEqual(
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
[_sure_bi_key(b) for b in batch.bi_list if b.is_sure],
)
self.assertEqual(
[_zs_key(z) for z in stream.bi_zs_list],
[_zs_key(z) for z in batch.bi_zs_list],
)
def test_append_bar_matches_init_stream(self):
df = _zigzag_df()
stream = TF_DF()
stream.init_stream(df, 1, "5m")
inc = TF_DF()
for _, row in df.iterrows():
inc.append_bar(row)
self.assertEqual(len(inc.klu_list), len(stream.klu_list))
self.assertEqual(len(inc.klc_list), len(stream.klc_list))
self.assertEqual(
[_sure_bi_key(b) for b in inc.bi_list if b.is_sure],
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
)
self.assertEqual(
[_zs_key(z) for z in inc.bi_zs_list],
[_zs_key(z) for z in stream.bi_zs_list],
)
def test_replace_last_bar_keeps_count(self):
df = _zigzag_df(n=80)
tf = TF_DF()
tf.init_stream(df, 1, "5m")
n_klu = len(tf.klu_list)
last = df.iloc[-1].copy()
last["close"] = float(last["close"]) + 0.01
last["high"] = max(float(last["high"]), float(last["close"]))
tf.replace_last_bar(last)
self.assertEqual(len(tf.klu_list), n_klu)
self.assertGreater(len(tf.klc_list), 0)
def test_check_fx_skips_forming_right_wing(self):
from types import SimpleNamespace
from chanlun.core.ChanEnum import Chan_FX_TYPE
tf = TF_DF()
pre = SimpleNamespace(high=10, low=8)
nxt_open = SimpleNamespace(high=11, low=7, end_klu=None)
nxt_done = SimpleNamespace(high=11, low=7, end_klu=object())
center = SimpleNamespace(
pre=pre,
next=nxt_open,
high=12,
low=9,
set_fx=lambda *_a, **_k: None,
)
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.UNKNOWN)
center.next = nxt_done
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.TOP)
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"strategy": "BTC_Maker_Micro_Scalper",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1m",
"process_only_new_candles": true,
"fee": 0.00016,
"unfilledtimeout": {
"entry": 1,
"exit": 1,
"exit_timeout_count": 3,
"unit": "minutes"
},
"order_types": {
"entry": "limit",
"exit": "limit",
"stoploss": "limit",
"stoploss_on_exchange": false
},
"order_time_in_force": {
"entry": "GTC",
"exit": "GTC"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "YOUR_BINANCE_API_KEY",
"secret": "YOUR_BINANCE_API_SECRET",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8821,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "change_me_mms_v1",
"ws_token": "change_me_mms_ws",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "BTC_Maker_Micro_Scalper",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 1
}
}
+98
View File
@@ -0,0 +1,98 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"strategy": "BTC_Maker_Micro_Scalper_v11",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper_v11.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1m",
"process_only_new_candles": true,
"fee": 0.00016,
"unfilledtimeout": {
"entry": 3,
"exit": 2,
"exit_timeout_count": 3,
"unit": "minutes"
},
"order_types": {
"entry": "limit",
"exit": "limit",
"stoploss": "limit",
"stoploss_on_exchange": false
},
"order_time_in_force": {
"entry": "GTC",
"exit": "GTC"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "YOUR_BINANCE_API_KEY",
"secret": "YOUR_BINANCE_API_SECRET",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8822,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "change_me_mms_v11",
"ws_token": "change_me_mms_v11_ws",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "BTC_Maker_Micro_Scalper_v11",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 1
}
}
+9 -2
View File
@@ -39,8 +39,15 @@
"name": "binance", "name": "binance",
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8", "key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l", "secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
"ccxt_config": {}, "ccxt_config": {
"ccxt_async_config": {}, "proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [ "pair_whitelist": [
"BTC/USDT:USDT" "BTC/USDT:USDT"
], ],
+91
View File
@@ -0,0 +1,91 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"strategy": "MakerEdgeProbe",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.maker_edge_probe.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1m",
"process_only_new_candles": false,
"fee": 0.00016,
"unfilledtimeout": {
"entry": 3,
"exit": 2,
"exit_timeout_count": 3,
"unit": "minutes"
},
"order_types": {
"entry": "limit",
"exit": "limit",
"stoploss": "market",
"stoploss_on_exchange": false
},
"order_time_in_force": {
"entry": "GTC",
"exit": "GTC"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "YOUR_BINANCE_API_KEY",
"secret": "YOUR_BINANCE_API_SECRET",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false
},
"api_server": {
"enabled": true,
"listen_ip_address": "127.0.0.1",
"listen_port": 8823,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "maker_edge_probe_change_me",
"ws_token": "maker_edge_probe_ws",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "MakerEdgeProbe",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 2
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.turtle_btc.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "15m",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 15,
"exit": 15,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8822,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "turtle-btc-change-me",
"ws_token": "turtle-btc-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "turtle_btc",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8823,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-btc-change-me",
"ws_token": "wyckoff-btc-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc_gated.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8825,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-gated-change-me",
"ws_token": "wyckoff-gated-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc_gated",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc_lps.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8824,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-lps-change-me",
"ws_token": "wyckoff-lps-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc_lps",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8823,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
"ws_token": "wyckoff-v1-baseline-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc_v1_baseline",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
+5
View File
@@ -0,0 +1,5 @@
"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0)."""
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"]
+342
View File
@@ -0,0 +1,342 @@
"""Walk-forward Wyckoff phase/event annotations for chart overlay."""
from __future__ import annotations
from datetime import date
from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase
from crypto_wyckoff.cycle import CycleEngine
from crypto_wyckoff.event import EventEngine
from crypto_wyckoff.features import FeatureEngine
from crypto_wyckoff.phase import PhaseEngine
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
_NOTABLE_EVENTS = {
WyckoffEvent.PS.value,
WyckoffEvent.SC.value,
WyckoffEvent.AR.value,
WyckoffEvent.ST.value,
WyckoffEvent.SPRING.value,
WyckoffEvent.TEST.value,
WyckoffEvent.SOS.value,
WyckoffEvent.LPS.value,
WyckoffEvent.JUMP.value,
WyckoffEvent.BACKUP.value,
WyckoffEvent.BC.value,
WyckoffEvent.UTAD.value,
WyckoffEvent.SOW.value,
WyckoffEvent.LPSY.value,
}
def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame:
n = end_idx + 1
return OHLCVFrame(
ts_code=frame.ts_code,
timeframe=frame.timeframe,
trade_dates=frame.trade_dates[:n],
open=frame.open[:n],
high=frame.high[:n],
low=frame.low[:n],
close=frame.close[:n],
volume=frame.volume[:n],
amount=frame.amount[:n] if frame.amount else [],
)
def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
"""points: [(date_iso, phase), ...] → segments."""
if not points:
return []
segs: list[dict] = []
start, phase = points[0]
prev = start
for d, p in points[1:]:
if p != phase:
segs.append({"start": start, "end": prev, "phase": phase})
start, phase = d, p
prev = d
segs.append({"start": start, "end": prev, "phase": phase})
return segs
def annotate_frame(
frame: OHLCVFrame,
step: int | None = None,
*,
role: str | None = None,
) -> dict:
"""Pure annotation: phase bands + event markers + latest levels.
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe.
``step`` defaults by role to keep interactive charts snappy.
"""
tf = role or frame.timeframe
min_bars = _MIN_BARS.get(tf, 30)
if step is None:
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
empty = {
"phases": [],
"events": [],
"levels": {},
"bars": len(frame),
"timeframe": tf,
}
if frame.empty or len(frame) < min_bars:
return empty
feat_eng = FeatureEngine()
cycle_eng = CycleEngine()
phase_eng = PhaseEngine()
event_eng = EventEngine()
phase_points: list[tuple[str, str]] = []
events: list[dict] = []
last_event: str | None = None
levels: dict = {}
# Ensure last bar is always evaluated
indices = list(range(min_bars - 1, len(frame), step))
if indices[-1] != len(frame) - 1:
indices.append(len(frame) - 1)
for i in indices:
sub = _slice_frame(frame, i)
f = feat_eng.run(sub, tf)
c = cycle_eng.run(f, tf)
p = phase_eng.run(c, f, tf)
e = event_eng.run(c, p, f, tf)
d = str(frame.trade_dates[i])[:10]
phase = p.payload.get("phase") or WyckoffPhase.NONE.value
phase_points.append((d, phase))
cur = e.payload.get("current_event") or WyckoffEvent.NONE.value
if cur in _NOTABLE_EVENTS and cur != last_event:
events.append({
"date": d,
"event": cur,
"price": float(frame.close[i]),
"low": float(frame.low[i]),
"high": float(frame.high[i]),
})
last_event = cur
elif cur == WyckoffEvent.NONE.value:
last_event = None
if i == len(frame) - 1 and not f.payload.get("insufficient"):
levels = {
k: f.payload.get(k)
for k in (
"range_high", "range_low", "ma20", "ma60",
"swing_high", "swing_low", "close",
)
if f.payload.get(k) is not None
}
levels["phase"] = phase
levels["cycle"] = c.payload.get("cycle")
levels["current_event"] = cur
return {
"phases": _compress_phases(phase_points),
"events": events,
"levels": levels,
"bars": len(frame),
"timeframe": tf,
}
_RANGE_CYCLES = {
WyckoffCycle.ACCUMULATION.value,
WyckoffCycle.RE_ACCUMULATION.value,
WyckoffCycle.DISTRIBUTION.value,
WyckoffCycle.RE_DISTRIBUTION.value,
}
def _build_range_zones(
price_frame: OHLCVFrame,
cycle_segs: list[dict],
levels: dict | None = None,
) -> list[dict]:
"""Build price boxes (high/low × date span) for accum/distrib ranges."""
if price_frame.empty:
return []
dates = [str(d)[:10] for d in price_frame.trade_dates]
highs = price_frame.high
lows = price_frame.low
zones: list[dict] = []
for seg in cycle_segs or []:
cy = seg.get("cycle")
if cy not in _RANGE_CYCLES:
continue
start, end = seg["start"], seg["end"]
idxs = [i for i, d in enumerate(dates) if start <= d <= end]
if not idxs:
# weekly bar date may sit between daily bars — take nearest window
i0 = next((i for i, d in enumerate(dates) if d >= start), None)
if i0 is None:
continue
i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1
idxs = list(range(i0, max(i0, i1) + 1))
if not idxs:
continue
# pad short weekly hits to at least ~1 week of dailies for visibility
if len(idxs) < 5 and idxs[-1] + 1 < len(dates):
extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1])
idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra)))
hi = max(highs[i] for i in idxs)
lo = min(lows[i] for i in idxs)
if hi <= lo:
continue
zones.append({
"kind": cy,
"start": dates[idxs[0]],
"end": dates[idxs[-1]],
"high": float(hi),
"low": float(lo),
"current": False,
})
# Always expose the latest trading-range box from feature snapshot
levels = levels or {}
rh, rl = levels.get("range_high"), levels.get("range_low")
if rh is not None and rl is not None and float(rh) > float(rl):
look = min(60, len(dates))
cy = levels.get("cycle") or "Unknown"
if cy not in _RANGE_CYCLES:
# Phase B/C in a range → treat as accumulation-style TR for display
ph = levels.get("phase") or ""
if ph in ("A", "B", "C"):
cy = WyckoffCycle.ACCUMULATION.value
elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh):
cy = WyckoffCycle.ACCUMULATION.value
else:
cy = "Range"
zones.append({
"kind": cy,
"start": dates[-look],
"end": dates[-1],
"high": float(rh),
"low": float(rl),
"current": True,
})
return zones
def annotate_symbol(
ts_code: str,
freq: str,
end_date: date | None = None,
lookback: int = 180,
*,
combo_id: str | None = None,
) -> dict:
"""IO + annotate for one symbol (used by API).
For the combo *low* chart, phase bands come from **mid** structure,
while event markers / levels come from the low TF.
"""
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo
from crypto_wyckoff.io import load_frame
combo = get_combo(combo_id)
allowed = {combo["low"], combo["mid"], combo["high"]}
if freq not in allowed:
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
empty = {
"ts_code": ts_code,
"freq": freq,
"phases": [],
"events": [],
"levels": {},
"zones": [],
"bars": 0,
"phase_source": freq,
"cycles": [],
"combo_id": combo["id"],
}
_ = end_date
if freq == combo["low"]:
low = load_frame(ts_code, combo["low"], lookback)
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3))
if low is None:
return empty
d_ann = annotate_frame(low, role=ROLE_LOW)
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []}
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else []
levels = d_ann.get("levels") or {}
if cycles:
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")}
for p in reversed(w_ann.get("phases") or []):
if p.get("phase") not in (None, "None"):
levels = {**levels, "phase": p["phase"]}
break
return {
"ts_code": ts_code,
"freq": freq,
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None,
"phases": w_ann.get("phases") or [],
"events": d_ann.get("events") or [],
"levels": d_ann.get("levels") or {},
"zones": _build_range_zones(low, cycles, levels),
"bars": d_ann.get("bars", 0),
"phase_source": combo["mid"],
"cycles": cycles,
"combo_id": combo["id"],
}
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH
frame = load_frame(ts_code, freq, lookback)
if frame is None:
return empty
out = annotate_frame(frame, role=role)
out["ts_code"] = ts_code
out["freq"] = freq
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None
out["phase_source"] = freq
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID)
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
out["combo_id"] = combo["id"]
if role == ROLE_HIGH:
if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
out["phases"] = [
{"start": c["start"], "end": c["end"], "phase": c["cycle"]}
for c in out["cycles"]
if c.get("cycle") and c["cycle"] != "Unknown"
]
return out
def _cycle_segments(
frame: OHLCVFrame,
step: int | None = None,
*,
role: str | None = None,
) -> list[dict]:
"""Walk-forward cycle labels compressed to segments."""
tf = role or frame.timeframe
min_bars = _MIN_BARS.get(tf, 30)
if step is None:
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
if frame.empty or len(frame) < min_bars:
return []
feat_eng = FeatureEngine()
cycle_eng = CycleEngine()
points: list[tuple[str, str]] = []
indices = list(range(min_bars - 1, len(frame), step))
if indices[-1] != len(frame) - 1:
indices.append(len(frame) - 1)
for i in indices:
sub = _slice_frame(frame, i)
f = feat_eng.run(sub, tf)
c = cycle_eng.run(f, tf)
points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown"))
segs = _compress_phases(points)
return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs]
+248
View File
@@ -0,0 +1,248 @@
"""Multi-timeframe combo presets for Crypto Wyckoff Screener.
Roles (engine rule aliases stay D/W/M):
high → Cycle (rules as 1M)
mid → Phase (rules as 1w)
low → Event (rules as 1d)
Actual bar TFs come from the combo (e.g. 8h/4h/1h).
"""
from __future__ import annotations
import json
import re
import threading
from copy import deepcopy
from pathlib import Path
from typing import Any
from crypto_wyckoff.io import DATA_DIR, ensure_dirs
ROLE_LOW = "1d"
ROLE_MID = "1w"
ROLE_HIGH = "1M"
# Minutes for ordering / validation (provider labels)
_TF_MINUTES: dict[str, int] = {
"1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5,
"10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45,
"1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300,
"6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600,
"11h": 660, "12h": 720, "16h": 960, "20h": 1200,
"1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640,
"1w": 10080, "2w": 20160, "3w": 30240,
"1M": 43200,
}
# TFs we allow in custom combos (provider-backed + local 1M)
ALLOWED_TFS: tuple[str, ...] = (
"1h", "2h", "3h", "4h", "6h", "8h", "12h",
"1d", "2d", "3d", "1w", "1M",
)
BUILTIN: list[dict[str, Any]] = [
{
"id": "h8_4_1",
"label": "8h / 4h / 1h",
"high": "8h",
"mid": "4h",
"low": "1h",
"builtin": True,
},
{
"id": "d_w_m",
"label": "1d / 1w / 1M",
"high": "1M",
"mid": "1w",
"low": "1d",
"builtin": True,
},
]
_COMBOS_FILE = DATA_DIR / "combos.json"
_lock = threading.Lock()
_cache: list[dict[str, Any]] | None = None
def tf_minutes(tf: str) -> int | None:
if tf in _TF_MINUTES:
return _TF_MINUTES[tf]
# tolerate provider typo "10" → skip
m = re.fullmatch(r"(\d+)([mhdwM])", tf)
if not m:
return None
n, u = int(m.group(1)), m.group(2)
mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u]
return n * mult
def combo_id_for(high: str, mid: str, low: str) -> str:
def _tok(t: str) -> str:
return t.replace("/", "_")
return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}"
def validate_combo(high: str, mid: str, low: str) -> str | None:
"""Return error message or None if ok."""
for tf in (high, mid, low):
if tf not in ALLOWED_TFS:
return f"不支持的周期: {tf}"
if len({high, mid, low}) < 3:
return "高/中/低周期必须互不相同"
hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low)
if hm is None or mm is None or lm is None:
return "无法解析周期长度"
if not (hm > mm > lm):
return "须满足 高 > 中 > 低(例如 8h > 4h > 1h"
return None
def _normalize(row: dict[str, Any]) -> dict[str, Any] | None:
high, mid, low = row.get("high"), row.get("mid"), row.get("low")
if not high or not mid or not low:
return None
err = validate_combo(str(high), str(mid), str(low))
if err:
return None
cid = str(row.get("id") or combo_id_for(high, mid, low))
label = str(row.get("label") or f"{high} / {mid} / {low}")
return {
"id": cid,
"label": label,
"high": str(high),
"mid": str(mid),
"low": str(low),
"builtin": bool(row.get("builtin", False)),
}
def _load_raw() -> list[dict[str, Any]]:
ensure_dirs()
if not _COMBOS_FILE.exists():
return deepcopy(BUILTIN)
try:
data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8"))
items = data.get("combos") if isinstance(data, dict) else data
if not isinstance(items, list):
return deepcopy(BUILTIN)
except (OSError, json.JSONDecodeError):
return deepcopy(BUILTIN)
out: list[dict[str, Any]] = []
seen: set[str] = set()
for b in BUILTIN:
out.append(deepcopy(b))
seen.add(b["id"])
for row in items:
if not isinstance(row, dict):
continue
norm = _normalize(row)
if not norm or norm["id"] in seen:
continue
if norm["id"] in {b["id"] for b in BUILTIN}:
continue
norm["builtin"] = False
out.append(norm)
seen.add(norm["id"])
return out
def _save(combos: list[dict[str, Any]]) -> None:
ensure_dirs()
custom = [c for c in combos if not c.get("builtin")]
payload = {"combos": custom}
tmp = _COMBOS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(_COMBOS_FILE)
def list_combos() -> list[dict[str, Any]]:
global _cache
with _lock:
if _cache is None:
_cache = _load_raw()
return deepcopy(_cache)
def get_combo(combo_id: str | None) -> dict[str, Any]:
combos = list_combos()
if combo_id:
for c in combos:
if c["id"] == combo_id:
return deepcopy(c)
return deepcopy(combos[0])
def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]:
err = validate_combo(high, mid, low)
if err:
raise ValueError(err)
cid = combo_id_for(high, mid, low)
row = {
"id": cid,
"label": label or f"{high} / {mid} / {low}",
"high": high,
"mid": mid,
"low": low,
"builtin": False,
}
with _lock:
combos = _load_raw()
for c in combos:
if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low):
_cache = combos
return deepcopy(c)
combos.append(row)
_save(combos)
_cache = combos
return deepcopy(row)
def delete_combo(combo_id: str) -> bool:
with _lock:
combos = _load_raw()
kept: list[dict[str, Any]] = []
removed = False
for c in combos:
if c["id"] == combo_id:
if c.get("builtin"):
raise ValueError("内置组合不可删除")
removed = True
continue
kept.append(c)
if removed:
_save(kept)
_cache = kept
return removed
def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]:
"""Unique TFs needed by active combos (stable order)."""
rows = combos if combos is not None else list_combos()
seen: list[str] = []
for c in rows:
for k in ("low", "mid", "high"):
tf = c[k]
if tf not in seen:
seen.append(tf)
return seen
def lookback_for(tf: str) -> int:
defaults = {
"1h": 500,
"2h": 400,
"3h": 350,
"4h": 300,
"6h": 280,
"8h": 250,
"12h": 220,
"1d": 250,
"2d": 200,
"3d": 180,
"1w": 104,
"1M": 60,
}
return defaults.get(tf, 200)
+102
View File
@@ -0,0 +1,102 @@
"""Cycle Engine — monthly/weekly macro cycle via Rule Registry."""
from __future__ import annotations
from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle
from crypto_wyckoff.rules.base import RuleHit
from crypto_wyckoff.rules.registry import rule_registry
def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]:
"""Accumulation vs Distribution overlap → mutually exclusive by MA120 position."""
accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value]
dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value]
if not (accum and dist):
return hits
close = float(features.get("close") or 0)
ma120 = float(features.get("ma120") or close) or close
others = [
h for h in hits
if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value)
]
# Below MA120 → accumulation; above → distribution; equal band uses relative position
if close < ma120 * 0.995:
return others + accum
if close > ma120 * 1.005:
return others + dist
# Tight band: keep higher confidence only
best_a = max(accum, key=lambda h: h.confidence)
best_d = max(dist, key=lambda h: h.confidence)
return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d])
class CycleEngine:
name = "Cycle"
version = "1.0.0"
def run(self, feature: EngineResult, timeframe: str) -> EngineResult:
features = feature.payload
if features.get("insufficient"):
return EngineResult(
name=self.name,
version=self.version,
confidence=15.0,
score=40.0,
reasons=[f"{timeframe} 数据不足,Cycle=Unknown"],
warnings=["insufficient_features"],
payload={
"cycle": WyckoffCycle.UNKNOWN.value,
"timeframe": timeframe,
"trend_score": 40.0,
},
)
context = {"features": features, "timeframe": timeframe}
hits: list[RuleHit] = []
for rule in rule_registry.by_category("cycle", timeframe):
hit = rule.evaluate(context)
if hit and hit.cycle:
hits.append(hit)
hits = _resolve_range_conflict(hits, features)
if not hits:
return EngineResult(
name=self.name,
version=self.version,
confidence=30.0,
score=40.0,
reasons=["无匹配周期规则,标记 Unknown"],
payload={
"cycle": WyckoffCycle.UNKNOWN.value,
"timeframe": timeframe,
"trend_score": 40.0,
},
)
best = max(hits, key=lambda h: h.confidence)
trend_score = best.score
if best.cycle == WyckoffCycle.MARKUP.value:
trend_score = max(trend_score, 75.0)
elif best.cycle == WyckoffCycle.ACCUMULATION.value:
trend_score = max(60.0, trend_score * 0.9)
elif best.cycle == WyckoffCycle.DISTRIBUTION.value:
trend_score = min(45.0, 100 - trend_score * 0.5)
elif best.cycle == WyckoffCycle.MARKDOWN.value:
trend_score = min(30.0, 100 - trend_score)
return EngineResult(
name=self.name,
version=self.version,
confidence=best.confidence,
score=trend_score,
reasons=best.reasons,
metrics=best.metrics,
payload={
"cycle": best.cycle,
"timeframe": timeframe,
"rule_id": best.rule_id,
"trend_score": trend_score,
},
)
+195
View File
@@ -0,0 +1,195 @@
"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0)."""
from __future__ import annotations
from crypto_wyckoff.domain_models import (
DecisionSignal,
EngineResult,
RiskLevel,
WyckoffCycle,
WyckoffEvent,
WyckoffPhase,
)
BULL_CYCLES = {
WyckoffCycle.ACCUMULATION.value,
WyckoffCycle.RE_ACCUMULATION.value,
WyckoffCycle.MARKUP.value,
}
BEAR_CYCLES = {
WyckoffCycle.DISTRIBUTION.value,
WyckoffCycle.RE_DISTRIBUTION.value,
WyckoffCycle.MARKDOWN.value,
}
class DecisionEngine:
name = "Decision"
version = "1.0.0"
def run(
self,
monthly_cycle: EngineResult,
weekly_cycle: EngineResult,
weekly_phase: EngineResult,
weekly_event: EngineResult,
daily_event: EngineResult,
daily_signal: EngineResult,
) -> EngineResult:
m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value)
w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value)
d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value)
trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score))
structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score))
entry_score = float(daily_event.payload.get("entry_score", daily_event.score))
overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score
reasons: list[str] = []
warnings: list[str] = []
alignment = 50.0
m_bull = m_cycle in BULL_CYCLES
m_bear = m_cycle in BEAR_CYCLES
w_bull = w_cycle in BULL_CYCLES
d_bullish_event = d_event in {
WyckoffEvent.SPRING.value,
WyckoffEvent.TEST.value,
WyckoffEvent.SOS.value,
WyckoffEvent.LPS.value,
WyckoffEvent.JUMP.value,
WyckoffEvent.BACKUP.value,
}
d_bearish_event = d_event in {
WyckoffEvent.UTAD.value,
WyckoffEvent.SOW.value,
WyckoffEvent.LPSY.value,
}
# Alignment scoring
if m_bull and w_bull and d_bullish_event:
alignment = 92.0
reasons.append("✓ 月/周多头结构与日线多头事件一致")
elif m_bull and d_bullish_event:
alignment = 78.0
reasons.append("✓ 月线支持,日线有入场事件")
if not w_bull:
warnings.append("周线结构未完全确认")
alignment -= 8
elif m_bear and d_bullish_event:
alignment = 35.0
reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹")
elif m_bear and d_bearish_event:
alignment = 85.0
reasons.append("✓ 空头多周期一致")
else:
alignment = 55.0
reasons.append("○ 多周期部分一致,需观察")
if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull:
alignment = min(98.0, alignment + 6)
reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event}")
active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or []
if d_event == WyckoffEvent.SPRING.value and len(active) >= 3:
alignment = min(98.0, alignment + 4)
reasons.append("✓ 日线多重事件同时确认")
# Decision signal — hard gate on monthly bear + daily spring
decision = DecisionSignal.WATCH.value
risk = RiskLevel.MEDIUM.value
if m_bear and d_event == WyckoffEvent.SPRING.value:
decision = DecisionSignal.WATCH.value
risk = RiskLevel.HIGH.value
overall_score = min(overall_score, 55.0)
reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)")
elif m_bear and d_bullish_event:
decision = DecisionSignal.AVOID.value
risk = RiskLevel.HIGH.value
overall_score = min(overall_score, 48.0)
reasons.append("→ 决策:回避(逆大周期多头事件)")
elif (
m_bull
and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value)
and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value)
and alignment >= 85
and overall_score >= 80
):
decision = DecisionSignal.STRONG_BUY.value
risk = RiskLevel.LOW.value
reasons.append("→ 决策:强烈买入(三级共振)")
elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70:
decision = DecisionSignal.BUY.value
risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value
reasons.append("→ 决策:买入")
elif m_bear and d_bearish_event and overall_score >= 65:
decision = DecisionSignal.SELL.value
risk = RiskLevel.MEDIUM.value
reasons.append("→ 决策:卖出")
else:
decision = DecisionSignal.WATCH.value
reasons.append("→ 决策:观察")
# Stars from score + alignment
combo = 0.6 * overall_score + 0.4 * alignment
if combo >= 90:
stars = 5
elif combo >= 80:
stars = 4
elif combo >= 65:
stars = 3
elif combo >= 50:
stars = 2
else:
stars = 1
overall_confidence = (
0.25 * monthly_cycle.confidence
+ 0.25 * weekly_phase.confidence
+ 0.25 * daily_event.confidence
+ 0.25 * daily_signal.confidence
)
# Weak event pulls overall down
if daily_event.confidence < 60:
overall_confidence = min(overall_confidence, daily_event.confidence + 15)
return EngineResult(
name=self.name,
version=self.version,
confidence=overall_confidence,
score=overall_score,
reasons=reasons,
warnings=warnings,
metrics={
"trend_score": trend_score,
"structure_score": structure_score,
"entry_score": entry_score,
"alignment": alignment,
"stars": stars,
},
payload={
"decision_signal": decision,
"alignment": alignment,
"stars": stars,
"risk": risk,
"overall_score": overall_score,
"overall_confidence": overall_confidence,
"trend_score": trend_score,
"structure_score": structure_score,
"entry_score": entry_score,
"m_cycle": m_cycle,
"w_cycle": w_cycle,
"w_phase": w_phase,
"w_event": w_event,
"d_event": d_event,
# Facts preserved — never overwritten
"facts": {
"monthly": {"cycle": m_cycle},
"weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event},
"daily": {"event": d_event},
},
},
)
+154
View File
@@ -0,0 +1,154 @@
"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import Enum
from typing import Any, Optional
class WyckoffCycle(str, Enum):
ACCUMULATION = "Accumulation"
RE_ACCUMULATION = "ReAccumulation"
MARKUP = "Markup"
DISTRIBUTION = "Distribution"
RE_DISTRIBUTION = "ReDistribution"
MARKDOWN = "Markdown"
UNKNOWN = "Unknown"
class WyckoffPhase(str, Enum):
A = "A"
B = "B"
C = "C"
D = "D"
E = "E"
NONE = "None"
class WyckoffEvent(str, Enum):
PS = "PS"
SC = "SC"
AR = "AR"
ST = "ST"
SPRING = "Spring"
TEST = "Test"
SOS = "SOS"
LPS = "LPS"
JUMP = "Jump"
BACKUP = "Backup"
BC = "BC"
UTAD = "UTAD"
SOW = "SOW"
LPSY = "LPSY"
NONE = "None"
class DecisionSignal(str, Enum):
STRONG_BUY = "StrongBuy"
BUY = "Buy"
WATCH = "Watch"
AVOID = "Avoid"
SELL = "Sell"
class RiskLevel(str, Enum):
LOW = "Low"
MEDIUM = "Medium"
HIGH = "High"
@dataclass
class EngineResult:
"""Unified result envelope for every Wyckoff engine (v1.0 contract)."""
name: str
version: str = "1.0.0"
confidence: float = 0.0
score: float = 0.0
reasons: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
metrics: dict[str, Any] = field(default_factory=dict)
payload: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"confidence": self.confidence,
"score": self.score,
"reasons": self.reasons,
"warnings": self.warnings,
"metrics": self.metrics,
"payload": self.payload,
}
@dataclass
class OHLCVFrame:
"""In-memory OHLCV for one symbol one timeframe. Engines never touch DB."""
ts_code: str
timeframe: str # "1d" | "1w" | "1M"
trade_dates: list[date]
open: list[float]
high: list[float]
low: list[float]
close: list[float]
volume: list[float]
amount: list[float] = field(default_factory=list)
def __len__(self) -> int:
return len(self.close)
@property
def empty(self) -> bool:
return len(self.close) == 0
@dataclass
class WyckoffScanRow:
"""Persisted scan row for wyckoff_scan table."""
trade_date: date
ts_code: str
name: str = ""
industry: str = ""
engine_version: str = "v1.0.0"
combo_id: str = "d_w_m"
m_cycle: str = WyckoffCycle.UNKNOWN.value
cycle_confidence: float = 0.0
trend_score: float = 0.0
w_cycle: str = WyckoffCycle.UNKNOWN.value
w_phase: str = WyckoffPhase.NONE.value
w_current_event: str = WyckoffEvent.NONE.value
w_recent_events_json: str = "[]"
phase_confidence: float = 0.0
structure_score: float = 0.0
d_current_event: str = WyckoffEvent.NONE.value
d_recent_events_json: str = "[]"
event_confidence: float = 0.0
entry_score: float = 0.0
entry: Optional[float] = None
stop: Optional[float] = None
target1: Optional[float] = None
target2: Optional[float] = None
rr: Optional[float] = None
alignment: float = 0.0
stars: int = 1
decision_signal: str = DecisionSignal.WATCH.value
signal_confidence: float = 0.0
overall_confidence: float = 0.0
overall_score: float = 0.0
risk: str = RiskLevel.MEDIUM.value
reasons_json: str = "[]"
feature_snapshot_json: str = "{}"
markers_json: str = "[]"
scanned_at: datetime = field(default_factory=datetime.now)
+149
View File
@@ -0,0 +1,149 @@
"""Event Engine — active concurrent events via Rule Registry.
Note: `active_events` are rules that fire on the latest bar snapshot,
NOT a historical SC→AR→ST timeline. Do not present as chronological chain.
"""
from __future__ import annotations
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
from crypto_wyckoff.rules.registry import rule_registry
# Display order only (not temporal history)
_DISPLAY_ORDER = [
WyckoffEvent.PS.value,
WyckoffEvent.SC.value,
WyckoffEvent.AR.value,
WyckoffEvent.ST.value,
WyckoffEvent.SPRING.value,
WyckoffEvent.TEST.value,
WyckoffEvent.SOS.value,
WyckoffEvent.LPS.value,
WyckoffEvent.JUMP.value,
WyckoffEvent.BACKUP.value,
WyckoffEvent.BC.value,
WyckoffEvent.UTAD.value,
WyckoffEvent.SOW.value,
WyckoffEvent.LPSY.value,
]
# Dominant event: highest confidence wins; ties broken by this priority
_DOMINANCE_PRIORITY = [
WyckoffEvent.SOS.value,
WyckoffEvent.LPS.value,
WyckoffEvent.UTAD.value,
WyckoffEvent.SPRING.value,
WyckoffEvent.JUMP.value,
WyckoffEvent.BACKUP.value,
WyckoffEvent.TEST.value,
WyckoffEvent.SC.value,
WyckoffEvent.SOW.value,
WyckoffEvent.AR.value,
WyckoffEvent.ST.value,
]
class EventEngine:
name = "Event"
version = "1.0.0"
def run(
self,
cycle: EngineResult,
phase: EngineResult,
feature: EngineResult,
timeframe: str,
) -> EngineResult:
if feature.payload.get("insufficient"):
return EngineResult(
name=self.name,
version=self.version,
confidence=20.0,
score=30.0,
reasons=["特征不足,跳过事件识别"],
warnings=["insufficient_features"],
payload={
"current_event": WyckoffEvent.NONE.value,
"active_events": [],
"recent_events": [], # alias for DB/API compat; same as active_events
"timeframe": timeframe,
"entry_score": 30.0,
},
)
context = {
"features": feature.payload,
"cycle": cycle.payload,
"phase": phase.payload,
"timeframe": timeframe,
}
hits = []
for rule in rule_registry.by_category("event", timeframe):
hit = rule.evaluate(context)
if hit and hit.event:
hits.append(hit)
if not hits:
return EngineResult(
name=self.name,
version=self.version,
confidence=35.0,
score=40.0,
reasons=["无显著事件"],
payload={
"current_event": WyckoffEvent.NONE.value,
"active_events": [],
"recent_events": [],
"timeframe": timeframe,
"entry_score": 40.0,
},
)
by_event: dict[str, float] = {}
reasons: list[str] = []
metrics: dict = {}
for h in hits:
prev = by_event.get(h.event, -1.0)
if h.confidence >= prev:
by_event[h.event] = h.confidence
reasons.extend(h.reasons)
metrics.update(h.metrics)
active = [e for e in _DISPLAY_ORDER if e in by_event]
for e in by_event:
if e not in active:
active.append(e)
# Dominant = max confidence; tie-break by dominance priority index
def _dom_key(ev: str) -> tuple:
conf = by_event[ev]
try:
prio = _DOMINANCE_PRIORITY.index(ev)
except ValueError:
prio = 99
return (conf, -prio)
current = max(by_event.keys(), key=_dom_key)
event_conf = by_event[current]
co_bonus = min(12.0, max(0, len(active) - 1) * 3)
entry_score = min(98.0, event_conf + co_bonus)
if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event:
entry_score = min(98.0, entry_score + 5)
return EngineResult(
name=self.name,
version=self.version,
confidence=event_conf,
score=entry_score,
reasons=list(dict.fromkeys(reasons))[:8],
warnings=["active_events_are_concurrent_not_timeline"],
metrics=metrics,
payload={
"current_event": current,
"active_events": active,
"recent_events": active, # persisted column name; semantic = active
"event_scores": by_event,
"timeframe": timeframe,
"entry_score": entry_score,
},
)
+206
View File
@@ -0,0 +1,206 @@
"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot)."""
from __future__ import annotations
from typing import Any
import numpy as np
from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame
def _sma(arr: np.ndarray, n: int) -> float:
if len(arr) < n:
return float(arr[-1]) if len(arr) else 0.0
return float(np.mean(arr[-n:]))
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
if len(close) < 2:
return 0.0
prev_close = close[:-1]
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close)))
if len(tr) < n:
return float(np.mean(tr)) if len(tr) else 0.0
return float(np.mean(tr[-n:]))
def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
"""Simplified ADX approximation."""
if len(close) < n + 2:
return 15.0
up = high[1:] - high[:-1]
down = low[:-1] - low[1:]
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])))
atr = np.mean(tr[-n:]) or 1e-9
plus_di = 100 * np.mean(plus_dm[-n:]) / atr
minus_di = 100 * np.mean(minus_dm[-n:]) / atr
denom = plus_di + minus_di
if denom < 1e-9:
return 10.0
dx = 100 * abs(plus_di - minus_di) / denom
return float(min(60.0, dx))
def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]:
"""Compute technical snapshot dict from OHLCV (no I/O)."""
if frame.empty or len(frame) < 5:
return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)}
close = np.asarray(frame.close, dtype=float)
high = np.asarray(frame.high, dtype=float)
low = np.asarray(frame.low, dtype=float)
volume = np.asarray(frame.volume, dtype=float)
open_ = np.asarray(frame.open, dtype=float)
ma20 = _sma(close, 20)
ma60 = _sma(close, 60)
ma120 = _sma(close, min(120, len(close)))
atr = _atr(high, low, close, 14)
vol_ma20 = _sma(volume, 20) or 1e-9
volume_ratio = float(volume[-1] / vol_ma20)
look = min(60, len(close))
window_h = high[-look:]
window_l = low[-look:]
range_high = float(np.max(window_h))
range_low = float(np.min(window_l))
rng = max(range_high - range_low, 1e-9)
range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0
range_position = float((close[-1] - range_low) / rng)
# Spring / UTAD hints
pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0
# if previous bars broke below and last close back in range
prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2])
pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1]))
close_back_in_range = 1.0 if close[-1] >= range_low else 0.0
reclaim_speed = 0.0
if pierce_below > 0 and close[-1] >= range_low:
reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2)
pierce_above = max(0.0, (high[-1] - range_high) / close[-1])
fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0
breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0
# pullback hold: close near ma20 from above after being higher
pullback_hold = 0.0
if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5:
pullback_hold = 0.8
ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60
ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9)
# volume trend: recent 10 vs prior 10
if len(volume) >= 20:
volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0)
else:
volume_trend = 0.0
bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9))
bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0
gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0
after_strength = 0.0
if len(close) >= 4 and close[-3] > close[-4]:
after_strength = 0.7
spring_score_hint = 0.0
if pierce_below >= 0.002 and close_back_in_range:
spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20)
utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0
# swing
swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1])
swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1])
return {
"ts_code": frame.ts_code,
"timeframe": frame.timeframe,
"bars": len(frame),
"close": float(close[-1]),
"open": float(open_[-1]),
"high": float(high[-1]),
"low": float(low[-1]),
"volume": float(volume[-1]),
"ma20": ma20,
"ma60": ma60,
"ma120": ma120,
"ma60_slope": float(ma60_slope),
"atr": atr,
"adx": _adx(high, low, close),
"volume_ma20": float(vol_ma20),
"volume_ratio": volume_ratio,
"volume_trend": volume_trend,
"range_high": range_high,
"range_low": range_low,
"range_pct_60": range_pct_60,
"range_position": range_position,
"pierce_below_range": pierce_below,
"pierce_above_range": pierce_above,
"close_back_in_range": close_back_in_range,
"reclaim_speed": reclaim_speed,
"fail_back_into_range": fail_back,
"breakout_above_range": breakout_above,
"pullback_hold": pullback_hold,
"bar_range_atr": bar_range_atr,
"bounce_from_low": bounce_from_low,
"gap_up_pct": gap_up_pct,
"after_strength": after_strength,
"spring_score_hint": spring_score_hint,
"utad_score_hint": utad_score_hint,
"swing_high": swing_high,
"swing_low": swing_low,
"trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None,
}
# Minimum bars before a timeframe is considered usable (no cross-TF borrow)
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
class FeatureEngine:
"""Pure Feature Engine — no database access."""
name = "Feature"
version = "1.0.0"
def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult:
tf = timeframe or (frame.timeframe if frame else "1d")
min_bars = _MIN_BARS.get(tf, 30)
if frame is None or frame.empty or len(frame) < min_bars:
bars = 0 if frame is None or frame.empty else len(frame)
return EngineResult(
name=self.name,
version=self.version,
confidence=10.0,
score=10.0,
reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"],
warnings=["insufficient_features"],
metrics={"bars": bars, "min_bars": min_bars},
payload={
"ts_code": getattr(frame, "ts_code", ""),
"timeframe": tf,
"bars": bars,
"insufficient": True,
},
)
snap = compute_feature_snapshot(frame)
snap["insufficient"] = False
conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5)
warnings = []
if snap.get("bars", 0) < 60:
warnings.append("bars偏少,特征可靠性中等")
return EngineResult(
name=self.name,
version=self.version,
confidence=conf,
score=conf,
reasons=[f"computed {snap.get('bars', 0)} bars {tf}"],
warnings=warnings,
metrics={"bars": snap.get("bars", 0)},
payload=snap,
)
+363
View File
@@ -0,0 +1,363 @@
"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar)."""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import time
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Iterable
import requests
from crypto_wyckoff.domain_models import OHLCVFrame
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff")))
BARS_DB = DATA_DIR / "bars.sqlite"
SCAN_DB = DATA_DIR / "scan.sqlite"
DATA_SERVICE_URL = os.environ.get(
"DATA_SERVICE_URL",
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
).rstrip("/")
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
# Provider has many TFs; 1M is resampled locally from daily UTC months.
LOOKBACK = {
"1h": 500,
"2h": 400,
"4h": 300,
"6h": 280,
"8h": 250,
"12h": 220,
"1d": 250,
"1w": 104,
"1M": 60,
}
# Default D/W/M stack (kept for compat); combos may request more TFs from provider.
TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w")
TF_LIST = ("1d", "1w", "1M")
LOCAL_ONLY_TFS = frozenset({"1M"})
def ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _symbol_key(symbol: str) -> str:
return symbol.replace("/", "_").replace(":", "_")
def _bars_conn() -> sqlite3.Connection:
ensure_dirs()
conn = sqlite3.connect(str(BARS_DB), timeout=60)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS bars (
symbol TEXT NOT NULL,
tf TEXT NOT NULL,
ts INTEGER NOT NULL,
open REAL, high REAL, low REAL, close REAL, volume REAL,
PRIMARY KEY (symbol, tf, ts)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)")
return conn
def fetch_candles(
symbol: str,
tf: str,
*,
limit: int | None = None,
start_ms: int | None = None,
end_ms: int | None = None,
timeout: float = 15.0,
) -> list[dict]:
params: dict = {"symbol": symbol, "tf": tf}
if limit is not None:
params["limit"] = int(limit)
if start_ms is not None:
params["start"] = int(start_ms)
if end_ms is not None:
params["end"] = int(end_ms)
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return []
out = []
for row in data:
try:
ts = int(float(row["timestamp"]))
out.append(
{
"ts": ts,
"open": float(row["open"]),
"high": float(row["high"]),
"low": float(row["low"]),
"close": float(row["close"]),
"volume": float(row.get("volume") or 0),
}
)
except (KeyError, TypeError, ValueError):
continue
out.sort(key=lambda r: r["ts"])
return out
def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
if not rows:
return 0
conn = _bars_conn()
try:
conn.executemany(
"""
INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, tf, ts) DO UPDATE SET
open=excluded.open, high=excluded.high, low=excluded.low,
close=excluded.close, volume=excluded.volume
""",
[
(symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"])
for r in rows
],
)
conn.commit()
return len(rows)
finally:
conn.close()
def is_intraday_tf(tf: str) -> bool:
"""True for minute/hour TFs that need clock time on charts."""
t = (tf or "").strip()
return t.endswith("m") or t.endswith("h")
def load_bars_with_ts(
symbol: str, tf: str, lookback: int | None = None
) -> list[dict]:
"""Return OHLCV rows with UTC ms ts (for chart labels).
``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display.
"""
from zoneinfo import ZoneInfo
tz_cn = ZoneInfo("Asia/Shanghai")
if lookback is None:
try:
from crypto_wyckoff.combos import lookback_for
lookback = lookback_for(tf)
except Exception:
lookback = LOOKBACK.get(tf, 100)
lookback = lookback or LOOKBACK.get(tf, 100)
conn = _bars_conn()
try:
cur = conn.execute(
"""
SELECT ts, open, high, low, close, volume FROM bars
WHERE symbol=? AND tf=?
ORDER BY ts DESC LIMIT ?
""",
(symbol, tf, lookback),
)
rows = list(reversed(cur.fetchall()))
finally:
conn.close()
out = []
for ts, o, h, l, c, v in rows:
dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
dt_cn = dt_utc.astimezone(tz_cn)
out.append(
{
"ts": int(ts),
"datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
"date": dt_cn.strftime("%Y-%m-%d"),
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
}
)
return out
def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
rows = load_bars_with_ts(symbol, tf, lookback)
if not rows:
return None
return OHLCVFrame(
ts_code=symbol,
timeframe=tf,
trade_dates=[
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows
],
open=[r["open"] for r in rows],
high=[r["high"] for r in rows],
low=[r["low"] for r in rows],
close=[r["close"] for r in rows],
volume=[r["volume"] for r in rows],
)
def bar_count(symbol: str, tf: str) -> int:
conn = _bars_conn()
try:
cur = conn.execute(
"SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf)
)
return int(cur.fetchone()[0])
finally:
conn.close()
def rebuild_monthly_from_daily(symbol: str) -> int:
"""Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M)."""
conn = _bars_conn()
try:
cur = conn.execute(
"""
SELECT ts, open, high, low, close, volume FROM bars
WHERE symbol=? AND tf='1d' ORDER BY ts ASC
""",
(symbol,),
)
daily = cur.fetchall()
finally:
conn.close()
if not daily:
return 0
months: dict[tuple[int, int], dict] = {}
for ts, o, h, l, c, v in daily:
dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
key = (dt.year, dt.month)
# month bar open timestamp = first day 00:00 UTC
month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000)
if key not in months:
months[key] = {
"ts": month_ts,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v or 0.0,
}
else:
m = months[key]
m["high"] = max(m["high"], h)
m["low"] = min(m["low"], l)
m["close"] = c
m["volume"] = (m["volume"] or 0) + (v or 0)
rows = sorted(months.values(), key=lambda r: r["ts"])
# drop stale months then upsert
conn = _bars_conn()
try:
conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,))
conn.commit()
finally:
conn.close()
return upsert_bars(symbol, "1M", rows)
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
"""Pull history for requested TFs; monthly derived from daily when needed."""
wanted = list(dict.fromkeys(tfs))
stats: dict = {}
need_monthly = "1M" in wanted
if need_monthly and "1d" not in wanted:
wanted = ["1d", *wanted]
for tf in wanted:
if tf in LOCAL_ONLY_TFS:
continue
need = LOOKBACK.get(tf, 100)
if tf == "1d" and need_monthly:
need = max(need, LOOKBACK["1M"] * 31)
try:
rows = fetch_candles(symbol, tf, limit=need)
n = upsert_bars(symbol, tf, rows)
stats[tf] = n
except Exception as e:
logger.warning("backfill %s %s failed: %s", symbol, tf, e)
stats[tf] = 0
time.sleep(0.05)
if need_monthly:
try:
stats["1M"] = rebuild_monthly_from_daily(symbol)
except Exception as e:
logger.warning("monthly rebuild %s failed: %s", symbol, e)
stats["1M"] = 0
return stats
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
"""Update forming tip bars (limit=3). Returns True if any bar changed."""
wanted = list(dict.fromkeys(tfs))
changed = False
for tf in wanted:
if tf in LOCAL_ONLY_TFS:
continue
try:
rows = fetch_candles(symbol, tf, limit=3)
if not rows:
continue
before = _tip_fingerprint(symbol, tf)
upsert_bars(symbol, tf, rows)
after = _tip_fingerprint(symbol, tf)
if before != after:
changed = True
except Exception as e:
logger.debug("tip %s %s: %s", symbol, tf, e)
time.sleep(0.02)
if "1M" in wanted:
before_m = _tip_fingerprint(symbol, "1M")
try:
rebuild_monthly_from_daily(symbol)
except Exception as e:
logger.debug("monthly tip %s: %s", symbol, e)
after_m = _tip_fingerprint(symbol, "1M")
if before_m != after_m:
changed = True
return changed
def _tip_fingerprint(symbol: str, tf: str) -> tuple | None:
conn = _bars_conn()
try:
cur = conn.execute(
"""
SELECT ts, open, high, low, close, volume FROM bars
WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1
""",
(symbol, tf),
)
row = cur.fetchone()
return tuple(row) if row else None
finally:
conn.close()
def fetch_symbols_from_provider() -> list[str]:
try:
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8)
resp.raise_for_status()
payload = resp.json()
symbols = payload.get("symbols") or payload.get("symbol_list") or []
return [s for s in symbols if isinstance(s, str)]
except Exception as e:
logger.warning("health symbols failed: %s", e)
return []
+78
View File
@@ -0,0 +1,78 @@
"""Phase Engine — Phase AE via Rule Registry."""
from __future__ import annotations
from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase
from crypto_wyckoff.rules.registry import rule_registry
class PhaseEngine:
name = "Phase"
version = "1.0.0"
def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult:
if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown":
return EngineResult(
name=self.name,
version=self.version,
confidence=20.0,
score=30.0,
reasons=["数据/周期不足,Phase=None"],
warnings=["insufficient_features"],
payload={
"phase": WyckoffPhase.NONE.value,
"timeframe": timeframe,
"cycle": cycle.payload.get("cycle"),
"structure_score": 30.0,
},
)
context = {
"features": feature.payload,
"cycle": cycle.payload,
"timeframe": timeframe,
}
hits = []
for rule in rule_registry.by_category("phase", timeframe):
hit = rule.evaluate(context)
if hit and hit.phase:
hits.append(hit)
if not hits:
return EngineResult(
name=self.name,
version=self.version,
confidence=40.0,
score=cycle.score * 0.5,
reasons=["未识别明确 Phase"],
payload={
"phase": WyckoffPhase.NONE.value,
"timeframe": timeframe,
"cycle": cycle.payload.get("cycle"),
"structure_score": cycle.score * 0.5,
},
)
best = max(hits, key=lambda h: h.confidence)
structure_score = best.score
# Phase D/E stronger structure
if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value):
structure_score = max(structure_score, 80.0)
elif best.phase == WyckoffPhase.C.value:
structure_score = max(structure_score, 72.0)
return EngineResult(
name=self.name,
version=self.version,
confidence=best.confidence,
score=structure_score,
reasons=best.reasons,
metrics=best.metrics,
payload={
"phase": best.phase,
"timeframe": timeframe,
"cycle": cycle.payload.get("cycle"),
"rule_id": best.rule_id,
"structure_score": structure_score,
},
)
+181
View File
@@ -0,0 +1,181 @@
"""Scan pipeline: load local frames → engines → store (per TF combo)."""
from __future__ import annotations
import json
import logging
from datetime import date, datetime, timezone
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for
from crypto_wyckoff.cycle import CycleEngine
from crypto_wyckoff.decision import DecisionEngine
from crypto_wyckoff.domain_models import WyckoffScanRow
from crypto_wyckoff.event import EventEngine
from crypto_wyckoff.features import FeatureEngine
from crypto_wyckoff.io import load_frame
from crypto_wyckoff.phase import PhaseEngine
from crypto_wyckoff.plan import PlanEngine
from crypto_wyckoff.signal import SignalEngine
from crypto_wyckoff.store import upsert_row
from crypto_wyckoff.symbols_cn import display_name_cn
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
logger = logging.getLogger(__name__)
def analyze_symbol(
low_frame,
mid_frame,
high_frame,
*,
feature_eng: FeatureEngine,
cycle_eng: CycleEngine,
phase_eng: PhaseEngine,
event_eng: EventEngine,
signal_eng: SignalEngine,
decision_eng: DecisionEngine,
plan_eng: PlanEngine,
) -> dict:
"""Run engines with D/W/M *role* aliases so existing rules match.
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles.
"""
f_d = feature_eng.run(low_frame, ROLE_LOW)
f_w = feature_eng.run(mid_frame, ROLE_MID)
f_m = feature_eng.run(high_frame, ROLE_HIGH)
c_m = cycle_eng.run(f_m, ROLE_HIGH)
c_w = cycle_eng.run(f_w, ROLE_MID)
p_w = phase_eng.run(c_w, f_w, ROLE_MID)
p_d = phase_eng.run(c_w, f_d, ROLE_LOW)
e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID)
e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW)
s_d = signal_eng.run(e_d, p_d)
decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
plan = plan_eng.run(f_d, decision)
return {
"f_d": f_d, "f_w": f_w, "f_m": f_m,
"c_m": c_m, "c_w": c_w, "p_w": p_w,
"e_w": e_w, "e_d": e_d, "s_d": s_d,
"decision": decision, "plan": plan,
}
def _to_row(
trade_date: date,
symbol: str,
result: dict,
*,
combo_id: str,
combo_label: str,
) -> WyckoffScanRow:
d = result["decision"]
p = result["plan"]
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"]
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
snapshot = {
"combo_id": combo_id,
"combo_label": combo_label,
"daily": {k: f_d.payload.get(k) for k in (
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
"range_high", "range_low", "swing_high", "swing_low", "close",
)},
"weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
"monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
}
markers = []
for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")):
if p.payload.get(key) is not None:
markers.append({"type": typ, "price": p.payload[key]})
return WyckoffScanRow(
trade_date=trade_date,
ts_code=symbol,
name=display_name_cn(symbol),
industry="crypto",
engine_version=WYCKOFF_ENGINE_VERSION,
m_cycle=c_m.payload.get("cycle", "Unknown"),
cycle_confidence=c_m.confidence,
trend_score=float(d.payload.get("trend_score", c_m.score)),
w_cycle=c_w.payload.get("cycle", "Unknown"),
w_phase=p_w.payload.get("phase", "None"),
w_current_event=e_w.payload.get("current_event", "None"),
w_recent_events_json=json.dumps(
e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [],
ensure_ascii=False,
),
phase_confidence=p_w.confidence,
structure_score=float(d.payload.get("structure_score", p_w.score)),
d_current_event=e_d.payload.get("current_event", "None"),
d_recent_events_json=json.dumps(
e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [],
ensure_ascii=False,
),
event_confidence=e_d.confidence,
entry_score=float(d.payload.get("entry_score", e_d.score)),
entry=p.payload.get("entry"),
stop=p.payload.get("stop"),
target1=p.payload.get("target1"),
target2=p.payload.get("target2"),
rr=p.payload.get("rr"),
alignment=float(d.payload.get("alignment", 0)),
stars=int(d.payload.get("stars", 1)),
decision_signal=d.payload.get("decision_signal", "Watch"),
signal_confidence=s_d.confidence,
overall_confidence=float(d.payload.get("overall_confidence", d.confidence)),
overall_score=float(d.payload.get("overall_score", d.score)),
risk=d.payload.get("risk", "Medium"),
reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False),
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
markers_json=json.dumps(markers, ensure_ascii=False),
scanned_at=datetime.now(timezone.utc),
combo_id=combo_id,
)
_ENGINES = None
def _engines():
global _ENGINES
if _ENGINES is None:
_ENGINES = {
"feature_eng": FeatureEngine(),
"cycle_eng": CycleEngine(),
"phase_eng": PhaseEngine(),
"event_eng": EventEngine(),
"signal_eng": SignalEngine(),
"decision_eng": DecisionEngine(),
"plan_eng": PlanEngine(),
}
return _ENGINES
def analyze_and_store(
symbol: str,
trade_date: date | None = None,
*,
combo_id: str | None = None,
) -> WyckoffScanRow | None:
eng = _engines()
combo = get_combo(combo_id)
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"]
low = load_frame(symbol, low_tf, lookback_for(low_tf))
mid = load_frame(symbol, mid_tf, lookback_for(mid_tf))
high = load_frame(symbol, high_tf, lookback_for(high_tf))
if low is None or len(low) < 40:
return None
result = analyze_symbol(low, mid, high, **eng)
td = trade_date or (
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date()
)
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"])
upsert_row(row)
return row
+78
View File
@@ -0,0 +1,78 @@
"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable."""
from __future__ import annotations
from crypto_wyckoff.domain_models import DecisionSignal, EngineResult
_TRADABLE = {
DecisionSignal.STRONG_BUY.value,
DecisionSignal.BUY.value,
DecisionSignal.SELL.value,
}
class PlanEngine:
name = "Plan"
version = "1.0.0"
def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult:
f = daily_feature.payload
close = float(f.get("close") or 0)
atr = float(f.get("atr") or 0) or close * 0.02
swing_low = float(f.get("swing_low") or close - 2 * atr)
swing_high = float(f.get("swing_high") or close + 2 * atr)
range_high = float(f.get("range_high") or swing_high)
signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value)
entry = stop = t1 = t2 = rr = None
reasons: list[str] = []
if signal not in _TRADABLE or close <= 0:
reasons.append(f"无交易计划(信号={signal}")
return EngineResult(
name=self.name,
version=self.version,
confidence=decision.confidence,
score=decision.score,
reasons=reasons,
payload={
"entry": None,
"stop": None,
"target1": None,
"target2": None,
"rr": None,
},
)
if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value):
entry = round(close, 4)
stop = round(min(swing_low, close - 1.5 * atr), 4)
risk = max(entry - stop, 1e-6)
t1 = round(entry + 2.0 * risk, 4)
t2 = round(max(range_high, entry + 3.0 * risk), 4)
rr = round((t1 - entry) / risk, 2)
reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}")
else: # Sell
entry = round(close, 4)
stop = round(max(swing_high, close + 1.5 * atr), 4)
risk = max(stop - entry, 1e-6)
t1 = round(entry - 2.0 * risk, 4)
t2 = round(entry - 3.0 * risk, 4)
rr = round((entry - t1) / risk, 2)
reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}")
return EngineResult(
name=self.name,
version=self.version,
confidence=decision.confidence,
score=decision.score,
reasons=reasons,
payload={
"entry": entry,
"stop": stop,
"target1": t1,
"target2": t2,
"rr": rr,
},
)
+3
View File
@@ -0,0 +1,3 @@
from crypto_wyckoff.rules.registry import rule_registry
__all__ = ["rule_registry"]
+33
View File
@@ -0,0 +1,33 @@
"""Rule protocol for Wyckoff Rule Registry."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class RuleHit:
"""A single rule match."""
rule_id: str
event: str | None = None
phase: str | None = None
cycle: str | None = None
confidence: float = 0.0
score: float = 0.0
reasons: list[str] = field(default_factory=list)
metrics: dict[str, Any] = field(default_factory=dict)
class WyckoffRule(ABC):
"""Pluggable rule. Engines iterate registry; never hardcode rule lists."""
rule_id: str
category: str # cycle | phase | event
timeframes: tuple[str, ...] = ("1d", "1w", "1M")
@abstractmethod
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
"""Return RuleHit if matched, else None. Pure — no I/O."""
+126
View File
@@ -0,0 +1,126 @@
"""Cycle classification rules (monthly / weekly)."""
from __future__ import annotations
from typing import Any
from crypto_wyckoff.domain_models import WyckoffCycle
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
v = ctx.get("features", {}).get(key, default)
try:
return float(v) if v is not None else default
except (TypeError, ValueError):
return default
class MarkupCycleRule(WyckoffRule):
rule_id = "cycle_markup"
category = "cycle"
timeframes = ("1M", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
close = _f(context, "close")
ma20 = _f(context, "ma20")
ma60 = _f(context, "ma60")
ma120 = _f(context, "ma120")
adx = _f(context, "adx")
slope = _f(context, "ma60_slope")
if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18:
conf = min(95.0, 55 + adx + (10 if close > ma120 else 0))
return RuleHit(
rule_id=self.rule_id,
cycle=WyckoffCycle.MARKUP.value,
confidence=conf,
score=conf,
reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"],
metrics={"adx": adx, "slope": slope},
)
return None
class MarkdownCycleRule(WyckoffRule):
rule_id = "cycle_markdown"
category = "cycle"
timeframes = ("1M", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
close = _f(context, "close")
ma20 = _f(context, "ma20")
ma60 = _f(context, "ma60")
ma120 = _f(context, "ma120")
adx = _f(context, "adx")
slope = _f(context, "ma60_slope")
if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18:
conf = min(95.0, 55 + adx + (10 if close < ma120 else 0))
return RuleHit(
rule_id=self.rule_id,
cycle=WyckoffCycle.MARKDOWN.value,
confidence=conf,
score=conf,
reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"],
metrics={"adx": adx},
)
return None
class AccumulationCycleRule(WyckoffRule):
rule_id = "cycle_accumulation"
category = "cycle"
timeframes = ("1M", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
adx = _f(context, "adx")
range_pct = _f(context, "range_pct_60")
close = _f(context, "close")
ma120 = _f(context, "ma120")
vol_trend = _f(context, "volume_trend")
# Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution)
if adx < 22 and range_pct < 0.28 and close <= ma120:
conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0)
return RuleHit(
rule_id=self.rule_id,
cycle=WyckoffCycle.ACCUMULATION.value,
confidence=min(90.0, conf),
score=min(90.0, conf),
reasons=["低趋势强度区间震荡", "疑似吸筹区间"],
metrics={"adx": adx, "range_pct_60": range_pct},
)
return None
class DistributionCycleRule(WyckoffRule):
rule_id = "cycle_distribution"
category = "cycle"
timeframes = ("1M", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
adx = _f(context, "adx")
range_pct = _f(context, "range_pct_60")
close = _f(context, "close")
ma120 = _f(context, "ma120")
vol_trend = _f(context, "volume_trend")
# Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation)
if adx < 22 and range_pct < 0.28 and close > ma120:
conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0)
return RuleHit(
rule_id=self.rule_id,
cycle=WyckoffCycle.DISTRIBUTION.value,
confidence=min(90.0, conf),
score=min(90.0, conf),
reasons=["高位低趋势震荡", "疑似派发区间"],
metrics={"adx": adx, "range_pct_60": range_pct},
)
return None
def build_rules() -> list[WyckoffRule]:
# Order: trend cycles first (more decisive), then range cycles
return [
MarkupCycleRule(),
MarkdownCycleRule(),
AccumulationCycleRule(),
DistributionCycleRule(),
]
+254
View File
@@ -0,0 +1,254 @@
"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/..."""
from __future__ import annotations
from typing import Any
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
v = ctx.get("features", {}).get(key, default)
try:
return float(v) if v is not None else default
except (TypeError, ValueError):
return default
def _cycle(ctx: dict[str, Any]) -> str:
return (ctx.get("cycle") or {}).get("cycle") or ""
def _phase(ctx: dict[str, Any]) -> str:
return (ctx.get("phase") or {}).get("phase") or ""
class SpringRule(WyckoffRule):
rule_id = "event_spring"
category = "event"
timeframes = ("1d",)
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value,
WyckoffCycle.MARKUP.value):
# Allow spring only in accumulative contexts; Decision will filter MTF
if cycle == WyckoffCycle.DISTRIBUTION.value:
pass # still detect for facts but lower confidence
pierce = _f(context, "pierce_below_range")
reclaim = _f(context, "reclaim_speed")
vol_ratio = _f(context, "volume_ratio")
close_in_range = _f(context, "close_back_in_range")
if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3:
strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5))
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.SPRING.value,
confidence=strength,
score=strength,
reasons=[
f"跌破区间后收回 (pierce={pierce:.3%})",
f"回收速度={reclaim:.2f}",
f"量比={vol_ratio:.2f}",
],
metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio},
)
return None
class TestRule(WyckoffRule):
rule_id = "event_test"
category = "event"
timeframes = ("1d", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
pos = _f(context, "range_position")
vol_ratio = _f(context, "volume_ratio")
near_low = pos < 0.2
if near_low and vol_ratio < 0.85:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.TEST.value,
confidence=68.0,
score=65.0,
reasons=["低位缩量回测"],
)
return None
class SOSRule(WyckoffRule):
rule_id = "event_sos"
category = "event"
timeframes = ("1d", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
breakout = _f(context, "breakout_above_range")
vol_ratio = _f(context, "volume_ratio")
close = _f(context, "close")
ma20 = _f(context, "ma20")
if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20:
conf = min(95.0, 70 + vol_ratio * 8)
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.SOS.value,
confidence=conf,
score=conf,
reasons=["放量突破区间上沿 (SOS)"],
metrics={"vol_ratio": vol_ratio},
)
return None
class LPSRule(WyckoffRule):
rule_id = "event_lps"
category = "event"
timeframes = ("1d", "1w")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
# Pullback hold above broken range / MA20 after prior strength
pullback = _f(context, "pullback_hold")
vol_ratio = _f(context, "volume_ratio")
above_ma = _f(context, "close") > _f(context, "ma20")
if pullback >= 0.5 and above_ma and vol_ratio <= 1.1:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.LPS.value,
confidence=74.0,
score=76.0,
reasons=["突破后缩量回踩支撑 (LPS)"],
)
return None
class SCRule(WyckoffRule):
rule_id = "event_sc"
category = "event"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
vol_ratio = _f(context, "volume_ratio")
bar_range = _f(context, "bar_range_atr")
pos = _f(context, "range_position")
if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.SC.value,
confidence=72.0,
score=70.0,
reasons=["低位放量宽幅,疑似 Selling Climax"],
)
return None
class ARRule(WyckoffRule):
rule_id = "event_ar"
category = "event"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
# Automatic rally: bounce from lows
bounce = _f(context, "bounce_from_low")
if bounce >= 0.04:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.AR.value,
confidence=65.0,
score=62.0,
reasons=["低点后自动反弹 (AR)"],
)
return None
class STRule(WyckoffRule):
rule_id = "event_st"
category = "event"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
pos = _f(context, "range_position")
vol_ratio = _f(context, "volume_ratio")
if 0.15 < pos < 0.45 and vol_ratio < 1.0:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.ST.value,
confidence=60.0,
score=58.0,
reasons=["次级测试 (ST)"],
)
return None
class UTADRule(WyckoffRule):
rule_id = "event_utad"
category = "event"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
pierce_up = _f(context, "pierce_above_range")
fail = _f(context, "fail_back_into_range")
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value,
WyckoffCycle.MARKUP.value):
if pierce_up >= 0.002 and fail >= 0.5:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.UTAD.value,
confidence=76.0,
score=74.0,
reasons=["冲高失败回到区间 (UTAD)"],
)
return None
class JumpRule(WyckoffRule):
rule_id = "event_jump"
category = "event"
timeframes = ("1d",)
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
gap = _f(context, "gap_up_pct")
vol_ratio = _f(context, "volume_ratio")
if gap >= 0.03 and vol_ratio >= 1.3:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.JUMP.value,
confidence=70.0,
score=72.0,
reasons=["放量向上跳跃 (Jump)"],
)
return None
class BackupRule(WyckoffRule):
rule_id = "event_backup"
category = "event"
timeframes = ("1d",)
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
pullback = _f(context, "pullback_hold")
after_jump = _f(context, "after_strength")
if after_jump >= 0.5 and pullback >= 0.5:
return RuleHit(
rule_id=self.rule_id,
event=WyckoffEvent.BACKUP.value,
confidence=68.0,
score=70.0,
reasons=["跳跃后回踩 (Backup)"],
)
return None
def build_rules() -> list[WyckoffRule]:
return [
SpringRule(),
UTADRule(),
SOSRule(),
LPSRule(),
SCRule(),
JumpRule(),
BackupRule(),
TestRule(),
ARRule(),
STRule(),
]
+163
View File
@@ -0,0 +1,163 @@
"""Phase AE rules (primarily weekly)."""
from __future__ import annotations
from typing import Any
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
v = ctx.get("features", {}).get(key, default)
try:
return float(v) if v is not None else default
except (TypeError, ValueError):
return default
def _cycle(ctx: dict[str, Any]) -> str:
return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value
class PhaseARule(WyckoffRule):
rule_id = "phase_a"
category = "phase"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value,
WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value):
return None
# Stopping action: high vol + large range recently, still range-bound
vol_ratio = _f(context, "volume_ratio")
range_last = _f(context, "bar_range_atr")
if vol_ratio >= 1.4 and range_last >= 1.2:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.A.value,
confidence=70.0,
score=65.0,
reasons=["放量宽幅波动,疑似 Phase A 停止行为"],
)
return None
class PhaseBRule(WyckoffRule):
rule_id = "phase_b"
category = "phase"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value):
return None
adx = _f(context, "adx")
range_pct = _f(context, "range_pct_60")
pos = _f(context, "range_position") # 0=low 1=high of range
if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.B.value,
confidence=72.0,
score=68.0,
reasons=["区间中部震荡,疑似 Phase B 建仓/派发"],
)
return None
class PhaseCRule(WyckoffRule):
rule_id = "phase_c"
category = "phase"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
pos = _f(context, "range_position")
spring_like = _f(context, "spring_score_hint")
utad_like = _f(context, "utad_score_hint")
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
if pos < 0.25 or spring_like >= 50:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.C.value,
confidence=75.0 + min(15.0, spring_like * 0.15),
score=78.0,
reasons=["区间低位测试,疑似 Phase C (Spring/Test)"],
)
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
if pos > 0.75 or utad_like >= 50:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.C.value,
confidence=75.0,
score=78.0,
reasons=["区间高位测试,疑似 Phase C (UTAD)"],
)
return None
class PhaseDRule(WyckoffRule):
rule_id = "phase_d"
category = "phase"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
close = _f(context, "close")
ma20 = _f(context, "ma20")
range_high = _f(context, "range_high")
range_low = _f(context, "range_low")
vol_ratio = _f(context, "volume_ratio")
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.D.value,
confidence=80.0,
score=82.0,
reasons=["突破区间上沿放量,疑似 Phase D SOS"],
)
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
if close < ma20 and range_low > 0 and close <= range_low * 1.02:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.D.value,
confidence=80.0,
score=82.0,
reasons=["跌破区间下沿,疑似 Phase D SOW"],
)
return None
class PhaseERule(WyckoffRule):
rule_id = "phase_e"
category = "phase"
timeframes = ("1w", "1d")
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
cycle = _cycle(context)
# Markup/Markdown already imply trend continuation (Phase E of prior structure)
if cycle == WyckoffCycle.MARKUP.value:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.E.value,
confidence=78.0,
score=80.0,
reasons=["趋势上行,对应 Phase E Markup"],
)
if cycle == WyckoffCycle.MARKDOWN.value:
return RuleHit(
rule_id=self.rule_id,
phase=WyckoffPhase.E.value,
confidence=78.0,
score=80.0,
reasons=["趋势下行,对应 Phase E Markdown"],
)
return None
def build_rules() -> list[WyckoffRule]:
# More specific phases first
return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()]
+39
View File
@@ -0,0 +1,39 @@
"""Rule Registry — register Wyckoff rules without modifying engines."""
from __future__ import annotations
from crypto_wyckoff.rules.base import WyckoffRule
class RuleRegistry:
def __init__(self) -> None:
self._rules: dict[str, WyckoffRule] = {}
def register(self, rule: WyckoffRule) -> None:
self._rules[rule.rule_id] = rule
def get(self, rule_id: str) -> WyckoffRule | None:
return self._rules.get(rule_id)
def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]:
out = [r for r in self._rules.values() if r.category == category]
if timeframe:
out = [r for r in out if timeframe in r.timeframes]
return out
def all(self) -> list[WyckoffRule]:
return list(self._rules.values())
rule_registry = RuleRegistry()
def _register_defaults() -> None:
from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules
for mod in (cycle_rules, phase_rules, event_rules):
for rule in mod.build_rules():
rule_registry.register(rule)
_register_defaults()
+127
View File
@@ -0,0 +1,127 @@
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos)."""
from __future__ import annotations
import logging
import threading
from datetime import datetime, timezone
from crypto_wyckoff.combos import all_tfs_for_combos, list_combos
from crypto_wyckoff.io import (
backfill_symbol,
bar_count,
fetch_symbols_from_provider,
tip_update_symbol,
)
from crypto_wyckoff.pipeline import analyze_and_store
logger = logging.getLogger(__name__)
_thread: threading.Thread | None = None
_stop = threading.Event()
_status: dict = {
"running": False,
"last_tick_at": None,
"last_error": None,
"symbols_total": 0,
"symbols_scanned": 0,
"tick_interval_sec": 60,
"backfill_done": False,
}
_status_lock = threading.Lock()
def _set(**kwargs):
with _status_lock:
_status.update(kwargs)
def get_status() -> dict:
with _status_lock:
return dict(_status)
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
"""One cycle: refresh symbols, tip-update, analyze each combo."""
symbols = fetch_symbols_from_provider()
if max_symbols:
symbols = symbols[:max_symbols]
combos = list_combos()
tfs = all_tfs_for_combos(combos)
_set(symbols_total=len(symbols), running=True, last_error=None)
scanned = 0
errors = 0
changed_n = 0
for i, sym in enumerate(symbols):
try:
# Prefer low-TF of first combo for "enough history" gate
low0 = combos[0]["low"] if combos else "1d"
if bar_count(sym, low0) < 40:
backfill_symbol(sym, tfs)
tip_changed = tip_update_symbol(sym, tfs)
if tip_changed:
changed_n += 1
if force_rescan or tip_changed:
for combo in combos:
row = analyze_and_store(sym, combo_id=combo["id"])
if row:
scanned += 1
except Exception as e:
errors += 1
if errors <= 5:
logger.warning("tick %s: %s", sym, e)
_set(last_error=str(e))
if (i + 1) % 25 == 0:
_set(symbols_scanned=scanned)
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
_set(
running=False,
symbols_scanned=scanned,
last_tick_at=datetime.now(timezone.utc).isoformat(),
backfill_done=True,
)
return {
"symbols": len(symbols),
"scanned": scanned,
"changed_tips": changed_n,
"errors": errors,
"combos": [c["id"] for c in combos],
"tfs": tfs,
}
def _loop(interval: int, max_symbols: int | None):
try:
run_tick(max_symbols=max_symbols, force_rescan=True)
except Exception as e:
logger.exception("initial tick failed: %s", e)
_set(last_error=str(e), running=False)
while not _stop.wait(interval):
try:
# Tip-driven: only force full rescan when tips change is handled inside
run_tick(max_symbols=max_symbols, force_rescan=False)
except Exception as e:
logger.exception("tick failed: %s", e)
_set(last_error=str(e), running=False)
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
global _thread
if _thread and _thread.is_alive():
return
_stop.clear()
_set(tick_interval_sec=interval_sec)
_thread = threading.Thread(
target=_loop,
args=(interval_sec, max_symbols),
name="crypto-wyckoff-scheduler",
daemon=True,
)
_thread.start()
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
def stop_scheduler() -> None:
_stop.set()
+35
View File
@@ -0,0 +1,35 @@
"""Signal Engine — timeframe-local status labels only (not tradability)."""
from __future__ import annotations
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
class SignalEngine:
"""Maps local Event/Phase into a status label. Decision decides tradability."""
name = "Signal"
version = "1.0.0"
def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult:
current = event.payload.get("current_event", WyckoffEvent.NONE.value)
conf = event.confidence
label = current # status label mirrors event for V1
reasons = [f"本地事件标签: {label}"]
if phase and phase.payload.get("phase"):
reasons.append(f"本地阶段: {phase.payload.get('phase')}")
return EngineResult(
name=self.name,
version=self.version,
confidence=conf,
score=event.score,
reasons=reasons,
payload={
"signal_label": label,
"current_event": current,
"phase": (phase.payload.get("phase") if phase else None),
"active_events": event.payload.get("active_events")
or event.payload.get("recent_events", []),
},
)
+236
View File
@@ -0,0 +1,236 @@
"""SQLite persistence for crypto wyckoff scan rows (per combo)."""
from __future__ import annotations
import sqlite3
from datetime import datetime
from typing import Any
from crypto_wyckoff.domain_models import WyckoffScanRow
from crypto_wyckoff.io import SCAN_DB, ensure_dirs
_COLS = [
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version",
"m_cycle", "cycle_confidence", "trend_score",
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
"phase_confidence", "structure_score",
"d_current_event", "d_recent_events_json", "event_confidence", "entry_score",
"entry", "stop", "target1", "target2", "rr",
"alignment", "stars", "decision_signal", "signal_confidence",
"overall_confidence", "overall_score", "risk", "reasons_json",
"feature_snapshot_json", "markers_json", "scanned_at",
]
_CREATE_SQL = """
CREATE TABLE IF NOT EXISTS wyckoff_scan (
trade_date TEXT NOT NULL,
combo_id TEXT NOT NULL DEFAULT 'd_w_m',
ts_code TEXT NOT NULL,
name TEXT DEFAULT '',
industry TEXT DEFAULT '',
engine_version TEXT,
m_cycle TEXT, cycle_confidence REAL, trend_score REAL,
w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT,
phase_confidence REAL, structure_score REAL,
d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL,
entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL,
alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL,
overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT,
feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT,
PRIMARY KEY (trade_date, combo_id, ts_code)
)
"""
def _migrate(c: sqlite3.Connection) -> None:
cur = c.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'"
)
if not cur.fetchone():
c.execute(_CREATE_SQL)
c.execute(
"CREATE INDEX IF NOT EXISTS idx_cw_score "
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
)
return
cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")}
if "combo_id" in cols:
c.execute(
"CREATE INDEX IF NOT EXISTS idx_cw_score "
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
)
return
# Legacy PK (trade_date, ts_code) → add combo_id via table rebuild
c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old")
c.execute(_CREATE_SQL)
old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")]
shared = [col for col in _COLS if col != "combo_id" and col in old_cols]
col_sql = ",".join(shared)
c.execute(
f"""
INSERT INTO wyckoff_scan (combo_id, {col_sql})
SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old
"""
)
c.execute("DROP TABLE wyckoff_scan_old")
c.execute(
"CREATE INDEX IF NOT EXISTS idx_cw_score "
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
)
def _conn() -> sqlite3.Connection:
ensure_dirs()
c = sqlite3.connect(str(SCAN_DB), timeout=60)
c.row_factory = sqlite3.Row
_migrate(c)
c.commit()
return c
def upsert_row(row: WyckoffScanRow) -> None:
combo_id = getattr(row, "combo_id", None) or "d_w_m"
vals = (
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date),
combo_id,
row.ts_code, row.name, row.industry, row.engine_version,
row.m_cycle, row.cycle_confidence, row.trend_score,
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
row.phase_confidence, row.structure_score,
row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score,
row.entry, row.stop, row.target1, row.target2, row.rr,
row.alignment, row.stars, row.decision_signal, row.signal_confidence,
row.overall_confidence, row.overall_score, row.risk, row.reasons_json,
row.feature_snapshot_json, row.markers_json,
row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at),
)
c = _conn()
try:
placeholders = ",".join("?" * len(_COLS))
col_sql = ",".join(_COLS)
updates = ",".join(
f"{col}=excluded.{col}"
for col in _COLS
if col not in ("trade_date", "combo_id", "ts_code")
)
c.execute(
f"""
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates}
""",
vals,
)
c.commit()
finally:
c.close()
def latest_trade_date(combo_id: str | None = None) -> str | None:
c = _conn()
try:
if combo_id:
cur = c.execute(
"SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?",
(combo_id,),
)
else:
cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
row = cur.fetchone()
return row[0] if row and row[0] else None
finally:
c.close()
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int:
td = trade_date or latest_trade_date(combo_id)
if not td:
return 0
c = _conn()
try:
if combo_id:
cur = c.execute(
"SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?",
(td, combo_id),
)
else:
cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
return int(cur.fetchone()[0])
finally:
c.close()
def query_scan(
*,
trade_date: str | None = None,
combo_id: str | None = None,
m_cycle: str | None = None,
w_phase: str | None = None,
d_event: str | None = None,
decision_signal: str | None = None,
min_overall_score: float | None = None,
min_alignment: float | None = None,
sort: str = "overall_score",
limit: int = 100,
offset: int = 0,
) -> list[dict[str, Any]]:
cid = combo_id or "d_w_m"
td = trade_date or latest_trade_date(cid)
if not td:
return []
sort_col = sort if sort in {
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
} else "overall_score"
clauses = ["trade_date=?", "combo_id=?"]
args: list[Any] = [td, cid]
if m_cycle:
clauses.append("m_cycle=?")
args.append(m_cycle)
if w_phase:
clauses.append("w_phase=?")
args.append(w_phase)
if d_event:
clauses.append("d_current_event=?")
args.append(d_event)
if decision_signal:
clauses.append("decision_signal=?")
args.append(decision_signal)
if min_overall_score is not None:
clauses.append("overall_score>=?")
args.append(min_overall_score)
if min_alignment is not None:
clauses.append("alignment>=?")
args.append(min_alignment)
where = " AND ".join(clauses)
args.extend([limit, offset])
c = _conn()
try:
cur = c.execute(
f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?",
args,
)
return [dict(r) for r in cur.fetchall()]
finally:
c.close()
def get_symbol(
ts_code: str,
trade_date: str | None = None,
combo_id: str | None = None,
) -> dict[str, Any] | None:
cid = combo_id or "d_w_m"
td = trade_date or latest_trade_date(cid)
if not td:
return None
c = _conn()
try:
cur = c.execute(
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?",
(td, cid, ts_code),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
c.close()
+51
View File
@@ -0,0 +1,51 @@
"""Crypto symbol → Chinese display name for screener UI."""
from __future__ import annotations
# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base)
_BASE_CN: dict[str, str] = {
"BTC": "比特币",
"ETH": "以太坊",
"SOL": "索拉纳",
"XAU": "黄金",
"XAG": "白银",
"SAGA": "Saga",
"CL": "原油",
"ZEC": "大零币",
"XRP": "瑞波币",
"DOGE": "狗狗币",
"BNB": "币安币",
"SUI": "Sui",
"BILL": "Bill",
"BZ": "BZ",
"LAB": "Lab",
"TON": "通联币",
"CRCL": "Circle",
"SNDK": "SNDK",
"1000PEPE": "千倍佩佩",
"PEPE": "佩佩",
"CHIP": "CHIP",
"WIF": "狗帽子",
}
def base_asset(symbol: str) -> str:
"""BTC/USDT:USDT → BTC1000PEPE/USDT:USDT → 1000PEPE."""
s = (symbol or "").strip()
if not s:
return ""
head = s.split(":")[0]
return head.split("/")[0].upper() if "/" in head else head.upper()
def display_name_cn(symbol: str) -> str:
base = base_asset(symbol)
if not base:
return symbol or ""
return _BASE_CN.get(base, base)
def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]:
if not symbols:
return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()}
return {s: display_name_cn(s) for s in symbols}
+4
View File
@@ -0,0 +1,4 @@
"""Wyckoff Screener engine version — bump when rules change."""
WYCKOFF_ENGINE_VERSION = "v1.0.0"
ARCHITECTURE_VERSION = "1.0"
+2 -6
View File
@@ -22,16 +22,13 @@
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约 - ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a` - ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
- ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数) - ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
- ECR-007 Final Approval / `276481e`Wyckoff Live Structure`live.py`);Confirmed ≠ Liveexecution 仅 confirmed
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
## 硬约束提醒 ## 硬约束提醒
- `/api/analyze` 字段可增不可删 - `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义 - 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法 - 威科夫为独立叠层(ECR-003);勿借机改缠论算法
- Live candidate **不得**进入 execution交易 L2+ → RISK_REVIEW + EXPLive 须 Human - 交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务 ## 已知债务
@@ -40,4 +37,3 @@
- 内存泄漏尚无自动化 heap/监听断言 - 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改) - `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参 - 威科夫启发式参数未做 UI 调参
- ECR-007 待 PR 合入 `dev`
@@ -1,70 +0,0 @@
# Backend Design: ECR-007 Wyckoff Live Structure
| Field | Value |
|-------|-------|
| ID | BD-2026-007 |
| ECR | ECR-007 |
| Change Level | L2 |
| Status | Approved |
| Author | Architect (LOOP-RUN-005 Planner) |
| Date | 2026-08-07 |
| Risk | High (domain / execution boundary) |
---
## Context
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
- 非目标:改 Confirmed 门槛;自动交易;策略。
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-001FROZEN)。
## Architecture Change / Change Boundary
```text
OHLCV
→ detect_trading_ranges (Confirmed path)
→ detect_bias_and_events / build_phases ← Confirmed(阈值不降)
→ analyze_live_structure ← Live(只读 confirmed
→ cycles[i] = { lifecycle, confirmed, live }
→ API analyze + Summary UI
→ execution_signal_from_wyckoff(confirmed only)
```
| Layer | May change | Must not |
|-------|------------|----------|
| Confirmed | assemble into `confirmed{}` | relax Spring/SOS rules |
| Live | `live.py` heuristics | write into confirmed.events |
| Execution helper | source=confirmed gate | consume candidates |
| UI | Summary partition | treat Live as order |
## Backend Change Boundary
Live outputs are **observation**. Execution boundary:
```python
assert execution_signal.source == "confirmed"
# live-only payload → None
```
## Data contract
See WYCKOFF-LIVE-STRUCTURE-001. Top-level `phases`/`events` mirror **Confirmed** only.
## delivery_constraints
- BD Status Approved
- TEST_REPORT commands/result/date
- CODE_REVIEW handoff
- TRACEABILITY commit
- out_of_scope + execution_source_confirmed_only
## Test Plan
1. Live candidates not in confirmed.events
2. CONFIRMED lifecycle when Spring+SOS confirmed
3. execution_signal source=confirmed; live-only → None
4. analyze contract keys include live/lifecycle
## Rollback
Remove live assembly path; Summary falls back to confirmed-only.
-10
View File
@@ -1,14 +1,5 @@
# CHANGELOG # CHANGELOG
## Unreleased — 2026-08-07
### ECR-007L2LOOP-RUN-005
- Wyckoff **Live Structure**`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
- Event candidatesSpring/SOS/LPS/UTAD+ 可解释 confidenceSummary Confirmed/Live 分区
- `execution_signal_from_wyckoff` **仅** `source=confirmed`Live-only → None
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
## Unreleased — 2026-08-06 ## Unreleased — 2026-08-06
### ECR-004L2Reviewed ### ECR-004L2Reviewed
@@ -16,7 +7,6 @@
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度 - 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负 - 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数) - `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
### ECR-003L2Reviewed ### ECR-003L2Reviewed
@@ -1,60 +0,0 @@
# ECR-007
**Title:** Wyckoff Live Structure
**Status:** Approved
**Date:** 2026-08-07
**Change Level:** L2
**Human:** Approved (LOOP-RUN-005 Start Authorization)
## Change
Add **Live / Developing** structure layer beside **Confirmed** Wyckoff engine: lifecycle, FORMING candidates (Spring/SOS/LPS/UTAD), explainable confidence, Summary partition. Keep Confirmed thresholds unchanged; execution may only consume Confirmed.
## Motivation
LOOP-RUN-005 — domain-state complexity under Adapter v0.1 STABLE (Confirmed ≠ Live ≠ execution).
## Scope
### Allowed (IN)
- `chanlun/analysis/wyckoff/live.py` + engine assembly
- lifecycle / confirmed / live payload
- Event candidates + confidence
- API contract + Summary UI
- tests + docs notes (WYCKOFF-LIVE-STRUCTURE-001)
### Forbidden (OUT)
- execution signal automation / auto trading
- strategy / maker / decide_quotes / `strategies/**`
- lowering Confirmed thresholds
- Live candidate replacing Confirmed
- ESS / Loop / Adapter changes
## Risk
| Risk | Mitigation |
|------|------------|
| Live → execution | `execution_signal_from_wyckoff` source=confirmed only; live-only → None |
| Confirmed pollution | candidates never written to confirmed.events |
| Domain confusion in UI | Summary Confirmed vs Live partitions |
## Acceptance Criteria
- [ ] Approved BD-2026-007
- [ ] Confirmed logic not relaxed
- [ ] Live ≠ execution signal (tests)
- [ ] Lifecycle verifiable
- [ ] Artifact chain + Gate PASS
## Rollback
- Disable live assembly; remove live.py; revert Summary partition
## Linked
- Note: `docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md` (FROZEN)
- BACKEND_DESIGN: `docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md`
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md`
- Loop: LOOP-RUN-005
@@ -1,26 +0,0 @@
# ENGINEERING_SPEC — ECR-007 Wyckoff Live Structure
**ECR:** ECR-007
**BD:** BD-2026-007
**Status:** Approved
## Intent
Operators observe FORMING Wyckoff structure without feeding Live into execution.
## Modules
| Module | Role |
|--------|------|
| `events.py` / `range.py` | Confirmed facts |
| `live.py` | Live candidates + confidence + lifecycle hint |
| `engine.py` | Assemble cycles[].confirmed / .live |
| `execution_signal_from_wyckoff` | Confirmed-only gate |
## Lifecycle
`UNKNOWN → FORMING → CONFIRMED → COMPLETED`
## Non-goals
strategies, maker, Live-as-signal, Confirmed threshold cuts.
@@ -1,21 +0,0 @@
# Handoff
**From:** Architect
**To:** Engineer
**ECR:** ECR-007
**State:** build
**Date:** 2026-08-07
## Artifacts
- [x] ECR-007 Approved
- [x] BACKEND_DESIGN BD-2026-007
- [x] Note WYCKOFF-LIVE-STRUCTURE-001 FROZEN
- [ ] TEST_REPORT / CODE_REVIEW
## Restrictions
- Do not lower Confirmed thresholds
- Do not let Live feed execution
- Do not touch strategies/**
## Goal
Ship Confirmed/Live separation + tests + Summary; Gate PASS.
-27
View File
@@ -1,27 +0,0 @@
# Code Review — ECR-007
**From:** Reviewer
**To:** Guardian / Human
**ECR:** ECR-007
**BD:** BD-2026-007
**Date:** 2026-08-07
**Decision:** PASS
## Checklist
| Item | Result | Notes |
|------|--------|-------|
| State machine boundary | PASS | lifecycle UNKNOWN/FORMING/CONFIRMED/COMPLETED; cycles[0]=ACTIVE |
| confidence explainability | PASS | cycle/phase/event/structure/volume/overall — not black-box |
| backward compatibility | PASS | top-level phases/events still Confirmed mirror |
| Live ≠ execution | PASS | execution_signal_from_wyckoff source=confirmed; live-only None |
| Confirmed thresholds | PASS | no intentional cut for Live; structural support fix is robustness (eaten spring) |
## Findings
1. Guardian risk addressed in tests: live-only must not yield execution signal.
2. Summary UI partitions Confirmed vs Live (observation).
## Decision
**PASS**
@@ -1,14 +0,0 @@
# Handoff — Engineer → Reviewer
**ECR:** ECR-007
**Date:** 2026-08-07
## Delivered
- `chanlun/analysis/wyckoff/live.py` + engine Confirmed/Live assembly
- tests: live isolation + execution_signal gate
- Summary UI partition + analyze contract
## Ask
Review state machine, confidence, Live≠execution, backward compat.
+1 -2
View File
@@ -34,11 +34,10 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
## Active anchors ## Active anchors
- ECR: ECR-002/003/004 ReviewedECR-007 Final ApprovalLive Structure,待合入 `dev` - ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化
- EXP: N/A - EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md` - TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md` - Memory: `docs/AGENT_MEMORY.md`
- Loop archive: `docs/runs/LOOP-RUN-005/`
## Pointers ## Pointers
+4 -7
View File
@@ -1,11 +1,11 @@
# STATE # STATE
**owner:** idle **owner:** idle
**active_ecr:** noneECR-007 Final Approval;待合入 `dev` **active_ecr:** noneECR-004 Reviewed;待本批提交合入
**phase:** post-approval **phase:** post-review
**system_version:** v1.0.0 **system_version:** v1.0.0
**strategy_version:** unchanged **strategy_version:** unchanged
**updated:** 2026-08-07 **updated:** 2026-08-06
## Recent ## Recent
@@ -16,11 +16,8 @@
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 | | ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 | | ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 | | ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · LOOP-RUN-005 |
## Notes ## Notes
- ECR-007**FINAL_APPROVAL** · gate PASS · Confirmed ≠ Live ≠ execution - ECR-004**Approve**14 passed);无币种独立参数
- 归档:`docs/runs/LOOP-RUN-005/`
- 未请求新 system tag - 未请求新 system tag
- 分支 `feature/ECR-007-wyckoff-live-structure` 待 PR → `dev`
-10
View File
@@ -1,10 +0,0 @@
ecr: ECR-007
owner: human
phase: done
updated: 2026-08-07
backend_design: BD-2026-007
loop: LOOP-RUN-005
gate: PASS
decision: FINAL_APPROVAL
implementation_commit: 276481e
notes: LOOP-RUN-005 DONE · Human Gate #2 Final Approval · archived to docs/runs/LOOP-RUN-005/
-33
View File
@@ -1,33 +0,0 @@
# TEST_REPORT — ECR-007
**Date:** 2026-08-07
**BD:** BD-2026-007
**Loop:** LOOP-RUN-005
## Commands
```bash
PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q
PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q
```
## Result
```text
tests/test_wyckoff.py ………… 9 passed
web/tests/test_analyze_contract.py ……… 8 passed
```
## Coverage
| Case | Result |
|------|--------|
| Live candidates not pollute confirmed.events | PASS |
| CONFIRMED + execution source=confirmed | PASS |
| live-only → execution None | PASS |
| analyze contract keys | PASS |
## Design Compliance
PASS — BD-2026-007; Live ≠ execution; Confirmed thresholds not cut for Live convenience
**Commit:** 276481e
-9
View File
@@ -44,12 +44,3 @@
| ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` | | ECR-004 | TR 评分选最优段 | ENG-004 | `wyckoff/range.py` | `test_wyckoff` / `test_range_scoring_skips_pretrend` |
| ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3bins≤24 | 人工 + ENG | | ECR-004 | VP/填充少 series | ENG-004 | `chart_tv.js` Top-8 + 填充 3bins≤24 | 人工 + ENG |
| ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` | | ECR-004 | 阶段最小长度 + elements_only 门闩 | ENG-004 | `events.py` + `analyze.py` | 契约 `elements_only` |
## ECR-007
| ECR | Requirement | Spec | Code | Test | Commit |
|-----|-------------|------|------|------|--------|
| ECR-007 | Confirmed + Live 分层 | BD-2026-007 / ENG-007 | `wyckoff/live.py` + `engine.py` | `test_live_*` / `test_confirmed_upgrade_*` | 276481e |
| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | 276481e |
| ECR-007 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | 276481e |
| ECR-007 | LOOP-RUN-005 | — | `docs/runs/LOOP-RUN-005/` | Gate + Artifact | 276481e |
-72
View File
@@ -1,72 +0,0 @@
# WYCKOFF-LIVE-STRUCTURE-001
**Status:** FROZEN
**Depends on:** WYCKOFF-MULTI-CYCLE-001
**Scope:** Live / Developing 结构层(独立于 Confirmed Engine
## 核心原则
| Layer | 定位 |
|-------|------|
| Confirmed Engine | 历史结构事实 |
| Live Engine | 当前结构推演 |
禁止:
- 降低 Spring/SOS Confirmed 条件
- 用 Live candidate 替代 Confirmed event
- Execution 消费 Live / FORMING / Candidate / Prediction
## 状态机
```
UNKNOWN → FORMING → CONFIRMED → COMPLETED
```
## 数据契约(Live 不进 events[]
```json
{
"cycles": [{
"id": 0,
"lifecycle": "FORMING",
"confirmed": { "phases": [], "events": [] },
"live": {
"phase_candidate": "D",
"event_candidates": [{ "type": "SOS", "confidence": 0.62, "confirmed": false }],
"next_expected": "LPS",
"confidence": { "cycle": 0.72, "phase": 0.68, "event": 0.55, "overall": 0.65 }
}
}],
"live": { "...": "顶层镜像 cycles[0].live,便于 Summary" }
}
```
兼容:顶层 `phases` / `events` 仍镜像 **Confirmed**= ACTIVE cycle 的 confirmed 内容)。
## Candidate v1(仅启发式)
- Range Formation:横盘时长、波动收敛 → Potential Trading Range
- Phase C candidate:测低 / 下影 / 缩量
- Event candidatesSpring / SOS / LPS / UTAD only
## Confidence
可解释分层:`cycle` / `phase` / `event` / `overall`structure+volume+event 加权),禁止黑盒 “AI probability”。
## Execution
```
assert execution_signal.source == "confirmed"
```
## No Change
- Confirmed 检测阈值、MULTI-CYCLE-001 排序、缠论 / strategies / chan_tv
## Only Change
- `chanlun/analysis/wyckoff/live.py`
- engine 组装 `lifecycle` / `confirmed` / `live`
- Summary 面板分区
- 测例
-76
View File
@@ -1,76 +0,0 @@
# WYCKOFF-LIVE-VALIDATION-001
**Status:** DRAFT(待确认执行后 FROZEN
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
## 不做
- 不新增 BC / AR / ST / UT / UTADv1 已够)
- 不降低 Confirmed 门槛
- 不让 Execution 消费 Live
## 目标指标(先看演化,不看「准确率」口号)
### 1) Candidate → Confirmed 转化率
```
candidate_to_confirmed_rate = confirmed_count / candidate_count
```
按 event type 分组:Spring / SOS / LPS / UTAD。
### 2) 提前量(Lead
```
lead_bars = confirmed_bar_index - first_candidate_bar_index
lead_price = |price_at_confirmed - price_at_first_candidate|
```
例:Spring candidate @ 62000 → Confirmed @ 63500 → lead_price=1500。
### 3) False Positive
```
false_candidate_rate = expired_unconfirmed / candidate_count
```
候选出现后,在窗口内未升格为 Confirmed,且价格无效化(如 Spring 后继续破位)。
## 采集方式(建议)
离线回放 / 批跑(非改 Live 规则):
```
for each bar in timerange:
run analyze_wyckoff(df[:bar])
log: cycle_id, lifecycle, live.candidates[], confirmed.events[]
```
输出:`reports/wyckoff_live_validation_{symbol}_{tf}_{date}.json` + 简表 CSV。
## Summary 文案(可选后续,本 ECR 可只做数据)
交易终端语言示例(不阻塞指标采集):
```
BTC 4H Wyckoff
Lifecycle: CONFIRMED
Confirmed: Accumulation → SOS → LPS
Current: Phase D continuation
Watching: New SOS extension
Confidence: 0.60
Risk: Below LPS invalidation
```
## 验收
1. 能对 BTC 4h(及可选 1h)跑出至少一类 Spring/SOS 的转化率与提前量
2. 报告可复现(固定 timerange + seed/数据快照说明)
3. 不修改 Confirmed / Live 检测逻辑(只读 + 日志)
## Only Change(确认执行后)
- `scripts/``tests/` 下批跑采集脚本
- `docs/notes``reports/` 输出样例
- 可选:Summary 文案升级(独立小项)
-62
View File
@@ -1,62 +0,0 @@
# WYCKOFF-MULTI-CYCLE-001
**Status:** FROZEN
**Scope:** Wyckoff Cycle Detection Layer
## No Change
- `chan.py` / 笔 / 段 / 中枢
- `strategies/`
- `/chan_tv`
## Only Change
- wyckoff range detection
- wyckoff engine payload
- API localization
- chart rendering
- tests
## Frozen Rules
1. 每个 TF 最大 8 个周期
2. `cycles[0]` 永远为 ACTIVE`cycles[1:]` 为 HISTORICAL
3. **禁止**用 `cycles[-1]` 判断 active;唯一来源:`active_cycle = cycles[0]`
4. 周期不可重叠;按时间倒序(近 → 远)
5. 顶层字段只镜像 `cycles[0]`
6. 历史 cycle 只用于展示/分析,不参与当前交易决策
7. 多 TF 只同步 active cycle`prefer_start_time` ← 主 TF `cycles[0]`
8. 每个 cycle 必须可追溯:`period` / `status` / `role` / `confidence`
9. 嵌套箱:`overlap_ratio < 0.2` 才可并存;否则丢弃
10. 验收重点:历史周期稳定复现 + active 不漂移
## Layer Duties
```
range.py
_detect_in_window() → TradingRange # 仅起止、高低、结构分
detect_trading_ranges() → list[TR] # 倒序扫 + 过滤 + mask
engine.py
phases / events / VP / confidence aggregation → cycles[]
```
## Filter Order(不可改)
```
candidate window
→ detect range
→ quality filter
→ trend contamination filter
→ overlap filter (<0.2)
→ accept cycle
→ mask
```
禁止先 mask 再判断质量。
## Display / Summary (2026-08-06)
- 图面阶段标记:`{TF} C{id} Phase {X}`;事件:`{TF} C{id} {Event}`
- Cycle Summary 面板:消费 `cycles[0]`,写入 `window.wyckoffCycleSummary`
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
@@ -1,18 +0,0 @@
{
"ecr": "ECR-007",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-06T19:14:19Z"
}
-18
View File
@@ -1,18 +0,0 @@
# LOOP-RUN-005 — ECR-007 archive
**Feature:** WYCKOFF-LIVE-STRUCTURE
**ECR:** ECR-007 · **BD:** BD-2026-007
**Decision:** FINAL_APPROVAL · gate PASS
**Implementation:** `276481e`
## Contents
| Path | Note |
|------|------|
| `task.yaml` / `result.yaml` / `human_interventions.yaml` | Loop runner state |
| `ECR-007-gate-report.json` | ess-gate-check PASS |
| `artifacts/` | plan · gate · code_review · test_report |
Code diff 以 git commit `276481e` 为准(未归档 192KB `diff.patch`)。
Working dirs `.gates/` / `loop/` 已忽略,勿再提交。
@@ -1,12 +0,0 @@
{
"stage": "code_reviewer",
"decision": "PASS",
"checks": {
"state_machine_boundary": "PASS",
"confidence_explainability": "PASS",
"backward_compatibility": "PASS",
"live_ne_execution": "PASS",
"confirmed_thresholds": "PASS"
},
"artifact": "docs/HANDOFF/ECR-007-code-review.md"
}
@@ -1,18 +0,0 @@
{
"ecr": "ECR-007",
"result": "PASS",
"ess_version": "v1.0",
"gate_version": "0.1.2",
"project_profile": "unknown",
"checks": {
"artifact": true,
"role_boundary": true,
"backend_boundary": true,
"traceability": true,
"tests": true
},
"violations": [],
"errors": [],
"warnings": [],
"timestamp": "2026-08-06T19:14:19Z"
}
@@ -1,84 +0,0 @@
artifact_schema:
version: 1
# LOOP-RUN-005 Planner — domain-state complexity (observe Confirmed vs Live)
layers:
- id: confirmed_engine
role: historical structure facts (range/phases/events) — thresholds UNCHANGED
- id: live_engine
role: FORMING candidates + confidence — independent of Confirmed writes
- id: lifecycle
role: UNKNOWN → FORMING → CONFIRMED → COMPLETED per cycle
- id: api_contract
role: analyze payload cycles[].confirmed / cycles[].live / top-level live mirror
- id: summary_ui
role: Confirmed vs Live partitioned Summary (observation only)
delivery_constraints:
required:
- commit_exists_in_traceability_or_test_report
- bd_status_format_approved
- test_report_with_commands_result_date
- code_review_handoff
- out_of_scope_declared
- execution_source_confirmed_only
gate:
ecr: ECR-007
command: ess-gate-check --ecr ECR-007
out_of_scope:
- execution signal automation / auto trading
- strategy / maker / decide_quotes / strategies/**
- lowering Confirmed Spring/SOS thresholds
- using Live candidate as Confirmed event or execution input
- Subagents / Adapter v0.2 / auto-retry
- chan algorithm (笔/线段/中枢) changes
scope:
files:
- chanlun/analysis/wyckoff/live.py
- chanlun/analysis/wyckoff/engine.py
- chanlun/analysis/wyckoff/__init__.py
- chanlun/analysis/wyckoff/events.py
- chanlun/analysis/wyckoff/range.py
- tests/test_wyckoff.py
- web/api/analyze.py
- web/static/js/app/ui.js
- web/templates/index.html
- web/tests/test_analyze_contract.py
- tests/fixtures/analyze_contract_keys.json
- docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md
- docs/ECR/ECR-007-wyckoff-live-structure.md
- docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md
- docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md
- docs/HANDOFF/ECR-007-architect-to-engineer.md
- docs/HANDOFF/ECR-007-code-review.md
- docs/HANDOFF/ECR-007-engineer-to-reviewer.md
- docs/TEST_REPORT/ECR-007.md
- docs/STATE/ECR-007.md
- docs/TRACEABILITY.md
- docs/CHANGELOG/CHANGELOG.md
boundary:
forbidden:
- strategies/
- decide_quotes / maker
- Live → execution_signal
- ESS / Loop v1.1 / Adapter v0.1
acceptance:
- lifecycle + confirmed/live separation in analyze_wyckoff output
- event_candidates confirmed=false; not in top-level events unless Confirmed
- execution_signal_from_wyckoff source==confirmed; live-only → None
- Summary shows Confirmed vs Live partition
- pytest test_wyckoff + analyze_contract green
- ess-gate-check ECR-007
risks: |
Primary Guardian risk: Live candidate mistaken for execution signal.
Code Review: state machine boundary, confidence explainability, backward compat of phases/events.
notes: |
Planner must name Confirmed / Live / Lifecycle / Event Candidate explicitly.
delivery_constraints include execution_source_confirmed_only.
@@ -1,10 +0,0 @@
{
"stage": "validator",
"result": "PASS",
"commands": [
"PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q",
"PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
],
"summary": "17 passed (9 wyckoff + 8 contract)",
"notes": "Live isolation + execution_signal confirmed-only"
}
@@ -1,11 +0,0 @@
interventions:
- stage: START_AUTHORIZATION
reason: "authorize LOOP-RUN-005 ECR-007 Wyckoff Live Structure (supervised; Adapter v0.1 STABLE)"
note: "Human Gate #1 — Goal + Authorization merged"
- stage: FINAL_APPROVAL
reason: "LOOP-RUN-005 approved — proceed to --approve and archive"
note: "Human Gate #2"
notes: |
No Plan Mode; no mid-build confirm; no Subagents / Adapter v0.2 / auto-retry.
Live ≠ execution signal held; TR-COMMIT BLOCK→PASS retained as training signal.
Final Approval distinct from Start Authorization.
-49
View File
@@ -1,49 +0,0 @@
loop:
id: LOOP-RUN-005
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
ecr: ECR-007
current_state: DONE
retry_count: 0
history:
- state: CREATED
timestamp: '2026-08-06T19:12:00Z'
actor: runner
result: INIT
- state: CREATED
timestamp: '2026-08-06T19:13:48Z'
actor: runner
result: PASS
detail: →PLANNING
- state: PLANNING
timestamp: '2026-08-06T19:13:48Z'
actor: runner
result: PASS
detail: →BUILDING
- state: BUILDING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →VALIDATING
- state: VALIDATING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →CODE_REVIEW
- state: CODE_REVIEW
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →GUARDING
- state: GUARDING
timestamp: '2026-08-06T19:14:34Z'
actor: runner
result: PASS
detail: →READY_FOR_APPROVAL
- state: READY_FOR_APPROVAL
timestamp: '2026-08-06T19:19:41Z'
actor: runner
result: APPROVED
- state: DONE
timestamp: '2026-08-06T19:19:41Z'
actor: runner
result: DONE
-77
View File
@@ -1,77 +0,0 @@
# LOOP-RUN-005 — ECR-007 Wyckoff Live Structure
# Adapter v0.1 STABLE · single agent · supervised
# Human Gate #1: Start Authorization granted
id: LOOP-RUN-005
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
ecr: ECR-007
project_profile: "2026.08"
goal: |
验证 Engineering Loop v1.1 + Adapter v0.1 在高领域状态复杂度 Feature 下的执行稳定性。
实现 Wyckoff Confirmed + Live Structure 分层,观察层与执行层严格隔离。
authorization:
approved_by: human
feature: ECR-007
run: LOOP-RUN-005
constraints:
- no_ess_change
- no_loop_v1_1_change
- no_adapter_v0_1_change
- single_agent
- supervised
- no_subagents
- no_auto_retry
- no_live_as_execution_signal
- no_confirmed_threshold_lowering
constraints:
allowed:
- "chanlun/analysis/wyckoff/**"
- "tests/test_wyckoff.py"
- "tests/fixtures/**"
- "tests/generate_golden.py"
- "tests/test_golden_pipeline.py"
- "web/api/analyze.py"
- "web/api/pages.py"
- "web/static/js/app/**"
- "web/templates/index.html"
- "web/tests/**"
- "web/services/runtime/timeframes.py"
- "docs/**"
- "loop/**"
forbidden:
- "strategies/**"
- "**/decide_quotes*"
- "maker/**"
- "skills/engineering-spec-system/**"
- "docs/architecture/ENGINEERING-LOOP-V1.1.md"
notes:
- Confirmed detection thresholds UNCHANGED
- Live candidates must never replace Confirmed events
- execution_signal_from_wyckoff source must be confirmed only
acceptance:
criteria:
- Confirmed logic unchanged (events.py confirm rules not relaxed)
- execution only consumes confirmed
- Live ≠ execution signal
- lifecycle transitions verifiable (UNKNOWN/FORMING/CONFIRMED/COMPLETED)
- API contract + Summary display Confirmed/Live separation
- Artifact chain complete
commands:
- "PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q"
- "PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
execution:
autonomy: supervised
adapter: none
ess:
gate_command: "python ${ESS_ROOT}/scripts/ess-gate-check.py --project . --ecr ECR-007"
observe:
planner_domain: Confirmed + Live + Lifecycle + Event Candidate
guardian_risk: live_candidate_must_not_become_execution_signal
human_gates: start_authorization + final_approval
+9
View File
@@ -0,0 +1,9 @@
"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。"""
from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask
__all__ = [
"compute_market_state_8h",
"spring_gate_mask",
"utad_gate_mask",
]
+155
View File
@@ -0,0 +1,155 @@
"""
Market State Engine v1 因果可计算无未来函数
仅使用截至当前 8h K 线已收盘信息
EMA50/200ADXEMA slope价格相对 MA200 距离
输出 0100 分数 + 主导状态标签argmax Decision Gate 使用
禁止用事后涨跌路径标注 cycle
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import talib.abstract as ta
def _clip01(x: pd.Series) -> pd.Series:
return x.clip(lower=0.0, upper=1.0)
def compute_market_state_8h(df: pd.DataFrame) -> pd.DataFrame:
"""
在原生 8h OHLCV 上计算状态分数
返回列: accumulation_score, markup_score, distribution_score,
markdown_score, range_score, market_state, allow_spring, allow_utad
"""
out = df.copy()
out["ema50"] = ta.EMA(out, timeperiod=50)
out["ema200"] = ta.EMA(out, timeperiod=200)
out["adx"] = ta.ADX(out, timeperiod=14)
# slope: 过去 6 根 8h(约 2 天),仅用历史
out["ema_slope"] = (out["ema50"] - out["ema50"].shift(6)) / out["ema50"].shift(6).replace(0, np.nan)
out["dist_ema200"] = (out["close"] - out["ema200"]) / out["ema200"].replace(0, np.nan)
bull = (out["close"] > out["ema200"]) & (out["ema50"] > out["ema200"])
bear = (out["close"] < out["ema200"]) & (out["ema50"] < out["ema200"])
range_m = (~bull) & (~bear)
slope = out["ema_slope"].fillna(0.0)
dist = out["dist_ema200"].fillna(0.0)
adx = out["adx"].fillna(0.0)
# ---- 分数:连续、因果、可解释 ----
# accumulation: 仍处熊偏结构,但下跌斜率缓和 / 略抬升(吸筹语境)
accum = (
0.45 * bear.astype(float)
+ 0.35 * _clip01((slope + 0.02) / 0.04) # slope 从 -2%→+2% 映射
+ 0.20 * _clip01((0.05 + dist) / 0.10) # 仍在 MA200 下方但不极端深
) * 100.0
# markup: 牛偏 + 正斜率 + 价格在 MA200 上方
markup = (
0.40 * bull.astype(float)
+ 0.35 * _clip01(slope / 0.02)
+ 0.25 * _clip01(dist / 0.08)
) * 100.0
# distribution: 牛偏但斜率走平/向下(顶部语境)
distrib = (
0.40 * bull.astype(float)
+ 0.40 * _clip01((-slope) / 0.015)
+ 0.20 * _clip01((0.12 - dist.abs()) / 0.12)
) * 100.0
# markdown: 熊偏 + 明显负斜率
markdown = (
0.45 * bear.astype(float)
+ 0.40 * _clip01((-slope) / 0.02)
+ 0.15 * _clip01((-dist) / 0.10)
) * 100.0
# range: 非明确牛熊,或 ADX 偏低
range_s = (
0.50 * range_m.astype(float)
+ 0.30 * _clip01((22.0 - adx) / 22.0)
+ 0.20 * (1.0 - bull.astype(float)) * (1.0 - bear.astype(float))
) * 100.0
out["accumulation_score"] = accum.clip(0, 100)
out["markup_score"] = markup.clip(0, 100)
out["distribution_score"] = distrib.clip(0, 100)
out["markdown_score"] = markdown.clip(0, 100)
out["range_score"] = range_s.clip(0, 100)
# 主导状态:与归因研究同一套因果规则(非事后路径标注)
# bear+非急跌斜率 → accumulationbull+正斜率 → markup;…
state = np.full(len(out), "range", dtype=object)
state[(bear) & (slope < -0.01)] = "markdown"
state[(bear) & (slope >= -0.01)] = "accumulation"
state[(bull) & (slope > 0.005)] = "markup"
state[(bull) & (slope <= 0.005)] = "distribution"
out["market_state"] = state
# 默认 Gate v1.1:状态集合(soft 阈值由 apply_decision_gate 覆盖)
out = apply_decision_gate(out, mode="state_set")
return out
def apply_decision_gate(
df: pd.DataFrame,
*,
mode: str = "state_set",
q_sum: float = 100.0,
q_bad: float = 55.0,
) -> pd.DataFrame:
"""
Decision Gate因果
mode:
- state_set: state {accumulation, markup} / UTAD 镜像
- soft_sum: state_set (accum+markup) >= q_sum
- soft_bad_cap: state_set max(distrib, range, markdown) <= q_bad
"""
out = df.copy()
state = out["market_state"]
spring_state = state.isin(["accumulation", "markup"])
utad_state = state.isin(["distribution", "markdown"])
good_sum = out["accumulation_score"] + out["markup_score"]
bad_max = out[["distribution_score", "range_score", "markdown_score"]].max(axis=1)
# UTAD 镜像:good = distrib+markdownbad = accum/range
utad_good_sum = out["distribution_score"] + out["markdown_score"]
utad_bad_max = out[["accumulation_score", "range_score", "markup_score"]].max(axis=1)
if mode == "state_set":
out["allow_spring"] = spring_state
out["allow_utad"] = utad_state
elif mode == "soft_sum":
out["allow_spring"] = spring_state & (good_sum >= float(q_sum))
out["allow_utad"] = utad_state & (utad_good_sum >= float(q_sum))
elif mode == "soft_bad_cap":
out["allow_spring"] = spring_state & (bad_max <= float(q_bad))
out["allow_utad"] = utad_state & (utad_bad_max <= float(q_bad))
else:
raise ValueError(f"unknown gate mode: {mode}")
out["gate_mode"] = mode
out["gate_q_sum"] = float(q_sum)
out["gate_q_bad"] = float(q_bad)
return out
def spring_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
col = f"allow_spring{suffix}"
if col not in dataframe.columns:
return pd.Series(True, index=dataframe.index)
return dataframe[col].fillna(False).astype(bool)
def utad_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
col = f"allow_utad{suffix}"
if col not in dataframe.columns:
return pd.Series(True, index=dataframe.index)
return dataframe[col].fillna(False).astype(bool)
+155
View File
@@ -0,0 +1,155 @@
# Dry-Run Decision Checklist — GATED_V1_1_LOCKED
```text
Purpose: 上线前不改规则,只验执行链
Stack: Market State → Decision → Frozen Signal
Version: GATED_V1_1_LOCKED
Mode: dry-run / monitoring only
```
研究线已收手。本清单是 **operational acceptance**,不是新实验。
---
## Locked defaults(不可在 dry-run 中改动)
| Item | Value |
|------|--------|
| Strategy | `Wyckoff_BTC_GATED` |
| Spring | `V1_BASELINE` FROZEN |
| Gate | `market_state in {accumulation, markup}` → allow Spring |
| Soft-score | rejected |
| Range | observe only(非交易规则) |
| gate_version | `GATED_V1_1_LOCKED` |
---
## 1. 信号一致性
上线前逐项勾选:
- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 lookaheadmerge 后读的是上一根已完成 bias bar)
- [ ] `allow_spring == True` **仅当** `market_state ∈ {accumulation, markup}`
- [ ] `allow_spring == False``market_state ∈ {distribution, markdown, range}` 或缺失
- [ ] Baseline 产生 `SPRING_LONG` 且 Gate block 时:**不下单**
- [ ] 同上 blocked 事件:**写入决策日志**(见 §2),与 kept 同 schema
- [ ] UTAD(若启用)镜像:`allow_utad``{distribution, markdown}`;本清单以 Spring 为主
快速自检(可在 dry-run 启动后抽查最近 N 条日志):
```text
assert gate_version == "GATED_V1_1_LOCKED"
assert allow ⇒ market_state in {accumulation, markup}
assert market_state == "distribution" ⇒ allow == false
assert block ⇒ order_not_sent
```
---
## 2. 日志字段(每条候选信号一行)
必需字段:
| Field | Example / notes |
|-------|-----------------|
| `timestamp` | entry candle open/close timeUTC |
| `pair` | e.g. `BTC/USDT:USDT` |
| `signal_type` | `SPRING_LONG` / `UTAD_SHORT` |
| `market_state` | accumulation \| markup \| distribution \| markdown \| range \| missing |
| `allow` | `true` / `false` |
| `gate_version` | `GATED_V1_1_LOCKED` |
| `baseline_signal` | `SPRING_LONG`Gate 前 Baseline 标签) |
| `block_reason` | `not_in_allow_set` \| `state_missing` \| `state_lag` \| `""` if allow |
推荐附加(便于监控,非规则):
| Field | Notes |
|-------|--------|
| `bias_bar_time` | 决策所用已收盘 8h bar 时间 |
| `accumulation_score``range_score` | 诊断用,**不参与默认 Gate** |
| `would_enter` | Baseline 是否曾置 `enter_long=1` |
| `order_sent` | dry-run 下应为 `allow` 的结果 |
Blocked 必须落盘;禁止静默丢弃。
---
## 3. Dry-run 监控指标
周期性汇总(建议日 / 周):
| Metric | 关注点 |
|--------|--------|
| `kept_n` / `blocked_n` | 量级是否合理,非零且非异常尖刺 |
| blocked domain 分布 | **尤其 `distribution` 应仍为主要 block 源** |
| kept trade PF / expectancy | 参考,不强求 > ungated baseline |
| max DDkept / 账户) | 应相对 ungated 历史继续偏低 |
| range share among blocked | 仅观察;上升不自动改规则 |
### 2023+ OOS 参考阈值(研究窗,非调参目标)
| | Gated(研究) | 解读 |
|--|---------------|------|
| PF | ~1.34baseline ~1.45 | **不强求超过 baseline** |
| DD | ~3.4%baseline ~7.9% | **DD 应继续低** |
| full DD | ~9.6% vs ~26% | 结构性降 DD 仍是成功标准 |
Dry-run 短期 PF 波动 **不触发规则变更**
---
## 4. 报警条件
| Severity | Condition | Action |
|----------|-----------|--------|
| P0 | `market_state` 缺失或滞后(bias bar 过旧 / merge 失败) | 停新开仓,查数据链 |
| P0 | Gate 放行且 `market_state ∉ {accumulation, markup}` | 立即停机排查;视为执行链 bug |
| P0 | `distribution` 被放行 Spring | 同上 |
| P1 | blocked 样本中 `range` **长期主导** 且 kept PF/expectancy 同步恶化 | 记观察票;**不改规则**,升级人工 review |
| P2 | kept/blocked 比为 0 或异常尖刺(数据空洞) | 查 feed / 时区 / 8h 对齐 |
报警只服务执行完整性,不服务「再优化一次 Gate」。
---
## 5. 不允许事项(硬禁)
- 不调 SpringTF / ATR / stoploss / entry 形态)
- 不调 soft-score,不把 soft-score 接回默认路径
- 不全样本扫 Gate 阈值 / 状态集合
- 不因短期 dry-run PF 调规则
- 不因 `range` 小样本表现把 range 升格为交易域
- 不默认合并 ETH/SOL 进生产路径
- 不复活 LPS 分支
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
---
## 6. Go / No-Godry-run → 有限实盘)
**Go**(全部满足):
- [ ] §1 信号一致性全部勾选
- [ ] §2 日志字段齐全,blocked 可见
- [ ] §4 无未关闭的 P0
- [ ] 监控窗内 blocked 仍以坏域为主(distribution 不消失为噪音)
- [ ] 规则文件与运行配置仍为 `GATED_V1_1_LOCKED` / `state_set`
**No-Go**
- 任一 P0
- 日志无法区分 kept vs blocked
- 发现非因果 8h 状态
- 有人为改动 Spring / Gate 默认值
---
## Related
- Status: `research/SYSTEM_STATUS.md`
- Boundary: `research/VALIDITY_BOUNDARY.md`
- Strategy: `strategies/Wyckoff_BTC_GATED.py`
- State engine: `engine/market_state.py`
- Audit evidence: `scripts/wyckoff_negative_domain_audit_result.json`
- Robustness: `scripts/wyckoff_gate_robustness_slices_result.json`
+63
View File
@@ -0,0 +1,63 @@
# Wyckoff BTC System v1 — Decision Rule Locked
```
Architecture: Market State → Decision → Signal
Spring: FROZEN
Gate v1.1: LOCKED DEFAULT Decision rule (PASS)
Soft-score: REJECTED (no increment)
Hard-score: REJECTED
Minimal rule:
market_state in {accumulation, markup} -> allow Spring
else -> block Spring
Primary invalidation domain: distribution
range: observation bucket only (NOT a trading rule)
Validity: DEFINED
Confidence: MEDIUM / defined-domain PASS
Status: DEFAULT RULES FROZEN
Next: dry-run / monitoring only(见 operational checklist
```
## Operational
上线前不改规则,只验执行链:
→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md)
覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。
## Locked stack
| Layer | File | Status |
|-------|------|--------|
| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN |
| State | `engine/market_state.py` | causal v1.1 |
| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** |
| Boundary | `VALIDITY_BOUNDARY.md` | active |
## Robustness slices (blocked Spring, by year/era)
证据:`scripts/wyckoff_gate_robustness_slices_result.json`
| Slice | blocked n | dist share | top blocked | blocked PF |
|-------|-----------|------------|-------------|------------|
| 2020 | 3 | **1.00** | distribution | 0.73 |
| 2021 | 4 | **0.75** | distribution | 0.31 |
| 2022 | 1 | 1.00 | distribution | 0 |
| 2023 | 1 | 1.00 | distribution | 0 |
| 2024 | 3 | 0.33 | distribution+range | 0 |
| 2025 | 1 | 0 | range (obs) | n=1 win |
| pre_2023 | 8 | **0.875** | distribution | 0.37 |
| 2023plus | 5 | 0.40 | distribution+range | 1.22 |
Verdict: **distribution 归因在多数有样本切片上稳定**PASS)。
2023+ / 202425 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
## Do not
- 调 Spring / soft-score / Gate 阈值
- 因 range 小样本正 PF 开放 range 交易
- 复活 LPS / 默认跨资产
+85
View File
@@ -0,0 +1,85 @@
# Validity Boundary — Market-State Gated Spring
## Definition (hard)
```text
market_state in {accumulation, markup} -> allow Spring
else -> block Spring
```
Spring 信号本体 = `V1_BASELINE`FROZEN)。
Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。
Soft-score / hard-score 阈值 **不进入默认规则**
## Validity statement
Spring has positive expectancy under:
1. BTC market
2. Causal `market_state ∈ {accumulation, markup}`
3. 8h / 4h / 1h alignment
4. Trend-compatible (range already blocked in Baseline)
Invalid under:
1. `distribution`
2. `range`
3. `markdown`(对 SPRING_LONG
4. Ungated global trading
## Causal state (entry-time only)
```
bear & ema_slope >= -1% → accumulation
bull & ema_slope > +0.5% → markup
bull & ema_slope <= +0.5% → distribution
bear & ema_slope < -1% → markdown
else → range
```
## Gate performance (net fee+slip)
| Window | Baseline | Gated state_set |
|--------|----------|-----------------|
| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** |
| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** |
Confidence: **MEDIUM / defined-domain PASS**full PF 仍 < 1)。
## Negative-domain audit
`scripts/wyckoff_negative_domain_audit_result.json`
对 Baseline 全部 `SPRING_LONG`n=28)按因果状态拆 kept/blocked
| | n | PF | 含义 |
|--|---|-----|------|
| Kept | 15 | 1.09 | 全部在 markup |
| Blocked | 13 | 0.58 | **100% bad domain** |
| Blocked × distribution | 9 | **0.38** | 主杀伤区 |
| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 |
→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。
## Default stackLOCKED
```
8h causal market_state
Decision: state_set Gate v1.1 ← LOCKED
Frozen V1_BASELINE Spring / UTAD
```
## Year/era robustness(冻结前确认)
`scripts/wyckoff_gate_robustness_slices_result.json`
- pre_2023 blockeddistribution share **87.5%**blocked PF 0.37
- 多数年份 blocked 以 distribution 为首
- 2023+ blockeddistribution + range 并存;range **仅观察**,不改规则
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
**Primary invalidation domain = distribution(稳定)**
**range = observation bucket only**
+19
View File
@@ -0,0 +1,19 @@
# Spring Baseline V1 — FROZEN SNAPSHOT
勿改本目录文件。可运行副本在:
- `strategies/Wyckoff_BTC_V1_BASELINE.py`
- `config/Wyckoff_BTC_V1_BASELINE.json`
## Evidence (cost-adjusted)
| Window | Profit | n | DD | Net PF |
|--------|--------|---|-----|--------|
| Train | +1.66% | 12 | 3.6% | 1.17 |
| Validate | +9.99% | 6 | 1.8% | 6.20 |
| Test | +0.85% | 2 | 0.7% | 2.18 |
| Full | +12.74% | 20 | 3.6% | 2.02 |
| fee+slip 5bps | +6.78% | 20 | — | **1.45** |
Status: **PASS + Limited Evidence** (N=20)
Next: Phase3 → N≥50(延历史 / 多品种),不改规则。
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8823,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
"ws_token": "wyckoff-v1-baseline-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc_v1_baseline",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE FROZEN
Status: BASELINE FROZEN
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究禁止并入本文件调参
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_V1_BASELINE(IStrategy):
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_BASELINE"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)
@@ -0,0 +1,460 @@
{
"branches": {
"Spring_V1": {
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": 1.6587295176,
"trades": 12,
"dd_pct": 3.644907735100005,
"pf": 1.1700179329477578,
"winrate": 25.0,
"final": 10165.87295176,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": 9.990534148400002,
"trades": 6,
"dd_pct": 1.797834787912851,
"pf": 6.201791679101682,
"winrate": 66.66666666666666,
"final": 10999.05341484,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.8458820224000001,
"trades": 2,
"dd_pct": 0.7197049309999966,
"pf": 2.175317808681236,
"winrate": 50.0,
"final": 10084.58820224,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": 8.166882314399999,
"trades": 12,
"dd_pct": 3.4837023928902555,
"pf": 1.9398544482027922,
"winrate": 33.33333333333333,
"final": 10816.688231439999,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 4.2503064875000005,
"trades": 8,
"dd_pct": 3.173714645599994,
"pf": 2.084295240772406,
"winrate": 50.0,
"final": 10425.03064875,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": -4.3352539371,
"trades": 6,
"dd_pct": 4.404162180500007,
"pf": 0.15746188404490422,
"winrate": 16.666666666666664,
"final": 9566.47460629,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": 7.831216539699999,
"trades": 26,
"dd_pct": 7.883451762900004,
"pf": 1.454582067425369,
"winrate": 34.61538461538461,
"final": 10783.12165397,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": 6.782772099999998,
"trades": 20,
"dd_pct": 7.851805397900007,
"pf": 1.4511324473780693,
"winrate": 35.0,
"final": 10678.27721,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": 3.182909279400001,
"trades": 20,
"dd_pct": 9.126146157700004,
"pf": 1.1834520309921508,
"winrate": 35.0,
"final": 10318.29092794,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.3,
"dd": 10.0,
"note": "Spring: PF>1.3 DD<10%"
},
"verdict": {
"full_pf": 2.0183507402435503,
"full_dd": 3.644907735100005,
"trades_per_year": 5.555555555555555,
"net_mid_pf": 1.4511324473780693,
"target_pf_ok": true,
"target_dd_ok": true
}
},
"LPS_V1": {
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -1.2518571096,
"trades": 1,
"dd_pct": 1.251857109600005,
"pf": 0.0,
"winrate": 0.0,
"final": 9874.81428904,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -0.24613064569999998,
"trades": 1,
"dd_pct": 0.24613064569999552,
"pf": 0.0,
"winrate": 0.0,
"final": 9975.38693543,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -1.6902037039,
"trades": 2,
"dd_pct": 1.6902037039000062,
"pf": 0.0,
"winrate": 0.0,
"final": 9830.97962961,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -2.0801051709,
"trades": 2,
"dd_pct": 2.080105170900006,
"pf": 0.0,
"winrate": 0.0,
"final": 9791.98948291,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS: PF>1.2, 次数增加"
},
"version": "LPS_V1.1",
"verdict": {
"full_pf": 0.0,
"full_dd": 1.4952529703999973,
"trades_per_year": 0.5555555555555556,
"net_mid_pf": 0.0,
"target_pf_ok": false,
"target_dd_ok": true
}
},
"LPS_V2": {
"version": "LPS_V2",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -3.5049591933000004,
"trades": 3,
"dd_pct": 3.504959193300001,
"pf": 0.0,
"winrate": 0.0,
"final": 9649.50408067,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -1.6143743830000001,
"trades": 2,
"dd_pct": 1.614374382999995,
"pf": 0.0,
"winrate": 0.0,
"final": 9838.5625617,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -5.0599199851,
"trades": 5,
"dd_pct": 5.059919985100005,
"pf": 0.0,
"winrate": 0.0,
"final": 9494.00800149,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -4.5549168852,
"trades": 7,
"dd_pct": 7.020877735199993,
"pf": 0.3512325585213674,
"winrate": 14.285714285714285,
"final": 9544.50831148,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -5.371762636800001,
"trades": 7,
"dd_pct": 8.1297357765,
"pf": 0.33924511392759654,
"winrate": 14.285714285714285,
"final": 9462.82373632,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
},
"verdict": {
"full_pf": 0.39720654015221873,
"full_dd": 6.479119889400008,
"trades_per_year": 1.9444444444444444,
"net_mid_pf": 0.3512325585213674,
"target_pf_ok": false,
"target_dd_ok": true,
"freq_ok": false,
"regime_logic_ok": true,
"status": "FAIL",
"hypothesis": "4h native SOS → 1h LPS"
}
}
},
"portfolio_note": {
"spring_tpy": 5.555555555555555,
"lps_tpy": 0.5555555555555556,
"sum_tpy_approx": 6.111111111111111,
"combined_target_tpy": "15-25",
"lps_status": "FAIL",
"spring_status": "PASS"
},
"system_status": {
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
"lps": "FAIL",
"spring_tpy": 5.555555555555555,
"lps_tpy": 1.9444444444444444,
"next": "若 LPS PASS → 组合层;否则 Spring-only"
}
}
@@ -0,0 +1,141 @@
{
"note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": 1.6587295176,
"trades": 12,
"dd_pct": 3.644907735100005,
"pf": 1.1700179329477578,
"winrate": 25.0,
"final": 10165.87295176,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": 9.990534148400002,
"trades": 6,
"dd_pct": 1.797834787912851,
"pf": 6.201791679101682,
"winrate": 66.66666666666666,
"final": 10999.05341484,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.8458820224000001,
"trades": 2,
"dd_pct": 0.7197049309999966,
"pf": 2.175317808681236,
"winrate": 50.0,
"final": 10084.58820224,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": 8.166882314399999,
"trades": 12,
"dd_pct": 3.4837023928902555,
"pf": 1.9398544482027922,
"winrate": 33.33333333333333,
"final": 10816.688231439999,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 4.2503064875000005,
"trades": 8,
"dd_pct": 3.173714645599994,
"pf": 2.084295240772406,
"winrate": 50.0,
"final": 10425.03064875,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": -4.3352539371,
"trades": 6,
"dd_pct": 4.404162180500007,
"pf": 0.15746188404490422,
"winrate": 16.666666666666664,
"final": 9566.47460629,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": 7.831216539699999,
"trades": 26,
"dd_pct": 7.883451762900004,
"pf": 1.454582067425369,
"winrate": 34.61538461538461,
"final": 10783.12165397,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": 6.782772099999998,
"trades": 20,
"dd_pct": 7.851805397900007,
"pf": 1.4511324473780693,
"winrate": 35.0,
"final": 10678.27721,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": 3.182909279400001,
"trades": 20,
"dd_pct": 9.126146157700004,
"pf": 1.1834520309921508,
"winrate": 35.0,
"final": 10318.29092794,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"verdict": {
"full_pf": 2.0183507402435503,
"full_dd": 3.644907735100005,
"trades_per_year": 5.555555555555555,
"net_mid_pf": 1.4511324473780693,
"target_pf_ok": true,
"target_dd_ok": true
}
}
+14
View File
@@ -0,0 +1,14 @@
# LPS V1.1 — REJECTED
## Hypothesis
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
## Result
- Full: **-1.50%**, n=**2**, 全亏
- 过滤方向正确,但过度收缩 → 无统计意义
## Reject reason
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
+15
View File
@@ -0,0 +1,15 @@
# LPS V1 — REJECTED
## Hypothesis
1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随)
## Result
- Full: **-18.92%**, n=133, PF **0.73**
- Regime anomaly: **trend 亏、range 赚**(反理论)
## Reject reason
捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。
定义错误,不是参数问题。
+43
View File
@@ -0,0 +1,43 @@
# LPS V2 — REJECTED(归档,不再救援)
## Hypothesis
**4h 原生 SOS Confirm → 1h LPS Entry**
大级别事件、小级别执行(非 1h 假突破)
## Implementation
`Wyckoff_BTC_LPS_V2.py`
4h SOS: 实体收盘离开区间 + vol>MA*1.5 + close strength>0.7 + 3 根 hold
1h LPS: 首次回踩 + 0.5~1.5 ATR + vol<breakout_vol + close>prev high
## Result
| Window | Profit | n | PF |
|--------|--------|---|-----|
| Train | -3.50% | 3 | 0 |
| Validate | -1.61% | 2 | 0 |
| Test | +1.22% | 2 | 1.82 |
| Full | **-3.91%** | 7 | **0.40** |
| fee+slip | -4.55% | 7 | **0.35** |
证据文件: `wyckoff_lps_v2_phase2_result.json`
## Funnel
```
4h sos_raw 183 → confirmed 123 → 1h LPS 7
```
SOS 识别有产出;**SOS→LPS 映射无稳定边际**。
## Reject reason
在 BTC 永续当前结构下,传统股票式 SOS→LPS→Markup 假设不成立:
突破后常不给标准 LPS,或首次回踩已破坏结构。
样本少/成本/Regime 均非主因。**停止优化本假设。**
## Reopen only if
成交量分布 / 订单流 / 资金费率等新信息源进入假设。
@@ -0,0 +1,492 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC Branch B: LPS Trend Continuation独立 Setup 研究
Status: RESEARCH
Spring V1: BASELINE FROZEN禁止改动 / 禁止与本分支合并调参
LPS V2 假设验证中:
4h 原生 SOS Confirm 1h LPS Entry
不是 1h 假突破回踩
4h SOS:
close > range_high实体收盘离开区间 wick
volume > MA20 * 1.5
close strength (close-low)/(high-low) > 0.7
随后 3 4h close > breakout_level
1h LPS:
第一次回踩 breakout_level
回踩深度 0.5~1.5 ATR(1h)
volume_4h < sos_break_volume
转强: close > previous high
setup_type / enter_tag: LPS / LPSY
regime_mode=trendRange disabled
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_LPS.json \
# --strategy Wyckoff_BTC_LPS --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_LPS(IStrategy):
"""LPS V2: 4h 原生 SOS → 1h LPS。不与 Spring 混用。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "LPS_V2"
SETUP_FAMILY = "LPS"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.12,
"1440": 0.06,
"4320": 0.03,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.025
trailing_stop_positive_offset = 0.05
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 固定规则(不做 hyperopt----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
sos_vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
sos_close_strength = DecimalParameter(0.55, 0.90, default=0.70, decimals=2, space="buy", optimize=False)
sos_hold_bars_4h = IntParameter(1, 6, default=3, space="buy", optimize=False)
lps_pb_atr_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=False)
lps_pb_atr_max = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
lps_max_age_1h = IntParameter(12, 120, default=72, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=168, space="sell", optimize=False)
use_lps_long = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_lps_short = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_bias_tf(self, df: DataFrame) -> DataFrame:
df = df.copy()
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
return df
def _add_sos_structure_4h(self, df: DataFrame) -> DataFrame:
"""在 4h 原生计算 SOS / SOW(含 hold 确认,无前视进场)。"""
df = df.copy()
lb = int(self.range_lookback.value)
hold = int(self.sos_hold_bars_4h.value)
vol_m = float(self.sos_vol_mult.value)
strength_min = float(self.sos_close_strength.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
# 区间用「突破前」边界:shift(1) 的 rolling,避免当根抬高
df["range_high"] = df["high"].rolling(lb).max().shift(1)
df["range_low"] = df["low"].rolling(lb).min().shift(1)
bar_range = (df["high"] - df["low"]).replace(0, np.nan)
df["close_strength"] = (df["close"] - df["low"]) / bar_range
df["close_weakness"] = (df["high"] - df["close"]) / bar_range
vol_ok = df["volume"] > df["volume_ma"] * vol_m
# ① 实体收盘离开区间 ② 放量 ③ Effort Result
sos_raw = (
df["range_high"].notna()
& (df["close"] > df["range_high"])
& (df["close"].shift(1) <= df["range_high"])
& vol_ok
& (df["close_strength"] > strength_min)
)
sow_raw = (
df["range_low"].notna()
& (df["close"] < df["range_low"])
& (df["close"].shift(1) >= df["range_low"])
& vol_ok
& (df["close_weakness"] > strength_min)
)
# 事件位:突破当根冻结
sos_level = df["range_high"].where(sos_raw)
sos_vol = df["volume"].where(sos_raw)
sos_origin = df["range_low"].where(sos_raw)
sow_level = df["range_low"].where(sow_raw)
sow_vol = df["volume"].where(sow_raw)
sow_origin = df["range_high"].where(sow_raw)
# ④ Hold:突破后 hold 根 4h 收盘仍在突破侧 → 在第 hold 根确认(无前视)
sos_confirmed = sos_raw.shift(hold).fillna(False)
sow_confirmed = sow_raw.shift(hold).fillna(False)
for k in range(hold):
sos_confirmed = sos_confirmed & (df["close"].shift(k) > sos_level.shift(hold))
sow_confirmed = sow_confirmed & (df["close"].shift(k) < sow_level.shift(hold))
# 确认当根带出冻结字段,再 ffill 供 1h 使用
df["sos_raw"] = sos_raw.fillna(False)
df["sow_raw"] = sow_raw.fillna(False)
df["sos_confirmed"] = sos_confirmed.fillna(False)
df["sow_confirmed"] = sow_confirmed.fillna(False)
df["sos_break_level"] = sos_level.shift(hold).where(df["sos_confirmed"])
df["sos_break_volume"] = sos_vol.shift(hold).where(df["sos_confirmed"])
df["sos_origin"] = sos_origin.shift(hold).where(df["sos_confirmed"])
df["sow_break_level"] = sow_level.shift(hold).where(df["sow_confirmed"])
df["sow_break_volume"] = sow_vol.shift(hold).where(df["sow_confirmed"])
df["sow_origin"] = sow_origin.shift(hold).where(df["sow_confirmed"])
df["sos_break_level"] = df["sos_break_level"].ffill()
df["sos_break_volume"] = df["sos_break_volume"].ffill()
df["sos_origin"] = df["sos_origin"].ffill()
df["sow_break_level"] = df["sow_break_level"].ffill()
df["sow_break_volume"] = df["sow_break_volume"].ffill()
df["sow_origin"] = df["sow_origin"].ffill()
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
return df
@staticmethod
def _bars_since(event: pd.Series) -> pd.Series:
ev = event.fillna(False).astype(bool).to_numpy()
out = np.full(len(ev), np.nan)
c = np.nan
for i, e in enumerate(ev):
if e:
c = 0.0
elif not np.isnan(c):
c += 1.0
out[i] = c
return pd.Series(out, index=event.index)
@staticmethod
def _expanding_max_since(event: pd.Series, value: pd.Series) -> pd.Series:
"""每个 event 之后对 value 做分段累计 max。"""
ev = event.fillna(False).astype(bool).to_numpy()
vals = value.to_numpy(dtype=float)
out = np.full(len(ev), np.nan)
cur = np.nan
active = False
for i in range(len(ev)):
if ev[i]:
active = True
cur = vals[i]
elif active:
if not np.isnan(vals[i]):
cur = vals[i] if np.isnan(cur) else max(cur, vals[i])
out[i] = cur if active else np.nan
return pd.Series(out, index=event.index)
@staticmethod
def _expanding_min_since(event: pd.Series, value: pd.Series) -> pd.Series:
ev = event.fillna(False).astype(bool).to_numpy()
vals = value.to_numpy(dtype=float)
out = np.full(len(ev), np.nan)
cur = np.nan
active = False
for i in range(len(ev)):
if ev[i]:
active = True
cur = vals[i]
elif active:
if not np.isnan(vals[i]):
cur = vals[i] if np.isnan(cur) else min(cur, vals[i])
out[i] = cur if active else np.nan
return pd.Series(out, index=event.index)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
btf = self.bias_timeframe
inf4 = self.dp.get_pair_dataframe(pair=pair, timeframe=stf)
inf4 = self._add_sos_structure_4h(inf4)
keep4 = [
"date", "atr", "adx", "volume",
"range_high", "range_low", "close_strength",
"sos_raw", "sow_raw", "sos_confirmed", "sow_confirmed",
"sos_break_level", "sos_break_volume", "sos_origin",
"sow_break_level", "sow_break_volume", "sow_origin",
"bull_bias", "bear_bias",
]
inf4 = inf4[[c for c in keep4 if c in inf4.columns]].copy()
dataframe = merge_informative_pair(dataframe, inf4, self.timeframe, stf, ffill=True)
if btf and self.use_bias_filter and btf != stf:
infb = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
infb = self._add_bias_tf(infb)
infb = infb[["date", "bull_bias", "bear_bias", "ema50", "ema200"]].copy()
dataframe = merge_informative_pair(dataframe, infb, self.timeframe, btf, ffill=True)
ss = f"_{stf}"
bs = f"_{btf}" if btf and btf != stf else ss
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
# 8h bias(优先);否则退回 4h bias
if f"bull_bias{bs}" in dataframe.columns:
bull = dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
bear = dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
else:
bull = dataframe[f"bull_bias{ss}"].fillna(False).astype(bool)
bear = dataframe[f"bear_bias{ss}"].fillna(False).astype(bool)
dataframe["bias_long_ok"] = bull
dataframe["bias_short_ok"] = bear
sos_conf = dataframe[f"sos_confirmed{ss}"].fillna(False).astype(bool)
sow_conf = dataframe[f"sow_confirmed{ss}"].fillna(False).astype(bool)
# 确认沿上升沿:4h 确认映射到 1h 后的首次 True
sos_event = sos_conf & ~sos_conf.shift(1).fillna(False)
sow_event = sow_conf & ~sow_conf.shift(1).fillna(False)
sos_level = dataframe[f"sos_break_level{ss}"]
sos_bvol = dataframe[f"sos_break_volume{ss}"]
sos_origin = dataframe[f"sos_origin{ss}"]
sow_level = dataframe[f"sow_break_level{ss}"]
sow_bvol = dataframe[f"sow_break_volume{ss}"]
sow_origin = dataframe[f"sow_origin{ss}"]
vol4 = dataframe[f"volume{ss}"]
sos_age = self._bars_since(sos_event)
sow_age = self._bars_since(sow_event)
post_high = self._expanding_max_since(sos_event, dataframe["high"])
post_low = self._expanding_min_since(sow_event, dataframe["low"])
atr = dataframe["atr"]
pb_min = float(self.lps_pb_atr_min.value)
pb_max = float(self.lps_pb_atr_max.value)
max_age = float(self.lps_max_age_1h.value)
# 回踩深度:SOS 后高点回撤的 ATR 倍数
retrace_long = (post_high - dataframe["low"]) / atr.replace(0, np.nan)
retrace_short = (dataframe["high"] - post_low) / atr.replace(0, np.nan)
near_sos = dataframe["low"] <= (sos_level + atr * 0.35)
near_sow = dataframe["high"] >= (sow_level - atr * 0.35)
vol_dry_long = vol4 < sos_bvol
vol_dry_short = vol4 < sow_bvol
reclaim_long = dataframe["close"] > dataframe["high"].shift(1)
reclaim_short = dataframe["close"] < dataframe["low"].shift(1)
first_near_long = near_sos & ~near_sos.shift(1).fillna(False)
first_near_short = near_sow & ~near_sow.shift(1).fillna(False)
alive_long = (
sos_age.notna()
& (sos_age >= 1)
& (sos_age <= max_age)
& (dataframe["close"] > sos_origin)
)
alive_short = (
sow_age.notna()
& (sow_age >= 1)
& (sow_age <= max_age)
& (dataframe["close"] < sow_origin)
)
dataframe["lps"] = (
alive_long
& first_near_long
& retrace_long.between(pb_min, pb_max)
& (dataframe["low"] > sos_origin)
& (dataframe["close"] >= sos_level * 0.995)
& vol_dry_long
& reclaim_long
& dataframe["bias_long_ok"]
)
dataframe["lpsy"] = (
alive_short
& first_near_short
& retrace_short.between(pb_min, pb_max)
& (dataframe["high"] < sow_origin)
& (dataframe["close"] <= sow_level * 1.005)
& vol_dry_short
& reclaim_short
& dataframe["bias_short_ok"]
)
dataframe["sos"] = sos_event
dataframe["sow"] = sow_event
dataframe["sos_level"] = sos_level
dataframe["sos_origin"] = sos_origin
dataframe["sow_level"] = sow_level
dataframe["sow_origin"] = sow_origin
dataframe["sos_age"] = sos_age
dataframe["sow_age"] = sow_age
for col in ["lps", "lpsy", "bias_long_ok", "bias_short_ok", "sos", "sow"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["lps"], "setup_type"] = "LPS"
dataframe.loc[dataframe["lpsy"], "setup_type"] = "LPSY"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
if bool(self.use_lps_long.value):
cond = vol_ok & dataframe["lps"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "LPS")
if bool(self.use_lps_short.value):
cond = vol_ok & dataframe["lpsy"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "LPSY")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
# 结构失效:收盘跌破 SOS 突破位 / 升破 SOW 突破位
exit_long = (
dataframe["sos_level"].notna()
& (dataframe["close"] < dataframe["sos_level"])
& (dataframe["close"] < dataframe["ema21"])
) | dataframe["sow"]
exit_short = (
dataframe["sow_level"].notna()
& (dataframe["close"] > dataframe["sow_level"])
& (dataframe["close"] > dataframe["ema21"])
) | dataframe["sos"]
dataframe.loc[exit_long.fillna(False), ["exit_long", "exit_tag"]] = (1, "lps_structure_fail")
dataframe.loc[exit_short.fillna(False), ["exit_short", "exit_tag"]] = (1, "lps_structure_fail")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if tag == "LPS" and pd.notna(last.get("sos_origin")):
trade.set_custom_data("struct_stop", float(last["sos_origin"]) - buffer)
elif tag == "LPSY" and pd.notna(last.get("sow_origin")):
trade.set_custom_data("struct_stop", float(last["sow_origin"]) + buffer)
elif trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in ("LPS", "LPSY"):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)
@@ -0,0 +1,150 @@
{
"version": "LPS_V2",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -3.5049591933000004,
"trades": 3,
"dd_pct": 3.504959193300001,
"pf": 0.0,
"winrate": 0.0,
"final": 9649.50408067,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -1.6143743830000001,
"trades": 2,
"dd_pct": 1.614374382999995,
"pf": 0.0,
"winrate": 0.0,
"final": 9838.5625617,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -5.0599199851,
"trades": 5,
"dd_pct": 5.059919985100005,
"pf": 0.0,
"winrate": 0.0,
"final": 9494.00800149,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -4.5549168852,
"trades": 7,
"dd_pct": 7.020877735199993,
"pf": 0.3512325585213674,
"winrate": 14.285714285714285,
"final": 9544.50831148,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -5.371762636800001,
"trades": 7,
"dd_pct": 8.1297357765,
"pf": 0.33924511392759654,
"winrate": 14.285714285714285,
"final": 9462.82373632,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
},
"verdict": {
"full_pf": 0.39720654015221873,
"full_dd": 6.479119889400008,
"trades_per_year": 1.9444444444444444,
"net_mid_pf": 0.3512325585213674,
"target_pf_ok": false,
"target_dd_ok": true,
"freq_ok": false,
"regime_logic_ok": true,
"status": "FAIL",
"hypothesis": "4h native SOS → 1h LPS"
}
}
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Market State Gate OOS Baseline vs GatedSpring 冻结
比较:
A) Wyckoff_BTC_V1_BASELINE Spring always (within trend regime)
B) Wyckoff_BTC_GATED Spring only when causal state gate opens
阈值先验固定不对 2023+ 做网格搜索
指标: net PF / DD / n / worst year / max consecutive losses
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Any
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_oos_result.json"
PAIR = "BTC/USDT:USDT"
WINDOWS = [
("define_pre2023", "20190901-20230101"), # 观察区(不调参)
("oos_2023plus", "20230101-"),
("full", "20190901-"),
("y2020", "20200101-20210101"),
("y2021", "20210101-20220101"),
("y2022", "20220101-20230101"),
("y2023", "20230101-20240101"),
("y2024", "20240101-20250101"),
("y2025", "20250101-20260101"),
]
STRATS = [
{
"name": "baseline",
"strategy": "Wyckoff_BTC_V1_BASELINE",
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json",
},
{
"name": "gated",
"strategy": "Wyckoff_BTC_GATED",
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json",
},
]
def _max_consecutive_losses(profits: list[float]) -> int:
best = cur = 0
for p in profits:
if p <= 0:
cur += 1
best = max(best, cur)
else:
cur = 0
return best
def _worst_year(trades: list[dict]) -> dict[str, Any]:
by_y: dict[str, float] = {}
for t in trades:
ed = t.get("open_date") or t.get("entry_date") or ""
y = str(ed)[:4]
if len(y) < 4:
continue
by_y[y] = by_y.get(y, 0.0) + float(t.get("profit_ratio") or 0.0) * 100
if not by_y:
return {"year": None, "sum_pct": 0.0}
y, v = min(by_y.items(), key=lambda x: x[1])
return {"year": y, "sum_pct": round(v, 2)}
def run_one(strategy: str, config_path: Path, timerange: str) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
config = Configuration.from_files([str(config_path)])
config.update(
{
"strategy": strategy,
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": timerange,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": 0.0010, # 5bps fee + 5bps slip
"exchange": {
**config.get("exchange", {}),
"name": "binance",
"pair_whitelist": [PAIR],
},
}
)
bt = Backtesting(config)
bt.start()
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
profit = st.get("profit_total_pct")
if profit is None:
profit = float(st.get("profit_total") or 0) * 100
trade_rows = []
profits = []
for t in LocalTrade.bt_trades:
pr = float(t.close_profit or 0.0)
profits.append(pr)
trade_rows.append(
{
"open_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
"enter_tag": t.enter_tag or "",
"profit_ratio": pr,
}
)
return {
"timerange": timerange,
"profit_pct": float(profit),
"trades": int(st.get("total_trades") or 0),
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
"pf": float(st.get("profit_factor") or 0),
"winrate": float(st.get("winrate") or 0) * 100,
"max_consec_loss": _max_consecutive_losses(profits),
"worst_year": _worst_year(trade_rows),
}
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([PAIR])
results: dict[str, Any] = {
"pair": PAIR,
"fee_model": "fee 5bps + slip 5bps",
"gate": {
"version": "v1.1_state_set",
"spring": "market_state ∈ {accumulation, markup}",
"utad": "market_state ∈ {distribution, markdown}",
"note": "Causal 8h EMA/slope rules (= attribution labels). Scores kept for observability. Not grid-searched on 2023+.",
"v1_score_threshold": "FAILED OOS (destroyed 2023+ PF 1.45→0.67); archived as too misaligned",
},
"windows": {},
"verdict": {},
}
print("===== Market State Gate OOS (BTC) =====", flush=True)
for wname, tr in WINDOWS:
print(f"\n--- {wname} {tr} ---", flush=True)
block = {}
for s in STRATS:
r = run_one(s["strategy"], s["config"], tr)
block[s["name"]] = r
print(
f" {s['name']:<9} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} "
f"mcl={r['max_consec_loss']} worst={r['worst_year']}",
flush=True,
)
# delta gated - baseline
b, g = block["baseline"], block["gated"]
block["delta_gated_minus_baseline"] = {
"pf": round(g["pf"] - b["pf"], 3),
"dd_pct": round(g["dd_pct"] - b["dd_pct"], 3),
"trades": g["trades"] - b["trades"],
"profit_pct": round(g["profit_pct"] - b["profit_pct"], 3),
"max_consec_loss": g["max_consec_loss"] - b["max_consec_loss"],
}
results["windows"][wname] = block
oos_b = results["windows"]["oos_2023plus"]["baseline"]
oos_g = results["windows"]["oos_2023plus"]["gated"]
full_b = results["windows"]["full"]["baseline"]
full_g = results["windows"]["full"]["gated"]
pre_b = results["windows"]["define_pre2023"]["baseline"]
pre_g = results["windows"]["define_pre2023"]["gated"]
results["verdict"] = {
"oos_gated_pf_ge_baseline": oos_g["pf"] >= oos_b["pf"] - 1e-9,
"oos_gated_pf_ge_1_2": oos_g["pf"] >= 1.2,
"oos_gated_dd_le_baseline": oos_g["dd_pct"] <= oos_b["dd_pct"] + 1e-9,
"full_gated_pf_gt_baseline": full_g["pf"] > full_b["pf"],
"pre2023_not_catastrophically_worse": pre_g["pf"] >= pre_b["pf"] - 0.15,
"status": (
"PASS"
if (
oos_g["pf"] >= 1.2
and oos_g["dd_pct"] <= oos_b["dd_pct"] + 0.5
and full_g["pf"] > full_b["pf"]
)
else "PARTIAL"
if (oos_g["pf"] >= oos_b["pf"] and full_g["pf"] >= full_b["pf"])
else "FAIL"
),
"note": "Gate must not destroy 2023+ edge; should improve or stabilize full-sample robustness.",
}
print("\n===== Verdict =====")
print(json.dumps(results["verdict"], indent=2, ensure_ascii=False))
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
print(f"Saved {OUT}")
if __name__ == "__main__":
main()
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Gate v1.1 冻结前小范围稳健性确认不改 Spring / 不调 soft-score
Baseline SPRING_LONG 全集上
- 按年份era 切片
- blocked 是否仍主要来自 distribution
- kept vs blocked PF 关系是否稳定
range 只作观察桶不改交易规则
"""
from __future__ import annotations
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "user_data/Chan"))
from engine.market_state import compute_market_state_8h # noqa: E402
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
AUDIT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_robustness_slices_result.json"
PAIR = "BTC/USDT:USDT"
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
def _pf(ps: list[float]) -> float:
wins = [p for p in ps if p > 0]
losses = [-p for p in ps if p <= 0]
gw, gl = sum(wins), sum(losses)
if gl <= 0:
return 999.0 if gw > 0 else 0.0
return gw / gl
def _stats(ps: list[float]) -> dict[str, Any]:
if not ps:
return {"n": 0, "pf": 0.0, "sum_pct": 0.0, "winrate": 0.0}
return {
"n": len(ps),
"pf": round(_pf(ps), 3),
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
}
def load_annotated_springs() -> list[dict[str, Any]]:
"""复用 audit 逻辑,产出逐笔 annotated SPRING。"""
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
cfg = Configuration.from_files([str(CFG)])
cfg.update(
{
"strategy": "Wyckoff_BTC_V1_BASELINE",
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": "20190901-",
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": 0.0010,
"exchange": {
**cfg.get("exchange", {}),
"name": "binance",
"pair_whitelist": [PAIR],
},
}
)
bt = Backtesting(cfg)
bt.start()
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
h8["date"] = pd.to_datetime(h8["date"], utc=True)
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
rows = []
for t in LocalTrade.bt_trades:
if "SPRING" not in (t.enter_tag or ""):
continue
ed = pd.Timestamp(t.open_date_utc)
if ed.tzinfo is None:
ed = ed.tz_localize("UTC")
idx = h8.index.get_indexer([ed], method="ffill")[0]
if idx < 0:
continue
st = h8.iloc[idx]
state = str(st["market_state"])
rows.append(
{
"entry_date": ed.isoformat(),
"year": str(ed.year),
"era": "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023",
"market_state": state,
"allow_spring": bool(st["allow_spring"]),
"profit_ratio": float(t.close_profit or 0.0),
}
)
return rows
def slice_report(rows: list[dict], key: str) -> dict[str, Any]:
out: dict[str, Any] = {}
groups: dict[str, list[dict]] = defaultdict(list)
for r in rows:
groups[str(r[key])].append(r)
for k, rs in sorted(groups.items()):
kept = [x for x in rs if x["allow_spring"]]
blocked = [x for x in rs if not x["allow_spring"]]
b_by_state: dict[str, list[float]] = defaultdict(list)
for x in blocked:
b_by_state[x["market_state"]].append(x["profit_ratio"])
blocked_states = {s: _stats(ps) for s, ps in b_by_state.items()}
dist_n = blocked_states.get("distribution", {}).get("n", 0)
blocked_n = len(blocked)
out[k] = {
"n_total": len(rs),
"kept": _stats([x["profit_ratio"] for x in kept]),
"blocked": _stats([x["profit_ratio"] for x in blocked]),
"blocked_by_state": blocked_states,
"blocked_distribution_share": round(dist_n / blocked_n, 3) if blocked_n else None,
"blocked_all_bad": (
all(s in ("distribution", "markdown", "range") for s in blocked_states)
if blocked_n
else True
),
}
return out
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([PAIR])
print("===== Annotate SPRING_LONG =====", flush=True)
rows = load_annotated_springs()
print(f" n={len(rows)}", flush=True)
by_year = slice_report(rows, "year")
by_era = slice_report(rows, "era")
# 稳定性:有 blocked 的切片里,distribution 是否为第一大来源
dist_primary = []
for label, block in {**{f"year:{k}": v for k, v in by_year.items()}, **{f"era:{k}": v for k, v in by_era.items()}}.items():
bn = block["blocked"]["n"]
if bn < 2:
continue
states = block["blocked_by_state"]
top = max(states.items(), key=lambda x: x[1]["n"])[0] if states else None
dist_primary.append(
{
"slice": label,
"blocked_n": bn,
"top_blocked_state": top,
"distribution_share": block["blocked_distribution_share"],
"blocked_pf": block["blocked"]["pf"],
"kept_pf": block["kept"]["pf"],
}
)
n_slices = len(dist_primary)
n_dist_top = sum(1 for x in dist_primary if x["top_blocked_state"] == "distribution")
n_dist_ge_50 = sum(
1 for x in dist_primary if (x["distribution_share"] or 0) >= 0.5
)
result = {
"n_spring": len(rows),
"by_year": by_year,
"by_era": by_era,
"slice_summaries": dist_primary,
"range_observation_only": {
"note": "range 不作交易规则;仅观察 blocked 中的占比与 PF",
"blocked_range_global": _stats(
[r["profit_ratio"] for r in rows if (not r["allow_spring"] and r["market_state"] == "range")]
),
},
"verdict": {
"slices_with_blocked_ge_2": n_slices,
"distribution_is_top_blocked_state": n_dist_top,
"distribution_share_ge_50pct_slices": n_dist_ge_50,
"distribution_attribution_stable": (
n_slices > 0 and (n_dist_top / n_slices) >= 0.6
),
"status": (
"PASS"
if n_slices > 0 and (n_dist_top / n_slices) >= 0.6
else "PARTIAL"
if n_dist_ge_50 >= max(1, n_slices // 2)
else "FAIL"
),
"note": "PASS = across year/era slices, blocked mass still led by distribution.",
},
}
print("\n===== By year (blocked focus) =====", flush=True)
for y, b in by_year.items():
print(
f" {y}: total={b['n_total']} kept_pf={b['kept']['pf']} "
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
flush=True,
)
print("\n===== By era =====", flush=True)
for e, b in by_era.items():
print(
f" {e}: total={b['n_total']} kept_pf={b['kept']['pf']} "
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
flush=True,
)
print("\n===== Verdict =====", flush=True)
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
print(f"\nSaved {OUT}")
if __name__ == "__main__":
main()
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Negative-domain audit
问题Gate 拦掉的 Spring是否集中死在 distribution | markdown | range结构性错误
而不是偶然删掉赚钱样本
方法Spring 冻结Gate=state_set
1) Baseline取出全部 SPRING_LONG 成交
2) 用因果 8h market_state 标注入场时状态
3) allow_spring 分成 kept vs blocked
4) 比较各域 n / PF / winrate / sum%
判定
- blocked 主要落在 bad domains
- blocked 整体 PF << kept或明显更差
- kept 域仍以 accumulation|markup 为主
"""
from __future__ import annotations
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "user_data/Chan"))
from engine.market_state import compute_market_state_8h # noqa: E402
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
OUT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
PAIR = "BTC/USDT:USDT"
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
GOOD = {"accumulation", "markup"}
BAD = {"distribution", "markdown", "range"}
def _pf(ps: list[float]) -> float:
wins = [p for p in ps if p > 0]
losses = [-p for p in ps if p <= 0]
gw, gl = sum(wins), sum(losses)
if gl <= 0:
return 999.0 if gw > 0 else 0.0
return gw / gl
def _stats(ps: list[float]) -> dict[str, Any]:
if not ps:
return {"n": 0, "pf": 0.0, "winrate": 0.0, "sum_pct": 0.0, "avg_pct": 0.0}
return {
"n": len(ps),
"pf": round(_pf(ps), 3),
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
"avg_pct": round(100.0 * float(np.mean(ps)), 2),
}
def run_baseline_spring_trades() -> list[dict[str, Any]]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
cfg = Configuration.from_files([str(CFG)])
cfg.update(
{
"strategy": "Wyckoff_BTC_V1_BASELINE",
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": "20190901-",
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": 0.0010,
"exchange": {
**cfg.get("exchange", {}),
"name": "binance",
"pair_whitelist": [PAIR],
},
}
)
bt = Backtesting(cfg)
bt.start()
rows = []
for t in LocalTrade.bt_trades:
tag = t.enter_tag or ""
if "SPRING" not in tag:
continue
rows.append(
{
"entry_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else "",
"enter_tag": tag,
"profit_ratio": float(t.close_profit or 0.0),
"era": (
"2023plus"
if t.open_date_utc and t.open_date_utc >= pd.Timestamp("2023-01-01", tz="UTC")
else "pre_2023"
),
}
)
return rows
def annotate(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
h8["date"] = pd.to_datetime(h8["date"], utc=True)
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
out = []
for t in trades:
ed = pd.Timestamp(t["entry_date"])
if ed.tzinfo is None:
ed = ed.tz_localize("UTC")
idx = h8.index.get_indexer([ed], method="ffill")[0]
if idx < 0:
continue
row = h8.iloc[idx]
state = str(row["market_state"])
allowed = bool(row["allow_spring"])
rec = {
**t,
"market_state": state,
"allow_spring": allowed,
"domain": "good" if state in GOOD else ("bad" if state in BAD else "other"),
"accumulation_score": float(row["accumulation_score"]),
"markup_score": float(row["markup_score"]),
"distribution_score": float(row["distribution_score"]),
"markdown_score": float(row["markdown_score"]),
"range_score": float(row["range_score"]),
}
out.append(rec)
return out
def bucket(rows: list[dict], key: str) -> dict[str, Any]:
g: dict[str, list[float]] = defaultdict(list)
for r in rows:
g[str(r[key])].append(float(r["profit_ratio"]))
return {k: _stats(v) for k, v in sorted(g.items(), key=lambda x: -len(x[1]))}
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([PAIR])
print("===== Baseline SPRING_LONG trades =====", flush=True)
raw = run_baseline_spring_trades()
print(f" spring trades={len(raw)}", flush=True)
rows = annotate(raw)
kept = [r for r in rows if r["allow_spring"]]
blocked = [r for r in rows if not r["allow_spring"]]
result: dict[str, Any] = {
"pair": PAIR,
"fee_model": "fee5bps+slip5bps",
"n_spring_total": len(rows),
"n_kept": len(kept),
"n_blocked": len(blocked),
"kept": {
"overall": _stats([r["profit_ratio"] for r in kept]),
"by_state": bucket(kept, "market_state"),
"by_era": bucket(kept, "era"),
},
"blocked": {
"overall": _stats([r["profit_ratio"] for r in blocked]),
"by_state": bucket(blocked, "market_state"),
"by_era": bucket(blocked, "era"),
"by_domain": bucket(blocked, "domain"),
},
"blocked_share_by_state": {},
"verdict": {},
}
# blocked 状态占比
if blocked:
for st, stt in result["blocked"]["by_state"].items():
result["blocked_share_by_state"][st] = round(stt["n"] / len(blocked), 3)
bad_n = sum(result["blocked"]["by_state"].get(s, {}).get("n", 0) for s in BAD)
blocked_bad_share = (bad_n / len(blocked)) if blocked else 0.0
kept_good_share = 0.0
if kept:
kg = sum(1 for r in kept if r["market_state"] in GOOD)
kept_good_share = kg / len(kept)
bk = result["blocked"]["overall"]
kp = result["kept"]["overall"]
result["verdict"] = {
"blocked_mostly_bad_domain": blocked_bad_share >= 0.8,
"blocked_bad_share": round(blocked_bad_share, 3),
"kept_mostly_good_domain": kept_good_share >= 0.95,
"kept_good_share": round(kept_good_share, 3),
"blocked_pf_worse_than_kept": bk["pf"] < kp["pf"],
"blocked_pf": bk["pf"],
"kept_pf": kp["pf"],
"status": (
"PASS"
if (
blocked_bad_share >= 0.8
and kept_good_share >= 0.95
and bk["pf"] < kp["pf"]
)
else "PARTIAL"
if (blocked_bad_share >= 0.7 and bk["pf"] <= kp["pf"])
else "FAIL"
),
"note": "PASS = Gate filters structural bad domains, not random sample deletion.",
}
print("\n===== KEPT (allow_spring) =====", flush=True)
print(json.dumps(result["kept"], indent=2, ensure_ascii=False))
print("\n===== BLOCKED =====", flush=True)
print(json.dumps(result["blocked"], indent=2, ensure_ascii=False))
print("\n===== Verdict =====", flush=True)
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
print(f"\nSaved {OUT}")
if __name__ == "__main__":
main()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""在最优周期 1h/4h/8h 上扫 ATR 与关键参数。"""
from __future__ import annotations
import itertools
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
STRAT_PATH,
install_offline_markets,
patch_strategy,
run_one,
)
# 参数名 -> (正则匹配赋值行前缀, 候选值列表)
PARAM_GRID = {
"atr_sl_mult": (
r'^(\tatr_sl_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
[1.5, 2.0, 2.5, 3.0],
),
"vol_spike_mult": (
r'^(\tvol_spike_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
[1.2, 1.4, 1.8],
),
"spring_pierce_pct": (
r'^(\tspring_pierce_pct = DecimalParameter\([^\n]*default=)([0-9.]+)',
[0.002, 0.004, 0.008],
),
"range_lookback": (
r'^(\trange_lookback = IntParameter\([^\n]*default=)([0-9]+)',
[18, 24, 36],
),
}
def set_defaults(text: str, values: dict[str, Any]) -> str:
for key, (pat, _) in PARAM_GRID.items():
val = values[key]
text = re.sub(pat, rf"\g<1>{val}", text, count=1, flags=re.M)
return text
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
install_offline_markets()
orig = STRAT_PATH.read_text()
keys = list(PARAM_GRID.keys())
combos = list(itertools.product(*[PARAM_GRID[k][1] for k in keys]))
# 全组合太多:改为坐标下降式 — 先基线,再逐参扫描
base = {k: PARAM_GRID[k][1][len(PARAM_GRID[k][1]) // 2] for k in keys}
# 确保与当前文件接近的中心点
base.update(
{
"atr_sl_mult": 2.0,
"vol_spike_mult": 1.4,
"spring_pierce_pct": 0.004,
"range_lookback": 24,
}
)
trials = [dict(base)]
for k in keys:
for v in PARAM_GRID[k][1]:
if v == base[k]:
continue
t = dict(base)
t[k] = v
trials.append(t)
rows = []
try:
patch_strategy("1h", "4h", "8h")
for i, vals in enumerate(trials):
text = set_defaults(STRAT_PATH.read_text(), vals)
STRAT_PATH.write_text(text)
label = ",".join(f"{k}={vals[k]}" for k in keys)
print(f"[{i+1}/{len(trials)}] {label}", flush=True)
try:
res = run_one("1h", timerange)
res.update(vals)
res["label"] = label
res["ok"] = True
except Exception as e:
res = {"ok": False, "error": str(e), "label": label, **vals}
rows.append(res)
if res.get("ok"):
print(
f" -> profit={res['profit_pct']:.2f}% trades={res['trades']} "
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f}",
flush=True,
)
else:
print(f" FAILED {res.get('error')}", flush=True)
finally:
STRAT_PATH.write_text(orig)
ok = [r for r in rows if r.get("ok")]
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
print("\n========== PARAM RANKING ==========")
for r in ok[:10]:
print(
f"{r['profit_pct']:>7.2f}% pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
f"n={r['trades']:<3} {r['label']}"
)
out = ROOT / "user_data/Chan/scripts/wyckoff_param_grid_result.txt"
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
print(f"\nSaved {out}")
if ok:
best = ok[0]
print("\nBEST params:", {k: best[k] for k in keys})
# 写回最优 default
text = set_defaults(orig, {k: best[k] for k in keys})
# 保持最优周期
text2 = text
text2 = re.sub(r'^(\ttimeframe = ).*$', r'\g<1>"1h"', text2, count=1, flags=re.M)
text2 = re.sub(
r'^(\tstructure_timeframe = ).*$', r'\g<1>"4h"', text2, count=1, flags=re.M
)
text2 = re.sub(
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
r'\g<1>"8h"',
text2,
count=1,
flags=re.M,
)
STRAT_PATH.write_text(text2)
print("Wrote best defaults into Wyckoff_BTC.py")
# 长周期验证
print("\nValidate 20230101- ...", flush=True)
res = run_one("1h", "20230101-")
print(res)
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Wyckoff Phase2 对比同一数据 / 同一成本 / 同一 WFO / 同一 Regime
对比:
- Wyckoff_BTC_V1_BASELINE (Spring, range off)
- Wyckoff_BTC_LPS (LPS continuation, range off)
统一看 net PFfee 计入
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
WFO = [
("train", "20230101-20250101"),
("validate", "20250101-20260101"),
("test", "20260101-"),
("full", "20230101-"),
]
BRANCHES = [
{
"name": "Spring_V1",
"strategy": "Wyckoff_BTC_V1_BASELINE",
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json",
"target": {"pf": 1.3, "dd": 10.0, "note": "Spring: PF>1.3 DD<10%"},
},
{
"name": "LPS_V2",
"strategy": "Wyckoff_BTC_LPS",
"config": ROOT / "user_data/Chan/config/Wyckoff_BTC_LPS.json",
"target": {"pf": 1.2, "dd": 15.0, "note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"},
},
]
def run_bt(
strategy: str,
config_path: Path,
timerange: str,
*,
fee: float = 0.0005,
extra_cost: float = 0.0,
regime: Optional[str] = None,
) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if strategy in mod or "Wyckoff_BTC" in mod:
del sys.modules[mod]
# 可选:临时改 regime_mode(写文件)
strat_path = ROOT / "user_data/Chan/strategies" / f"{strategy}.py"
orig = None
if regime is not None:
import re
orig = strat_path.read_text()
text2, n = re.subn(
r'^(\tregime_mode: str = )".*"',
rf'\g<1>"{regime}"',
orig,
count=1,
flags=re.M,
)
if n == 0:
raise RuntimeError(f"regime_mode not found in {strategy}")
strat_path.write_text(text2)
pycache = strat_path.parent / "__pycache__"
if pycache.is_dir():
for p in pycache.glob(f"{strategy}*.pyc"):
p.unlink(missing_ok=True)
try:
config = Configuration.from_files([str(config_path)])
config.update(
{
"strategy": strategy,
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": timerange,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": fee + extra_cost,
}
)
bt = Backtesting(config)
loaded = getattr(bt.strategylist[0], "regime_mode", None)
bt.start()
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
profit = st.get("profit_total_pct")
if profit is None:
profit = float(st.get("profit_total") or 0) * 100
return {
"profit_pct": float(profit),
"trades": int(st.get("total_trades") or 0),
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
"pf": float(st.get("profit_factor") or 0),
"winrate": float(st.get("winrate") or 0) * 100,
"final": float(st.get("final_balance") or 0),
"fee_used": config["fee"],
"regime_loaded": loaded,
}
finally:
if orig is not None:
strat_path.write_text(orig)
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets()
results: dict[str, Any] = {"branches": {}}
for br in BRANCHES:
name = br["name"]
print(f"\n===== {name} ({br['strategy']}) =====", flush=True)
block: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
print("--- WFO ---", flush=True)
for wname, tr in WFO:
r = run_bt(br["strategy"], br["config"], tr)
block["wfo"][wname] = {"timerange": tr, **r}
print(
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
flush=True,
)
print("--- Regime ---", flush=True)
for mode in ["trend", "bull", "bear", "range", "all"]:
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
block["regimes"][mode] = r
print(
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
flush=True,
)
print("--- Cost (net PF) ---", flush=True)
for label, fee, extra in [
("fee_5bps", 0.0005, 0.0),
("fee_5bps+slip_5bps", 0.0005, 0.0005),
("fee_10bps+slip_10bps", 0.0010, 0.0010),
]:
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
block["cost_stress"][label] = r
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
print(
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"pf={r['pf']:.2f} [{flag}]",
flush=True,
)
full = block["wfo"]["full"]
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
years = 3.6 # ~2023→2026.6
tpy = full["trades"] / years if years else 0
block["verdict"] = {
"full_pf": full["pf"],
"full_dd": full["dd_pct"],
"trades_per_year": tpy,
"net_mid_pf": mid["pf"],
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
}
results["branches"][name] = block
print(f"Verdict: {json.dumps(block['verdict'], ensure_ascii=False)}", flush=True)
# 组合粗估:独立回测不可简单相加;只报告各自频率目标
s = results["branches"]["Spring_V1"]["verdict"]
l = results["branches"]["LPS_V2"]["verdict"]
results["portfolio_note"] = {
"spring_tpy": s["trades_per_year"],
"lps_tpy": l["trades_per_year"],
"sum_tpy_approx": s["trades_per_year"] + l["trades_per_year"],
"combined_target_tpy": "10-20",
"warning": "频率可近似相加;PF/收益不可相加,需另做组合回测;Spring 冻结勿改",
}
print("\n===== Portfolio note =====")
print(json.dumps(results["portfolio_note"], ensure_ascii=False, indent=2))
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
print(f"\nSaved {OUT}")
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
LPS V2 单独 Phase2不改 Spring不合并组合
同一 WFO / Regime / 成本模型
目标: net PF > 1.2频率约 5-15/year
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_phase2_compare import ( # noqa: E402
BRANCHES,
WFO,
install_offline_markets,
run_bt,
)
OUT = ROOT / "user_data/Chan/scripts/wyckoff_lps_v2_phase2_result.json"
COMPARE = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets()
br = next(b for b in BRANCHES if b["name"] == "LPS_V2")
print(f"===== {br['name']} ({br['strategy']}) — LPS-only Phase2 =====", flush=True)
block = {"version": "LPS_V2", "wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
print("--- WFO ---", flush=True)
for wname, tr in WFO:
r = run_bt(br["strategy"], br["config"], tr)
block["wfo"][wname] = {"timerange": tr, **r}
print(
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
flush=True,
)
print("--- Regime ---", flush=True)
for mode in ["trend", "bull", "bear", "range", "all"]:
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
block["regimes"][mode] = r
print(
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
flush=True,
)
print("--- Cost (net PF) ---", flush=True)
for label, fee, extra in [
("fee_5bps", 0.0005, 0.0),
("fee_5bps+slip_5bps", 0.0005, 0.0005),
("fee_10bps+slip_10bps", 0.0010, 0.0010),
]:
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
block["cost_stress"][label] = r
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
print(
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"pf={r['pf']:.2f} [{flag}]",
flush=True,
)
full = block["wfo"]["full"]
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
tpy = full["trades"] / 3.6
trend_pf = block["regimes"]["trend"]["pf"]
range_pf = block["regimes"]["range"]["pf"]
block["verdict"] = {
"full_pf": full["pf"],
"full_dd": full["dd_pct"],
"trades_per_year": tpy,
"net_mid_pf": mid["pf"],
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
"freq_ok": 5.0 <= tpy <= 15.0,
"regime_logic_ok": trend_pf >= range_pf, # 趋势应不差于横盘
"status": "PASS" if (mid["pf"] >= br["target"]["pf"] and full["dd_pct"] <= br["target"]["dd"]) else "FAIL",
"hypothesis": "4h native SOS → 1h LPS",
}
print("\n===== Verdict =====")
print(json.dumps(block["verdict"], ensure_ascii=False, indent=2))
OUT.write_text(json.dumps(block, indent=2, ensure_ascii=False))
# 合并进 compare 结果(保留 Spring,覆盖 LPS)
if COMPARE.exists():
prev = json.loads(COMPARE.read_text())
else:
prev = {"branches": {}}
prev.setdefault("branches", {})["LPS_V2"] = block
# 清理旧 LPS_V1 key 的活跃地位,保留作历史可手动看
spring = prev["branches"].get("Spring_V1", {}).get("verdict", {})
prev["system_status"] = {
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
"lps": block["verdict"]["status"],
"spring_tpy": spring.get("trades_per_year"),
"lps_tpy": tpy,
"next": "若 LPS PASS → 组合层;否则 Spring-only",
}
COMPARE.write_text(json.dumps(prev, indent=2, ensure_ascii=False))
print(f"\nSaved {OUT}")
print("system_status:", json.dumps(prev["system_status"], ensure_ascii=False))
if __name__ == "__main__":
main()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Wyckoff Phase 2鲁棒性验证固定当前参数不再扫参
1) Walk-ForwardTrain 2023-2024 / Validate 2025 / Test 2026
2) 市场状态拆分bull / bear / range8h EMA200 语境
3) 成本压力抬高手续费 + 滑点后是否仍 PF>1.3
"""
from __future__ import annotations
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
CONFIG_PATH,
STRAT_PATH,
install_offline_markets,
patch_strategy,
)
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase2_result.json"
WFO = [
("train", "20230101-20250101"),
("validate", "20250101-20260101"),
("test", "20260101-"),
("full", "20230101-"),
]
def set_regime(mode: str) -> None:
text = STRAT_PATH.read_text()
text2, n = re.subn(
r'^(\tregime_mode: str = )".*"',
rf'\g<1>"{mode}"',
text,
count=1,
flags=re.M,
)
if n == 0:
raise RuntimeError("regime_mode not found in strategy")
STRAT_PATH.write_text(text2)
# 清掉 bytecode,避免连续切换时读到旧 class 属性
pycache = STRAT_PATH.parent / "__pycache__"
if pycache.is_dir():
for p in pycache.glob("Wyckoff_BTC*.pyc"):
p.unlink(missing_ok=True)
def run_bt(
timerange: str,
*,
fee: Optional[float] = None,
extra_cost: float = 0.0,
regime: Optional[str] = None,
) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
if regime is not None:
set_regime(regime)
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
config = Configuration.from_files([str(CONFIG_PATH)])
config.update(
{
"strategy": "Wyckoff_BTC",
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": timerange,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
}
)
base_fee = 0.0005 if fee is None else fee
config["fee"] = base_fee + extra_cost
bt = Backtesting(config)
loaded_regime = getattr(bt.strategylist[0], "regime_mode", None)
bt.start()
st = bt.results["strategy"].get("Wyckoff_BTC") or list(bt.results["strategy"].values())[0]
profit = st.get("profit_total_pct")
if profit is None:
profit = float(st.get("profit_total") or 0) * 100
return {
"profit_pct": float(profit),
"trades": int(st.get("total_trades") or 0),
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
"pf": float(st.get("profit_factor") or 0),
"winrate": float(st.get("winrate") or 0) * 100,
"final": float(st.get("final_balance") or 0),
"fee_used": config["fee"],
"regime_loaded": loaded_regime,
}
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets()
orig = STRAT_PATH.read_text()
results: dict[str, Any] = {"wfo": {}, "regimes": {}, "cost_stress": {}}
try:
patch_strategy("1h", "4h", "8h")
set_regime("all")
print("===== 1) Walk-Forward (fixed params, no re-opt) =====")
for name, tr in WFO:
r = run_bt(tr)
results["wfo"][name] = {"timerange": tr, **r}
print(
f" {name:<8} {tr:<22} profit={r['profit_pct']:>7.2f}% "
f"n={r['trades']:<3} dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
flush=True,
)
print("\n===== 2) Regime split (20230101-) =====")
for mode in ["all", "bull", "bear", "range"]:
r = run_bt("20230101-", regime=mode)
results["regimes"][mode] = r
print(
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}% "
f"(loaded={r.get('regime_loaded')})",
flush=True,
)
set_regime("all")
print("\n===== 3) Cost stress (20230101-) =====")
for label, fee, extra in [
("fee_5bps", 0.0005, 0.0),
("fee_10bps", 0.0010, 0.0),
("fee_5bps+slip_5bps", 0.0005, 0.0005),
("fee_10bps+slip_10bps", 0.0010, 0.0010),
]:
r = run_bt("20230101-", fee=fee, extra_cost=extra)
results["cost_stress"][label] = r
flag = "OK" if r["pf"] >= 1.3 else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
print(
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
f"pf={r['pf']:.2f} [{flag}]",
flush=True,
)
wfo = results["wfo"]
results["verdict"] = {
"validate_profit_ok": wfo["validate"]["profit_pct"] > 0,
"validate_pf_ge_1": wfo["validate"]["pf"] >= 1.0,
"test_pf_ge_1": wfo["test"]["pf"] >= 1.0,
"cost_mid_pf_ge_1_3": results["cost_stress"]["fee_5bps+slip_5bps"]["pf"] >= 1.3,
"next": [
"若 validate/test 稳定 → paper / 小资金",
"若仅 train 好 → 参数过拟合,冻结开发",
"可并行加 SOS/LPS 趋势跟随以提高频率",
],
}
print("\n===== Verdict =====")
print(json.dumps(results["verdict"], ensure_ascii=False, indent=2))
finally:
STRAT_PATH.write_text(orig)
print("\nRestored strategy file", flush=True)
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
print(f"Saved {OUT}")
if __name__ == "__main__":
main()
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Phase3 Evidence Expansion不改 Spring 规则
目标: 将样本从 N=20 推向 N>=50
手段:
- 多品种外部验证本地有数据的 pair
- 分开统计 SPRING_LONG / UTAD_SHORT
- 同一净成本模型fee+slip
- 不引入 LPS不扫参
用法:
.venv/bin/python user_data/Chan/scripts/wyckoff_phase3_evidence.py
4h/8h 时从 1h resample离线不依赖 API
BTC 若无 2019 更早数据脚本会标明 gap不伪造历史
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Any, Optional
import pandas as pd
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
DATADIR = ROOT / "user_data/data/binance/futures"
STRAT = "Wyckoff_BTC_V1_BASELINE"
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase3_evidence_result.json"
# 候选外部验证(规则冻结;用本地最长可用历史)
CANDIDATES = [
{"pair": "BTC/USDT:USDT", "file": "BTC_USDT_USDT", "timerange": "20190901-"},
{"pair": "ETH/USDT:USDT", "file": "ETH_USDT_USDT", "timerange": "20191101-"},
{"pair": "SOL/USDT:USDT", "file": "SOL_USDT_USDT", "timerange": "20200901-"},
]
MIN_1H_BARS = 4000 # ~ema200@8h 需要足够历史;过短 skip
def ensure_tf(file_stub: str, tf: str, source_tf: str = "1h") -> bool:
"""从更细周期 resample 生成 tf feather;已存在则跳过。"""
out = DATADIR / f"{file_stub}-{tf}-futures.feather"
src = DATADIR / f"{file_stub}-{source_tf}-futures.feather"
if out.exists():
return True
if not src.exists():
return False
df = pd.read_feather(src)
df["date"] = pd.to_datetime(df["date"], utc=True)
df = df.set_index("date").sort_index()
rule = tf.replace("m", "min") if tf.endswith("m") else tf
ohlc = df.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna(subset=["open", "close"])
ohlc = ohlc.reset_index()
ohlc.to_feather(out)
print(f" resampled {out.name} n={len(ohlc)}", flush=True)
return True
def pair_ready(file_stub: str) -> tuple[bool, str]:
p1 = DATADIR / f"{file_stub}-1h-futures.feather"
if not p1.exists():
return False, "missing 1h"
df = pd.read_feather(p1)
n = len(df)
if n < MIN_1H_BARS:
return False, f"1h bars={n} < {MIN_1H_BARS} (insufficient for 8h ema200)"
ok4 = ensure_tf(file_stub, "4h")
ok8 = ensure_tf(file_stub, "8h")
if not (ok4 and ok8):
return False, "cannot build 4h/8h"
return True, f"1h={n}"
def run_bt(pair: str, timerange: str, fee: float = 0.0005, extra: float = 0.0) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
config = Configuration.from_files([str(CONFIG)])
config.update(
{
"strategy": STRAT,
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": timerange,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": fee + extra,
"exchange": {
**config.get("exchange", {}),
"pair_whitelist": [pair],
"name": config.get("exchange", {}).get("name", "binance"),
},
}
)
bt = Backtesting(config)
bt.start()
st = bt.results["strategy"].get(STRAT) or list(bt.results["strategy"].values())[0]
profit = st.get("profit_total_pct")
if profit is None:
profit = float(st.get("profit_total") or 0) * 100
# 按 enter_tag 拆分(freqtrade 可能是 dict 或 list[dict]
by_tag: dict[str, dict[str, Any]] = {}
trades = st.get("trades") or []
tag_stats = st.get("results_per_enter_tag") or {}
items = []
if isinstance(tag_stats, dict):
items = list(tag_stats.items())
elif isinstance(tag_stats, list):
items = [
(x.get("key") or x.get("enter_tag") or x.get("tag") or "unknown", x)
for x in tag_stats
if isinstance(x, dict)
]
if items:
for tag, info in items:
if not isinstance(info, dict):
continue
by_tag[str(tag)] = {
"trades": int(info.get("trades") or info.get("total_trades") or 0),
"profit_pct": float(
info.get("profit_total_pct")
if info.get("profit_total_pct") is not None
else (float(info.get("profit_total") or 0) * 100)
),
"pf": float(info.get("profit_factor") or 0),
}
elif trades:
from collections import defaultdict
agg: dict[str, list] = defaultdict(list)
for t in trades:
tag = t.get("enter_tag") or "unknown"
agg[tag].append(float(t.get("profit_ratio") or 0))
for tag, profits in agg.items():
wins = [p for p in profits if p > 0]
losses = [-p for p in profits if p <= 0]
gross_win = sum(wins)
gross_loss = sum(losses)
pf = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0)
by_tag[tag] = {
"trades": len(profits),
"profit_pct": sum(profits) * 100,
"pf": float(pf),
}
return {
"pair": pair,
"timerange": timerange,
"profit_pct": float(profit),
"trades": int(st.get("total_trades") or 0),
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
"pf": float(st.get("profit_factor") or 0),
"winrate": float(st.get("winrate") or 0) * 100,
"fee_used": config["fee"],
"by_setup": by_tag,
}
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([c["pair"] for c in CANDIDATES])
results: dict[str, Any] = {
"phase": "Phase3 Evidence Expansion",
"strategy": STRAT,
"rule": "frozen Spring-only; no LPS; no param change",
"pairs": {},
"skipped": {},
"notes": [],
}
# BTC 历史缺口说明
btc_1h = DATADIR / "BTC_USDT_USDT-1h-futures.feather"
if btc_1h.exists():
d0 = pd.read_feather(btc_1h)["date"].min()
results["notes"].append(
f"BTC local 1h starts {d0}; 2019-2022 not in datadir — download separately for deeper N"
)
print("===== Phase3: prepare TF data =====", flush=True)
run_list = []
for c in CANDIDATES:
ok, msg = pair_ready(c["file"])
if ok:
print(f" READY {c['pair']}: {msg}", flush=True)
run_list.append(c)
else:
print(f" SKIP {c['pair']}: {msg}", flush=True)
results["skipped"][c["pair"]] = msg
print("\n===== Phase3: backtests (fee 5bps, then fee+slip) =====", flush=True)
total_n = 0
spring_n = 0
utad_n = 0
for c in run_list:
print(f"\n--- {c['pair']} ---", flush=True)
base = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0)
mid = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0005)
block = {"base_fee": base, "net_mid": mid}
results["pairs"][c["pair"]] = block
total_n += base["trades"]
for tag, info in base.get("by_setup", {}).items():
if "SPRING" in tag:
spring_n += info["trades"]
if "UTAD" in tag:
utad_n += info["trades"]
print(
f" fee5bps profit={base['profit_pct']:.2f}% n={base['trades']} "
f"dd={base['dd_pct']:.1f}% pf={base['pf']:.2f}",
flush=True,
)
print(
f" net_mid profit={mid['profit_pct']:.2f}% n={mid['trades']} "
f"pf={mid['pf']:.2f}",
flush=True,
)
print(f" by_setup {base.get('by_setup')}", flush=True)
results["aggregate"] = {
"pairs_tested": len(run_list),
"total_trades": total_n,
"spring_long_trades": spring_n,
"utad_short_trades": utad_n,
"target_n": 50,
"target_met": total_n >= 50,
"next": (
"目标 N>=50 已达成 — 再看跨品种 net PF 是否仍>1.3"
if total_n >= 50
else "继续补历史数据(BTC 2019+)或更多品种 1h/4h/8h"
),
}
print("\n===== Aggregate =====")
print(json.dumps(results["aggregate"], ensure_ascii=False, indent=2))
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
print(f"\nSaved {OUT}")
if __name__ == "__main__":
main()
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Regime Attribution Study 策略完全冻结
问题为什么 Spring 2023+ BTC 有效全历史 / 多品种不稳健
方法逐笔交易打市场状态标签按桶看 net PF不改任何入场逻辑
输出:
- scripts/wyckoff_regime_attribution_trades.jsonl 逐笔
- scripts/wyckoff_regime_attribution_result.json 汇总
- research/VALIDITY_BOUNDARY.md 适用域草案
"""
from __future__ import annotations
import json
import logging
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, Optional
import numpy as np
import pandas as pd
import talib.abstract as ta
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
STRAT = "Wyckoff_BTC_V1_BASELINE"
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
DATADIR = ROOT / "user_data/data/binance/futures"
OUT_JSON = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_result.json"
OUT_TRADES = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_trades.jsonl"
OUT_BOUNDARY = ROOT / "user_data/Chan/research/VALIDITY_BOUNDARY.md"
STATUS = ROOT / "user_data/Chan/research/SYSTEM_STATUS.md"
PAIR = "BTC/USDT:USDT"
TIMERANGE = "20190901-"
FEE = 0.0005
SLIP = 0.0005 # 评价用 net
def _pf(profits: list[float]) -> float:
wins = [p for p in profits if p > 0]
losses = [-p for p in profits if p <= 0]
gw, gl = sum(wins), sum(losses)
if gl <= 0:
return 999.0 if gw > 0 else 0.0
return gw / gl
def _bucket_stats(rows: list[dict], key: str) -> dict[str, Any]:
groups: dict[str, list[float]] = defaultdict(list)
for r in rows:
groups[str(r.get(key, "na"))].append(float(r["profit_ratio"]))
out = {}
for k, ps in sorted(groups.items(), key=lambda x: -len(x[1])):
out[k] = {
"n": len(ps),
"winrate": 100.0 * sum(1 for p in ps if p > 0) / len(ps),
"avg_pct": 100.0 * float(np.mean(ps)),
"sum_pct": 100.0 * float(np.sum(ps)),
"pf": round(_pf(ps), 3),
}
return out
def build_feature_frames(pair_file: str = "BTC_USDT_USDT") -> tuple[pd.DataFrame, pd.DataFrame]:
"""1h ATR percentile + 8h structure features(与策略无关的分析层)。"""
h1 = pd.read_feather(DATADIR / f"{pair_file}-1h-futures.feather")
h1["date"] = pd.to_datetime(h1["date"], utc=True)
h1 = h1.sort_values("date").reset_index(drop=True)
h1["atr"] = ta.ATR(h1, timeperiod=14)
# 滚动 90 天 ≈ 2160 根 1h 的 ATR 分位
win = 2160
h1["atr_percentile"] = h1["atr"].rolling(win, min_periods=200).apply(
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
)
h8 = pd.read_feather(DATADIR / f"{pair_file}-8h-futures.feather")
h8["date"] = pd.to_datetime(h8["date"], utc=True)
h8 = h8.sort_values("date").reset_index(drop=True)
h8["ema50"] = ta.EMA(h8, timeperiod=50)
h8["ema200"] = ta.EMA(h8, timeperiod=200)
h8["adx"] = ta.ADX(h8, timeperiod=14)
h8["ema_slope"] = (h8["ema50"] - h8["ema50"].shift(6)) / h8["ema50"].shift(6)
h8["dist_ema200"] = (h8["close"] - h8["ema200"]) / h8["ema200"]
h8["bull"] = (h8["close"] > h8["ema200"]) & (h8["ema50"] > h8["ema200"])
h8["bear"] = (h8["close"] < h8["ema200"]) & (h8["ema50"] < h8["ema200"])
# Cycle(粗粒度威科夫语境,非策略信号)
slope = h8["ema_slope"]
cycle = np.full(len(h8), "transition", dtype=object)
cycle[(h8["bear"]) & (slope < -0.01)] = "markdown"
cycle[(h8["bear"]) & (slope >= -0.01)] = "accumulation_like"
cycle[(h8["bull"]) & (slope > 0.005)] = "markup"
cycle[(h8["bull"]) & (slope <= 0.005)] = "distribution_like"
h8["btc_cycle"] = cycle
# trend strength
ts = np.full(len(h8), "weak", dtype=object)
ts[(h8["adx"] >= 25) & (h8["adx"] < 35)] = "moderate"
ts[h8["adx"] >= 35] = "strong"
h8["trend_strength"] = ts
regime = np.full(len(h8), "range", dtype=object)
regime[h8["bull"].fillna(False)] = "bull"
regime[h8["bear"].fillna(False)] = "bear"
h8["market_regime"] = regime
return h1, h8
def atr_bucket(p: float) -> str:
if pd.isna(p):
return "atr_unknown"
if p < 0.33:
return "atr_low"
if p < 0.66:
return "atr_mid"
return "atr_high"
def slope_bucket(s: float) -> str:
if pd.isna(s):
return "slope_unknown"
if s > 0.01:
return "slope_up_strong"
if s > 0:
return "slope_up_mild"
if s > -0.01:
return "slope_flat_down"
return "slope_down_strong"
def run_backtest_trades() -> list[dict[str, Any]]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod:
del sys.modules[mod]
config = Configuration.from_files([str(CONFIG)])
config.update(
{
"strategy": STRAT,
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": TIMERANGE,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": FEE + SLIP,
"exchange": {
**config.get("exchange", {}),
"name": "binance",
"pair_whitelist": [PAIR],
},
}
)
bt = Backtesting(config)
bt.start()
rows = []
for t in LocalTrade.bt_trades:
rows.append(
{
"pair": t.pair,
"enter_tag": t.enter_tag or "",
"is_short": bool(t.is_short),
"entry_date": t.open_date_utc.isoformat(),
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else None,
"profit_ratio": float(t.close_profit or 0.0),
"exit_reason": t.exit_reason or "",
}
)
return rows
def attribute(trades: list[dict], h1: pd.DataFrame, h8: pd.DataFrame) -> list[dict]:
h1 = h1.set_index("date").sort_index()
h8 = h8.set_index("date").sort_index()
out = []
for t in trades:
ed = pd.Timestamp(t["entry_date"])
if ed.tzinfo is None:
ed = ed.tz_localize("UTC")
# asof merge:入场前最后一根已收盘特征
i1 = h1.index.get_indexer([ed], method="ffill")[0]
i8 = h8.index.get_indexer([ed], method="ffill")[0]
if i1 < 0 or i8 < 0:
continue
r1 = h1.iloc[i1]
r8 = h8.iloc[i8]
ap = float(r1["atr_percentile"]) if pd.notna(r1["atr_percentile"]) else float("nan")
slope = float(r8["ema_slope"]) if pd.notna(r8["ema_slope"]) else float("nan")
adx = float(r8["adx"]) if pd.notna(r8["adx"]) else float("nan")
era = "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023"
rec = {
**t,
"market_regime": str(r8["market_regime"]),
"8h_adx": round(adx, 2) if not np.isnan(adx) else None,
"8h_ema_slope": round(slope, 5) if not np.isnan(slope) else None,
"atr_percentile": round(ap, 3) if not np.isnan(ap) else None,
"btc_cycle": str(r8["btc_cycle"]),
"trend_strength": str(r8["trend_strength"]),
"dist_ema200": round(float(r8["dist_ema200"]), 4) if pd.notna(r8["dist_ema200"]) else None,
"atr_bucket": atr_bucket(ap),
"slope_bucket": slope_bucket(slope),
"era": era,
"setup": t["enter_tag"] or ("UTAD_SHORT" if t["is_short"] else "SPRING_LONG"),
"result": "win" if t["profit_ratio"] > 0 else "loss",
}
out.append(rec)
return out
def write_boundary(summary: dict[str, Any]) -> None:
# 从桶结果提炼适用域草案(描述性,非自动交易规则)
atr = summary["by_atr_bucket"]
cycle = summary["by_btc_cycle"]
era = summary["by_era"]
ts = summary["by_trend_strength"]
def best_worst(d: dict) -> tuple[str, str]:
items = [(k, v) for k, v in d.items() if v["n"] >= 5]
if not items:
return "n/a", "n/a"
best = max(items, key=lambda x: x[1]["pf"])
worst = min(items, key=lambda x: x[1]["pf"])
return f"{best[0]} (PF {best[1]['pf']}, n={best[1]['n']})", f"{worst[0]} (PF {worst[1]['pf']}, n={worst[1]['n']})"
ab, aw = best_worst(atr)
cb, cw = best_worst(cycle)
tb, tw = best_worst(ts)
text = f"""# Validity Boundary — Spring Baseline (draft)
> 策略规则冻结本文仅来自 Regime Attribution**不是**新入场条件
## Evidence snapshot
| Era | n | PF (net) | sum%% |
|-----|---|----------|-------|
| pre_2023 | {era.get('pre_2023', {}).get('n', 0)} | {era.get('pre_2023', {}).get('pf', 0)} | {era.get('pre_2023', {}).get('sum_pct', 0):.1f} |
| 2023plus | {era.get('2023plus', {}).get('n', 0)} | {era.get('2023plus', {}).get('pf', 0)} | {era.get('2023plus', {}).get('sum_pct', 0):.1f} |
## Observed favorable (descriptive)
- ATR bucket best: **{ab}**
- Cycle best: **{cb}**
- Trend strength best: **{tb}**
## Observed unfavorable (descriptive)
- ATR bucket worst: **{aw}**
- Cycle worst: **{cw}**
- Trend strength worst: **{tw}**
## Draft Validity Boundary
```
Spring Strategy (BTC)
适用研究假设 Decision Engine 验证:
BTC非默认跨资产
2023+ 明确资金方向 / Markup 启动环境
/中波动ATR rising / mid-high percentile若数据支持
Accumulation_like Markup 过渡语境
不适用当前证据:
默认全历史无条件交易
横盘 / range regime
跨资产默认开启ETH/SOL Phase3 未过
熊市 Markdown 快速崩跌阶段若桶显示 PF
```
## Next for Decision Engine
Market State 先判定是否落在适用域 再允许 SPRING_LONG / UTAD_SHORT 信号
**禁止**把本文件桶标签直接写回 Baseline 参数扫参
"""
OUT_BOUNDARY.write_text(text)
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([PAIR])
print("===== 1) Frozen baseline backtest (BTC, net cost) =====", flush=True)
raw = run_backtest_trades()
print(f" trades={len(raw)}", flush=True)
print("===== 2) Build regime features =====", flush=True)
h1, h8 = build_feature_frames()
rows = attribute(raw, h1, h8)
print(f" attributed={len(rows)}", flush=True)
with OUT_TRADES.open("w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
summary: dict[str, Any] = {
"pair": PAIR,
"timerange": TIMERANGE,
"fee_model": f"fee {FEE}+slip {SLIP}",
"n": len(rows),
"overall_pf": round(_pf([r["profit_ratio"] for r in rows]), 3),
"by_era": _bucket_stats(rows, "era"),
"by_setup": _bucket_stats(rows, "setup"),
"by_market_regime": _bucket_stats(rows, "market_regime"),
"by_atr_bucket": _bucket_stats(rows, "atr_bucket"),
"by_trend_strength": _bucket_stats(rows, "trend_strength"),
"by_slope_bucket": _bucket_stats(rows, "slope_bucket"),
"by_btc_cycle": _bucket_stats(rows, "btc_cycle"),
"by_era_x_cycle": {},
"by_era_x_atr": {},
"interpretation": [],
}
# 交叉:era × cycle / atr
for era in ("pre_2023", "2023plus"):
sub = [r for r in rows if r["era"] == era]
summary["by_era_x_cycle"][era] = _bucket_stats(sub, "btc_cycle")
summary["by_era_x_atr"][era] = _bucket_stats(sub, "atr_bucket")
# 自动写几条解释线索(非交易规则)
era = summary["by_era"]
if era.get("2023plus", {}).get("pf", 0) > era.get("pre_2023", {}).get("pf", 0):
summary["interpretation"].append(
"2023plus PF 显著高于 pre_2023 → 存在 regime/cycle 依赖,非随机噪声单一窗口。"
)
cyc = summary["by_btc_cycle"]
if cyc:
best_c = max(cyc.items(), key=lambda x: (x[1]["n"] >= 5, x[1]["pf"]))
summary["interpretation"].append(
f"全样本 cycle 最优桶(n≥5 优先): {best_c[0]} PF={best_c[1]['pf']} n={best_c[1]['n']}"
)
print("\n===== 3) Attribution tables =====", flush=True)
for name in (
"by_era", "by_setup", "by_market_regime", "by_atr_bucket",
"by_trend_strength", "by_slope_bucket", "by_btc_cycle",
):
print(f"\n-- {name} --")
for k, v in summary[name].items():
print(f" {k:<22} n={v['n']:<3} pf={v['pf']:<6} wr={v['winrate']:.0f}% sum={v['sum_pct']:.1f}%")
print("\n-- by_era_x_cycle --")
print(json.dumps(summary["by_era_x_cycle"], indent=2, ensure_ascii=False))
write_boundary(summary)
OUT_JSON.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
# 更新 SYSTEM_STATUS
if STATUS.exists():
st = STATUS.read_text()
marker = "## Frozen Baseline"
block = (
"**Status update (Regime Attribution):**\n"
"Evidence: PASS (2023+ BTC) · Robustness: FAILED (multi-cycle) · "
"Confidence: LOW-MEDIUM · Next: Decision Engine validity gate "
f"(see `VALIDITY_BOUNDARY.md`, trades=`{OUT_TRADES.name}`).\n\n"
)
if "Status update (Regime Attribution)" not in st:
st = st.replace(marker, block + marker)
STATUS.write_text(st)
print(f"\nSaved {OUT_JSON}")
print(f"Saved {OUT_TRADES}")
print(f"Saved {OUT_BOUNDARY}")
if __name__ == "__main__":
main()
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Soft-score Gate 窄实验研究纪律
1) 仅在 pre_2023 比较少数 Gate 形式并选定阈值
2) 锁定后评估 2023+ / full
3) 禁止全样本扫参判定不要求超过 baseline PF
候选:
- state_set
- soft_sum: state_set & (accum+markup) >= q q {80,100,120,140}
- soft_bad_cap: state_set & max(bad) <= q q {40,50,60}
Fit 目标pre_2023: n>=5 前提下优先更低 DD其次更高 PF非收益最大化
OOS 通过:
- 2023+ PF >= 1.2
- full DD 明显低于 baseline<= baseline_dd * 0.7 或绝对差 >= 5pp
- pre_2023 n >= 5非极低样本偶然
- 标签不漂移gated 入场中 state{accumulation,markup}|UTAD镜像 比例 >= 0.95
"""
from __future__ import annotations
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC_GATED.py"
BASE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
GATE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json"
OUT = ROOT / "user_data/Chan/scripts/wyckoff_soft_gate_oos_result.json"
PAIR = "BTC/USDT:USDT"
FIT_TR = "20190901-20230101"
OOS_TR = "20230101-"
FULL_TR = "20190901-"
CANDIDATES: list[dict[str, Any]] = [
{"mode": "state_set", "q_sum": 100.0, "q_bad": 55.0},
{"mode": "soft_sum", "q_sum": 80.0, "q_bad": 55.0},
{"mode": "soft_sum", "q_sum": 100.0, "q_bad": 55.0},
{"mode": "soft_sum", "q_sum": 120.0, "q_bad": 55.0},
{"mode": "soft_sum", "q_sum": 140.0, "q_bad": 55.0},
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 40.0},
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 50.0},
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 60.0},
]
def set_gate(mode: str, q_sum: float, q_bad: float) -> None:
text = STRAT_PATH.read_text()
text2, n1 = re.subn(
r'^(\tgate_mode: str = )".*"',
rf'\g<1>"{mode}"',
text,
count=1,
flags=re.M,
)
text2, n2 = re.subn(
r'^(\tgate_q_sum: float = )[0-9.]+',
rf"\g<1>{float(q_sum)}",
text2,
count=1,
flags=re.M,
)
text2, n3 = re.subn(
r'^(\tgate_q_bad: float = )[0-9.]+',
rf"\g<1>{float(q_bad)}",
text2,
count=1,
flags=re.M,
)
if min(n1, n2, n3) < 1:
raise RuntimeError(f"failed patching gate attrs n=({n1},{n2},{n3})")
STRAT_PATH.write_text(text2)
pyc = STRAT_PATH.parent / "__pycache__"
if pyc.is_dir():
for p in pyc.glob("Wyckoff_BTC_GATED*.pyc"):
p.unlink(missing_ok=True)
def run_bt(strategy: str, config: Path, timerange: str) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade
import freqtrade.optimize.optimize_reports.bt_output as bt_output
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod or "market_state" in mod:
del sys.modules[mod]
cfg = Configuration.from_files([str(config)])
cfg.update(
{
"strategy": strategy,
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
"timerange": timerange,
"timeframe": "1h",
"export": "none",
"runmode": RunMode.BACKTEST,
"datadir": ROOT / "user_data/data/binance",
"user_data_dir": ROOT / "user_data",
"enable_protections": False,
"fee": 0.0010,
"exchange": {
**cfg.get("exchange", {}),
"name": "binance",
"pair_whitelist": [PAIR],
},
}
)
bt = Backtesting(cfg)
bt.start()
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
profit = st.get("profit_total_pct")
if profit is None:
profit = float(st.get("profit_total") or 0) * 100
profits = [float(t.close_profit or 0.0) for t in LocalTrade.bt_trades]
wins = [p for p in profits if p > 0]
losses = [p for p in profits if p <= 0]
avg_win = float(sum(wins) / len(wins)) if wins else 0.0
avg_loss = float(sum(losses) / len(losses)) if losses else 0.0
expectancy = float(sum(profits) / len(profits)) if profits else 0.0
# 标签漂移:用原生 8h 因果状态(不依赖 analyzed 缓存窗口)
label_ok_rate = None
try:
import pandas as pd
from engine.market_state import compute_market_state_8h
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
h8["date"] = pd.to_datetime(h8["date"], utc=True)
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
ok = tot = 0
for t in LocalTrade.bt_trades:
ed = pd.Timestamp(t.open_date_utc)
if ed.tzinfo is None:
ed = ed.tz_localize("UTC")
idx = h8.index.get_indexer([ed], method="ffill")[0]
if idx < 0:
continue
stt = str(h8.iloc[idx]["market_state"])
tag = t.enter_tag or ""
if "SPRING" in tag:
ok += int(stt in ("accumulation", "markup"))
elif "UTAD" in tag:
ok += int(stt in ("distribution", "markdown"))
else:
ok += 1
tot += 1
label_ok_rate = (ok / tot) if tot else None
except Exception:
label_ok_rate = None
return {
"profit_pct": float(profit),
"trades": int(st.get("total_trades") or 0),
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
"pf": float(st.get("profit_factor") or 0),
"winrate": float(st.get("winrate") or 0) * 100,
"expectancy": expectancy,
"avg_win": avg_win,
"avg_loss": avg_loss,
"label_ok_rate": label_ok_rate,
}
def fit_score(m: dict[str, Any]) -> tuple:
"""pre_2023 选择:n>=5DD 越低越好;PF 次之;n 再之。"""
n = m["trades"]
if n < 5:
return (0, 999.0, 0.0, 0) # invalid
return (1, m["dd_pct"], -m["pf"], -n)
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
install_offline_markets([PAIR])
orig = STRAT_PATH.read_text()
results: dict[str, Any] = {
"discipline": "fit on pre_2023 only; lock; test 2023+/full; no full-sample sweep",
"baseline": {},
"candidates_fit_pre2023": [],
"locked": None,
"oos": {},
"verdict": {},
}
try:
print("===== Baseline (reference) =====", flush=True)
for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]:
r = run_bt("Wyckoff_BTC_V1_BASELINE", BASE_CFG, tr)
results["baseline"][name] = r
print(
f" baseline {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} "
f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}%",
flush=True,
)
print("\n===== Fit soft gates on pre_2023 only =====", flush=True)
fit_rows = []
for c in CANDIDATES:
set_gate(c["mode"], c["q_sum"], c["q_bad"])
r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, FIT_TR)
row = {**c, **r, "valid_n": r["trades"] >= 5}
fit_rows.append(row)
print(
f" {c['mode']:<12} q_sum={c['q_sum']:<5} q_bad={c['q_bad']:<5} "
f"n={r['trades']:<3} pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
f"label_ok={r['label_ok_rate']}",
flush=True,
)
results["candidates_fit_pre2023"] = fit_rows
valid = [x for x in fit_rows if x["valid_n"]]
if not valid:
raise RuntimeError("no candidate with n>=5 on pre_2023")
locked = sorted(valid, key=fit_score)[0]
results["locked"] = {
"mode": locked["mode"],
"q_sum": locked["q_sum"],
"q_bad": locked["q_bad"],
"pre_2023": {
k: locked[k]
for k in (
"trades", "pf", "dd_pct", "profit_pct", "expectancy",
"avg_win", "avg_loss", "label_ok_rate",
)
},
}
print(
f"\nLOCKED (pre_2023): mode={locked['mode']} q_sum={locked['q_sum']} "
f"q_bad={locked['q_bad']} n={locked['trades']} pf={locked['pf']:.2f} "
f"dd={locked['dd_pct']:.1f}%",
flush=True,
)
set_gate(locked["mode"], locked["q_sum"], locked["q_bad"])
print("\n===== Locked gate → OOS / full =====", flush=True)
for name, tr in [("pre_2023", FIT_TR), ("oos_2023plus", OOS_TR), ("full", FULL_TR)]:
r = run_bt("Wyckoff_BTC_GATED", GATE_CFG, tr)
results["oos"][name] = r
print(
f" gated {name:<12} n={r['trades']:<3} pf={r['pf']:.2f} "
f"dd={r['dd_pct']:.1f}% exp={r['expectancy']*100:.2f}% "
f"avgW={r['avg_win']*100:.2f}% avgL={r['avg_loss']*100:.2f}% "
f"label_ok={r['label_ok_rate']}",
flush=True,
)
b_full = results["baseline"]["full"]
b_oos = results["baseline"]["oos_2023plus"]
g_pre = results["oos"]["pre_2023"]
g_oos = results["oos"]["oos_2023plus"]
g_full = results["oos"]["full"]
dd_ok = (g_full["dd_pct"] <= b_full["dd_pct"] * 0.7) or (
(b_full["dd_pct"] - g_full["dd_pct"]) >= 5.0
)
label_ok = (g_oos.get("label_ok_rate") is None) or (g_oos["label_ok_rate"] >= 0.95)
results["verdict"] = {
"oos_pf_ge_1_2": g_oos["pf"] >= 1.2,
"full_dd_clearly_below_baseline": dd_ok,
"pre2023_n_ge_5": g_pre["trades"] >= 5,
"label_no_drift": label_ok,
"oos_pf": g_oos["pf"],
"oos_n": g_oos["trades"],
"full_dd_gated": g_full["dd_pct"],
"full_dd_baseline": b_full["dd_pct"],
"baseline_oos_pf": b_oos["pf"],
"status": (
"PASS"
if (
g_oos["pf"] >= 1.2
and dd_ok
and g_pre["trades"] >= 5
and label_ok
)
else "FAIL"
),
"note": "Success = domain control (PF floor + DD cut), not beating baseline PF.",
}
print("\n===== Verdict =====")
print(json.dumps(results["verdict"], indent=2, ensure_ascii=False))
finally:
# 恢复默认 state_set,避免污染 live 默认
STRAT_PATH.write_text(orig)
print("\nRestored Wyckoff_BTC_GATED.py defaults", flush=True)
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
print(f"Saved {OUT}")
if __name__ == "__main__":
main()
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""离线网格:对比 Wyckoff 多周期组合(不依赖 Binance API)。"""
from __future__ import annotations
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any, Optional
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT))
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC.py"
CONFIG_PATH = ROOT / "user_data/Chan/config/Wyckoff_BTC.json"
COMBOS = [
("1h_4h_noBias", "1h", "4h", None),
("1h_4h_8h", "1h", "4h", "8h"),
("1h_8h_noBias", "1h", "8h", None),
("30m_4h_8h", "30m", "4h", "8h"),
("30m_4h_noBias", "30m", "4h", None),
("15m_1h_4h", "15m", "1h", "4h"),
("15m_4h_8h", "15m", "4h", "8h"),
("4h_8h_noBias", "4h", "8h", None),
]
def stub_market(symbol: str = "BTC/USDT:USDT") -> dict[str, Any]:
base = symbol.split("/")[0]
return {
"id": symbol,
"symbol": symbol,
"base": base,
"quote": "USDT",
"settle": "USDT",
"baseId": base,
"quoteId": "USDT",
"settleId": "USDT",
"type": "swap",
"spot": False,
"swap": True,
"future": False,
"option": False,
"active": True,
"contract": True,
"linear": True,
"inverse": False,
"contractSize": 1.0,
"precision": {"amount": 0.001, "price": 0.1},
"limits": {
"amount": {"min": 0.001, "max": 1000.0},
"price": {"min": 0.1, "max": None},
"cost": {"min": 5.0, "max": None},
"leverage": {"min": 1.0, "max": 125.0},
},
"percentage": True,
"taker": 0.0005,
"maker": 0.0002,
"info": {},
}
def install_offline_markets(pairs: Optional[list[str]] = None) -> None:
import ccxt
import freqtrade.exchange.exchange as exmod
from freqtrade.util import dt_ts
if pairs is None:
pairs = ["BTC/USDT:USDT"]
markets = {p: stub_market(p) for p in pairs}
tiers = {
p: [
{
"minNotional": 0,
"maxNotional": 1e12,
"maintenanceMarginRate": 0.005,
"maxLeverage": 125,
"info": {},
}
]
for p in pairs
}
def fake_reload(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None:
self._markets = markets
try:
self._api.precisionMode = ccxt.TICK_SIZE
self._api_async.precisionMode = ccxt.TICK_SIZE
except Exception:
pass
try:
self._api.set_markets(markets)
except Exception:
pass
try:
self._api_async.set_markets(markets)
except Exception:
pass
self._last_markets_refresh = dt_ts()
self._leverage_tiers = tiers
self._trading_fees = {}
exmod.Exchange.reload_markets = fake_reload # type: ignore
exmod.Exchange.fills_leverage_tiers = lambda self: setattr(self, "_leverage_tiers", tiers) # type: ignore
def patch_strategy(exec_tf: str, structure_tf: str, bias_tf: Optional[str]) -> None:
text = STRAT_PATH.read_text()
bias_repr = "None" if bias_tf is None else f'"{bias_tf}"'
text = re.sub(r'^(\ttimeframe = ).*$', rf'\g<1>"{exec_tf}"', text, count=1, flags=re.M)
text = re.sub(
r'^(\tstructure_timeframe = ).*$', rf'\g<1>"{structure_tf}"', text, count=1, flags=re.M
)
text = re.sub(
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
rf'\g<1>{bias_repr}',
text,
count=1,
flags=re.M,
)
startup = 220 if exec_tf in ("1h", "4h", "8h") else 400
text = re.sub(
r'^(\tstartup_candle_count = ).*$', rf'\g<1>{startup}', text, count=1, flags=re.M
)
STRAT_PATH.write_text(text)
def run_one(exec_tf: str, timerange: str) -> dict[str, Any]:
from freqtrade.configuration import Configuration
from freqtrade.enums import RunMode
from freqtrade.optimize.backtesting import Backtesting
import freqtrade.optimize.optimize_reports.bt_output as bt_output
# 静默打印
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
bt_output.show_backtest_result = lambda *a, **k: None # type: ignore
for mod in list(sys.modules):
if "Wyckoff_BTC" in mod or mod.endswith("Wyckoff_BTC"):
del sys.modules[mod]
config = Configuration.from_files([str(CONFIG_PATH)])
config["strategy"] = "Wyckoff_BTC"
config["strategy_path"] = str(ROOT / "user_data/Chan/strategies")
config["timerange"] = timerange
config["timeframe"] = exec_tf
config["export"] = "none"
config["runmode"] = RunMode.BACKTEST
config["datadir"] = ROOT / "user_data/data/binance"
config["user_data_dir"] = ROOT / "user_data"
config["enable_protections"] = False
bt = Backtesting(config)
bt.start()
stats = bt.results
strat_stats = stats["strategy"].get("Wyckoff_BTC") or list(stats["strategy"].values())[0]
trades = int(strat_stats.get("total_trades") or 0)
profit_pct = strat_stats.get("profit_total_pct")
if profit_pct is None:
profit_pct = float(strat_stats.get("profit_total") or 0) * 100
dd = float(strat_stats.get("max_drawdown_account") or 0) * 100
wr = float(strat_stats.get("winrate") or 0) * 100
return {
"ok": True,
"profit_pct": float(profit_pct),
"trades": trades,
"dd_pct": dd,
"pf": float(strat_stats.get("profit_factor") or 0),
"winrate": wr,
"rejected": int(strat_stats.get("rejected_signals") or 0),
"timeframe_used": config.get("timeframe"),
}
def main() -> None:
logging.getLogger("freqtrade").setLevel(logging.ERROR)
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
install_offline_markets()
orig = STRAT_PATH.read_text()
rows: list[dict[str, Any]] = []
try:
for label, exec_tf, stf, btf in COMBOS:
print(f"=== {label} ===", flush=True)
patch_strategy(exec_tf, stf, btf)
try:
res = run_one(exec_tf, timerange)
except Exception as e:
res = {"ok": False, "error": f"{type(e).__name__}: {e}"}
res["label"] = label
res["exec"] = exec_tf
res["struct"] = stf
res["bias"] = btf or "-"
rows.append(res)
if res.get("ok"):
print(
f" profit={res['profit_pct']:.2f}% trades={res['trades']} "
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f} wr={res['winrate']:.1f}% "
f"rej={res['rejected']}",
flush=True,
)
else:
print(f" FAILED: {res.get('error')}", flush=True)
finally:
STRAT_PATH.write_text(orig)
ok = [r for r in rows if r.get("ok")]
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
print("\n========== RANKING ==========")
print(f"{'label':<16} {'E':<5} {'S':<5} {'B':<5} {'profit%':>8} {'trades':>7} {'dd%':>7} {'pf':>6} {'wr%':>6}")
for r in ok:
print(
f"{r['label']:<16} {r['exec']:<5} {r['struct']:<5} {r['bias']:<5} "
f"{r['profit_pct']:>8.2f} {r['trades']:>7} {r['dd_pct']:>7.2f} {r['pf']:>6.2f} {r['winrate']:>6.1f}"
)
out = ROOT / "user_data/Chan/scripts/wyckoff_tf_grid_result.txt"
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
print(f"\nSaved {out}")
if ok:
best = ok[0]
print(f"BEST: {best['label']} -> 将写入策略默认周期")
if __name__ == "__main__":
main()
+458
View File
@@ -0,0 +1,458 @@
"""
BTC Maker Micro Scalper v1.0
目标 BTCUSDT 永续 1m 级别用盘口微结构OBI / Delta / CVD / VWAP
Maker 挂单捕捉约 0.03%~0.08% 的微小价差
回测说明
- Freqtrade 标准回测只有 OHLCV没有真实 L2 / Tick
- 本策略用 K 线代理重构 OBI / Delta / CVD使逻辑可回测可验证
- 实盘 / Dry-run confirm_trade_entry 会用真实 10 orderbook 覆盖 OBI
不要加入RSI / MACD / 均线交叉 / 神经网络
运行示例
freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
-t 1m --pairs BTC/USDT:USDT --timerange=20260101-
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
--strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\
--timerange=20260101- --fee 0.00016
python user_data/Chan/strategies/mms_stats.py
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy, DecimalParameter
logger = logging.getLogger(__name__)
def _safe_div(num, den):
return np.where(den != 0, num / den, 0.0)
class BTC_Maker_Micro_Scalper(IStrategy):
"""
Maker Micro Scalping MVP 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤
"""
INTERFACE_VERSION: int = 3
timeframe: str = "1m"
can_short: bool = True
process_only_new_candles: bool = True
startup_candle_count: int = 120
# 固定小止盈 / 止损(价格百分比,非杠杆后权益)
# ROI +0.05%stoploss -0.03%;时间止损 3 分钟在 custom_exit
minimal_roi = {"0": 0.0005}
stoploss = -0.0003
trailing_stop = False
use_exit_signal = False
use_custom_stoploss = False
# Maker 限价单
order_types = {
"entry": "limit",
"exit": "limit",
"stoploss": "limit",
"stoploss_on_exchange": False,
}
order_time_in_force = {
"entry": "GTC",
"exit": "GTC",
}
# ---- 可调参数(保持与规格一致;后续可 hyperopt)----
maker_fee = 0.00016 # 0.016%
atr_fee_mult = 3.0 # ATR > fee * 3
obi_threshold = 0.15
tp_pct = 0.0005 # +0.05%
sl_pct = 0.0003 # -0.03%
max_hold_minutes = 3
stake_pct = 0.005 # 单次 0.5% 账户资金
max_leverage = 3.0
consecutive_loss_limit = 3
pause_minutes = 30
vwap_band = 0.001 # ±0.1%
ob_levels = 10 # 实盘用 10 档
tick_size = 0.1 # BTCUSDT 永续常见最小变动
maker_offset_ticks = 1
# Hyperopt 可选(默认关闭,不改变 v1 逻辑)
buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False)
# 运行时状态:连续亏损熔断
_loss_streak: int = 0
_pause_until: Optional[datetime] = None
_maker_fills: int = 0
_total_fills: int = 0
plot_config = {
"main_plot": {
"vwap": {"color": "orange"},
},
"subplots": {
"OBI": {"obi": {"color": "blue"}},
"Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}},
"CVD": {"cvd": {"color": "purple"}},
"ATR_pct": {"atr_pct": {"color": "red"}},
},
}
# ------------------------------------------------------------------ #
# 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖)
# ------------------------------------------------------------------ #
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
high = df["high"]
low = df["low"]
close = df["close"]
volume = df["volume"].astype(float)
# ATR(20) 与相对波动
df["atr"] = ta.ATR(df, timeperiod=20)
df["atr_pct"] = _safe_div(df["atr"], close)
# 规格:ATR > 单边手续费 × 30.016% × 3 = 0.048%
df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult)
# ---- Delta / Buy-Sell 分解(蜡烛代理)----
# buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low)
# 先把 close 夹到 [low, high],避免脏数据让 OBI 越界
close_c = close.clip(lower=low, upper=high)
hl = (high - low).astype(float)
hl_safe = hl.where(hl > 0, np.nan)
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
sell_frac = 1.0 - buy_frac
buy_vol = volume * buy_frac
sell_vol = volume * sell_frac
df["buy_vol"] = buy_vol
df["sell_vol"] = sell_vol
df["delta"] = buy_vol - sell_vol
# 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta
# 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向”
df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum()
# “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值
df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean()
df["delta_accel"] = df["delta"] > df["delta_ma"]
# CVD
df["cvd"] = df["delta"].cumsum()
# 规格:CVD_now > CVD_20s_ago1m 用 shift(1)
df["cvd_up"] = df["cvd"] > df["cvd"].shift(1)
df["cvd_down"] = df["cvd"] < df["cvd"].shift(1)
# ---- OBI 代理(无 L2 时)----
# OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1]
denom = buy_vol + sell_vol
df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0)
# ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)----
tp = (high + low + close) / 3.0
window = 60
cum_pv = (tp * volume).rolling(window, min_periods=1).sum()
cum_v = volume.rolling(window, min_periods=1).sum()
df["vwap"] = _safe_div(cum_pv, cum_v)
df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band)
df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band)
# 辅助:标记是否满足波动过滤
df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult
return df
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold
long_cond = (
dataframe["vol_ok"]
& (dataframe["obi"] > obi_th)
& (dataframe["delta_sum"] > 0)
& dataframe["delta_accel"]
& dataframe["cvd_up"]
& dataframe["below_vwap_band"]
& (dataframe["volume"] > 0)
)
short_cond = (
dataframe["vol_ok"]
& (dataframe["obi"] < -obi_th)
& (dataframe["delta_sum"] < 0)
& (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值)
& dataframe["cvd_down"]
& dataframe["above_vwap_band"]
& (dataframe["volume"] > 0)
)
dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi")
dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi")
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# 出场交给 ROI / stoploss / custom_exit(时间止损)
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
# ------------------------------------------------------------------ #
# Maker 报价:Bid+1tick / Ask-1tick
# ------------------------------------------------------------------ #
def custom_entry_price(
self,
pair: str,
trade: Trade | None,
current_time: datetime,
proposed_rate: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
tick = self.tick_size
offset = self.maker_offset_ticks * tick
# 实盘优先用盘口
try:
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
bids = ob.get("bids") or []
asks = ob.get("asks") or []
if side == "long" and bids:
return float(bids[0][0]) + offset
if side == "short" and asks:
return float(asks[0][0]) - offset
except Exception as e:
logger.debug("custom_entry_price orderbook fallback: %s", e)
# 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂)
if side == "long":
return proposed_rate - offset
return proposed_rate + offset
def custom_exit_price(
self,
pair: str,
trade: Trade,
current_time: datetime,
proposed_rate: float,
current_profit: float,
exit_tag: str | None,
**kwargs,
) -> float:
tick = self.tick_size
offset = self.maker_offset_ticks * tick
try:
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
bids = ob.get("bids") or []
asks = ob.get("asks") or []
if trade.is_short and bids:
# 空头平仓 = 买入,挂 bid+1tick
return float(bids[0][0]) + offset
if (not trade.is_short) and asks:
# 多头平仓 = 卖出,挂 ask-1tick
return float(asks[0][0]) - offset
except Exception as e:
logger.debug("custom_exit_price orderbook fallback: %s", e)
if trade.is_short:
return proposed_rate - offset
return proposed_rate + offset
# ------------------------------------------------------------------ #
# 风控
# ------------------------------------------------------------------ #
def leverage(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_leverage: float,
max_leverage: float,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> float:
return min(self.max_leverage, float(max_leverage))
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: float | None,
max_stake: float,
leverage: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
# 单次账户资金 0.5%(作为保证金 stake)
try:
wallets = self.wallets
if wallets:
free = wallets.get_free(self.config["stake_currency"])
stake = free * self.stake_pct
if min_stake:
stake = max(stake, min_stake)
return min(stake, max_stake)
except Exception as e:
logger.debug("custom_stake_amount fallback: %s", e)
return proposed_stake * self.stake_pct if proposed_stake else proposed_stake
def _paused(self, current_time: datetime) -> bool:
if self._pause_until is None:
return False
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
until = self._pause_until if self._pause_until.tzinfo else self._pause_until.replace(
tzinfo=timezone.utc
)
return now < until
@staticmethod
def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]:
bids = (ob.get("bids") or [])[:levels]
asks = (ob.get("asks") or [])[:levels]
if not bids or not asks:
return None
bid_vol = sum(float(b[1]) for b in bids)
ask_vol = sum(float(a[1]) for a in asks)
tot = bid_vol + ask_vol
if tot <= 0:
return None
return (bid_vol - ask_vol) / tot
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: str | None,
side: str,
**kwargs,
) -> bool:
if self._paused(current_time):
logger.info("Paused until %s — skip entry", self._pause_until)
return False
# 实盘:用真实 10 档 OBI 复核
try:
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
obi = self._calc_obi_from_orderbook(ob, self.ob_levels)
if obi is None:
return False
if side == "long" and obi <= self.obi_threshold:
logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold)
return False
if side == "short" and obi >= -self.obi_threshold:
logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold)
return False
except Exception as e:
logger.warning("confirm_trade_entry orderbook check failed: %s", e)
return True
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
):
# 时间止损:持仓 > 3 分钟
open_time = trade.open_date_utc
if open_time.tzinfo is None:
open_time = open_time.replace(tzinfo=timezone.utc)
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
held = now - open_time
if held >= timedelta(minutes=self.max_hold_minutes):
return "time_stop_3m"
# 双保险:显式 TP / SLROI/stoploss 也会触发)
if current_profit >= self.tp_pct:
return "tp_0.05pct"
if current_profit <= -self.sl_pct:
return "sl_0.03pct"
return None
def order_filled(
self,
pair: str,
trade: Trade,
order,
current_time: datetime,
**kwargs,
) -> None:
self._total_fills += 1
# limit 单视为 Maker
otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None)
if otype and str(otype).lower() == "limit":
self._maker_fills += 1
def confirm_trade_exit(
self,
pair: str,
trade: Trade,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
exit_reason: str,
current_time: datetime,
**kwargs,
) -> bool:
# 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估)
try:
profit = trade.calc_profit_ratio(rate)
if profit < 0:
self._loss_streak += 1
if self._loss_streak >= self.consecutive_loss_limit:
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
logger.warning(
"Loss streak=%d → pause %d min until %s",
self._loss_streak,
self.pause_minutes,
self._pause_until,
)
self._loss_streak = 0
else:
self._loss_streak = 0
except Exception as e:
logger.debug("confirm_trade_exit streak update: %s", e)
return True
# ------------------------------------------------------------------ #
# Protections(回测需 --enable-protections
# ------------------------------------------------------------------ #
@property
def protections(self):
return [
{
"method": "StoplossGuard",
"lookback_period_candles": 30,
"trade_limit": self.consecutive_loss_limit,
"stop_duration_candles": self.pause_minutes,
"only_per_pair": True,
"only_per_side": False,
}
]
+488
View File
@@ -0,0 +1,488 @@
"""
BTC Maker Scalper v1.1 Liquidity Providing
相对 v1.0 的核心变化
- 不再用 OBI/Delta/CVD 预测下一根涨跌Directional Scalping
- 改为卖压衰竭 + Bid 吸收 提供流动性接单Liquidity Providing
- 挂单更深Bid - 0~2 tick等待被打
- 出场盘口/价差优势恢复非固定 0.05% TP
- 禁做市5m EMA26 斜率过大 ATR 异常单边趋势
回测限制仍然存在但模型目标不同
- OHLCV 无法完美模拟 Maker 成交时点本版用更严过滤降频到 ~10-30 /天量级做压力测试
- 实盘用 orderbook 复核吸收/挂价
运行
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\
--strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\
--timerange=20260701-20260708 --fee 0.00016 --enable-protections
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy
logger = logging.getLogger(__name__)
def _safe_div(num, den, fill=0.0):
out = np.where((den is not None) & (den != 0), num / den, fill)
return out
class BTC_Maker_Micro_Scalper_v11(IStrategy):
"""
v1.1 Liquidity Providing卖压衰竭 + 吸收 Maker 接单趋势中禁做市
"""
INTERFACE_VERSION: int = 3
timeframe = "1m"
can_short = True
process_only_new_candles = True
startup_candle_count = 200
# 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复)
# 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉
minimal_roi = {"0": 0.01}
# 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复
stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察)
trailing_stop = False
use_exit_signal = True
exit_profit_only = False
use_custom_stoploss = False
order_types = {
"entry": "limit",
"exit": "limit",
"stoploss": "limit",
"stoploss_on_exchange": False,
}
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
# ---- 费用 / 风控 ----
maker_fee = 0.00016
stake_pct = 0.005
max_leverage = 2.0 # v1.1 更克制
consecutive_loss_limit = 10
pause_minutes = 30
max_hold_minutes = 5
# ---- 微结构代理窗口(1m 近似 20s/100trades----
sell_window = 3 # 近端卖量
sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭”
absorb_lookback = 5
min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参)
tick_size = 0.1
maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick
exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70%
prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压)
# ---- 禁做市(趋势)----
ema_slope_thr = 0.00018 # 更早禁止单边做市
atr_spike_mult = 1.8
min_atr_pct = 0.00035
# 目标退出:相对入场的“优势恢复”幅度(价格)
edge_exit_pct = 0.00025
adverse_exit_pct = 0.0006
cooldown_minutes = 8 # 降频到验收带附近
ob_levels = 10
_loss_streak: int = 0
_pause_until: Optional[datetime] = None
_last_entry_time: Optional[datetime] = None
plot_config = {
"main_plot": {
"ema26_1m": {"color": "gray"},
},
"subplots": {
"SellVol": {
"sell_vol": {"color": "red"},
"sell_vol_ma": {"color": "orange"},
},
"Absorb": {"absorb_ratio": {"color": "blue"}},
"TrendBlock": {"trend_block": {"color": "black"}},
},
}
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe.copy()
high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float)
close_c = close.clip(lower=low, upper=high)
hl = (high - low).astype(float)
hl_safe = hl.where(hl > 0, np.nan)
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
sell_frac = 1.0 - buy_frac
buy_vol = volume * buy_frac
sell_vol = volume * sell_frac
df["buy_vol"] = buy_vol
df["sell_vol"] = sell_vol
df["delta"] = buy_vol - sell_vol
# ---- A. 主动卖压衰竭(Long----
# 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低
df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean()
df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean()
sell_baseline = sell_vol.rolling(30, min_periods=10).mean()
df["sell_exhaust"] = (
(df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult)
& (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio)
& (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1))
)
# 主动买压衰竭(Short 对称)
df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean()
df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean()
buy_baseline = buy_vol.rolling(30, min_periods=10).mean()
df["buy_exhaust"] = (
(df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult)
& (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio)
& (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1))
)
# ---- B. Bid 吸收:成交卖量 / 价格跌幅 ----
# 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零)
px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0)
px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan)
sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum()
# absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大
df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace(
[np.inf, -np.inf], np.nan
).fillna(0.0)
# 价格几乎不跌但有大量卖出 → 吸收极强:给高分
flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median())
df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[
flat_sell.fillna(False), "absorb_ratio"
].clip(lower=self.min_absorb_ratio * 1.5)
# Ask 吸收(Short):买量 / 上涨幅度
px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0)
px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan)
buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum()
df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace(
[np.inf, -np.inf], np.nan
).fillna(0.0)
flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median())
df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[
flat_buy.fillna(False), "absorb_ratio_ask"
].clip(lower=self.min_absorb_ratio * 1.5)
df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio
df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio
# ---- 波动与 ATR ----
df["atr"] = ta.ATR(df, timeperiod=20)
df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
atr_med = df["atr_pct"].rolling(60, min_periods=20).median()
df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult)
df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"])
# ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)----
df["ema26_1m"] = ta.EMA(df, timeperiod=26)
df["ema26_slope"] = (
(df["ema26_1m"] - df["ema26_1m"].shift(5)) / close
).replace([np.inf, -np.inf], np.nan).fillna(0.0)
df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr
# 微结构“可做市”综合
df["mm_regime"] = df["atr_ok"] & (~df["trend_block"])
# 中价 / 伪价差
df["mid"] = (high + low) / 2.0
df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
return df
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
# Long:卖压衰竭 + Bid 吸收 + 非趋势
long_cond = (
df["mm_regime"]
& df["sell_exhaust"]
& df["bid_absorb"]
& (df["volume"] > 0)
# 额外:近端 delta 不再恶化(卖压减弱)
& (df["delta"] > df["delta"].shift(1))
)
# Short:买压衰竭 + Ask 吸收 + 非趋势
short_cond = (
df["mm_regime"]
& df["buy_exhaust"]
& df["ask_absorb"]
& (df["volume"] > 0)
& (df["delta"] < df["delta"].shift(1))
)
df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb")
df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb")
return df
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
"""信号层只做趋势禁做市强平;主出场交给 custom_exit。"""
df = dataframe
df["exit_long"] = 0
df["exit_short"] = 0
df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block")
df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block")
return df
# ------------------------------------------------------------------ #
# Maker 报价:Bid - depth ticks / Ask + depth ticks
# ------------------------------------------------------------------ #
def custom_entry_price(
self,
pair: str,
trade: Trade | None,
current_time: datetime,
proposed_rate: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
offset = self.maker_depth_ticks * self.tick_size
try:
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
bids = ob.get("bids") or []
asks = ob.get("asks") or []
if side == "long" and bids:
return max(float(bids[0][0]) - offset, self.tick_size)
if side == "short" and asks:
return float(asks[0][0]) + offset
except Exception as e:
logger.debug("v11 entry price ob fallback: %s", e)
# 回测:挂得更深,降低“虚假即时成交”概率(仍不完美)
if side == "long":
return proposed_rate - offset
return proposed_rate + offset
def custom_exit_price(
self,
pair: str,
trade: Trade,
current_time: datetime,
proposed_rate: float,
current_profit: float,
exit_tag: str | None,
**kwargs,
) -> float:
offset = 1 * self.tick_size
try:
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
bids = ob.get("bids") or []
asks = ob.get("asks") or []
# 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1
if trade.is_short and bids:
return float(bids[0][0]) + offset
if (not trade.is_short) and asks:
return float(asks[0][0]) - offset
except Exception as e:
logger.debug("v11 exit price ob fallback: %s", e)
if trade.is_short:
return proposed_rate - offset
return proposed_rate + offset
def leverage(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_leverage: float,
max_leverage: float,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> float:
return min(self.max_leverage, float(max_leverage))
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: float | None,
max_stake: float,
leverage: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
try:
if self.wallets:
free = self.wallets.get_free(self.config["stake_currency"])
stake = free * self.stake_pct
if min_stake:
stake = max(stake, min_stake)
return min(stake, max_stake)
except Exception:
pass
return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake
def _paused(self, current_time: datetime) -> bool:
if self._pause_until is None:
return False
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
until = (
self._pause_until
if self._pause_until.tzinfo
else self._pause_until.replace(tzinfo=timezone.utc)
)
return now < until
def _in_cooldown(self, current_time: datetime) -> bool:
if self._last_entry_time is None:
return False
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
last = (
self._last_entry_time
if self._last_entry_time.tzinfo
else self._last_entry_time.replace(tzinfo=timezone.utc)
)
return (now - last) < timedelta(minutes=self.cooldown_minutes)
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: str | None,
side: str,
**kwargs,
) -> bool:
if self._paused(current_time) or self._in_cooldown(current_time):
return False
# 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度)
try:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is not None and len(dataframe):
last = dataframe.iloc[-1]
if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))):
return False
if self.dp.runmode.value in ("live", "dry_run"):
ob = self.dp.orderbook(pair, self.ob_levels)
bids = ob.get("bids") or []
asks = ob.get("asks") or []
if not bids or not asks:
return False
# 简单吸收代理:同价位附近挂单厚度
bid_vol = sum(float(b[1]) for b in bids[:3])
ask_vol = sum(float(a[1]) for a in asks[:3])
if side == "long" and bid_vol < ask_vol * 0.8:
# Bid 不够厚,吸收叙事弱
return False
if side == "short" and ask_vol < bid_vol * 0.8:
return False
except Exception as e:
logger.debug("v11 confirm entry: %s", e)
self._last_entry_time = current_time
return True
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
):
open_time = trade.open_date_utc
if open_time.tzinfo is None:
open_time = open_time.replace(tzinfo=timezone.utc)
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
return "time_stop_5m"
# 优势恢复出场(替代固定 0.05% TP)
# long: 价格相对开仓上涨 edge_exit_pctshort: 下跌 edge_exit_pct
# current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳
entry = trade.open_rate
if not trade.is_short:
edge = (current_rate - entry) / entry
if edge >= self.edge_exit_pct:
return "spread_edge_restore"
if edge <= -self.adverse_exit_pct:
return "adverse_move"
else:
edge = (entry - current_rate) / entry
if edge >= self.edge_exit_pct:
return "spread_edge_restore"
if edge <= -self.adverse_exit_pct:
return "adverse_move"
# 重新进入趋势禁做市 → 立刻撤流动性
try:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is not None and len(dataframe):
if bool(dataframe.iloc[-1].get("trend_block", False)):
return "trend_block_exit"
except Exception:
pass
return None
def confirm_trade_exit(
self,
pair: str,
trade: Trade,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
exit_reason: str,
current_time: datetime,
**kwargs,
) -> bool:
try:
profit = trade.calc_profit_ratio(rate)
if profit < 0:
self._loss_streak += 1
if self._loss_streak >= self.consecutive_loss_limit:
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
self._loss_streak = 0
else:
self._loss_streak = 0
except Exception:
pass
return True
@property
def protections(self):
return [
{
"method": "CooldownPeriod",
"stop_duration_candles": int(self.cooldown_minutes),
},
{
"method": "StoplossGuard",
"lookback_period_candles": 60,
"trade_limit": self.consecutive_loss_limit,
"stop_duration_candles": self.pause_minutes,
"only_per_pair": True,
},
]
+8 -15
View File
@@ -77,7 +77,7 @@ class ChanLun_BTC_15(IStrategy):
trailing_only_offset_is_reached = False trailing_only_offset_is_reached = False
position_adjustment_enable = True position_adjustment_enable = True
startup_candle_count = 100 startup_candle_count = 1000
time5 = 5 time5 = 5
time15 = 15 time15 = 15
@@ -88,21 +88,14 @@ class ChanLun_BTC_15(IStrategy):
time5 = 1440 time5 = 1440
last_time = datetime.now() last_time = datetime.now()
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
tf_df_5 = TF_DF(dataframe, self.time5, '5m') df_5m = resample_to_interval(dataframe, self.time5)
tf_df_15 = TF_DF(dataframe, self.time15, '15m') df_15m = resample_to_interval(dataframe, self.time15)
tf_df_30 = TF_DF(dataframe, self.time30, '30m') dataframe = TF_DF.add_indicators(dataframe)
tf_df_60 = TF_DF(dataframe, self.time60, '60m') df_5m = TF_DF.add_indicators(df_5m)
tf_df_4h = TF_DF(dataframe, self.time4h, '4h') df_15m = TF_DF.add_indicators(df_15m)
tf_df_1d = TF_DF(dataframe, self.time1d, '1d')
dataframe = resampled_merge(dataframe, df_5m)
dataframe = resampled_merge(dataframe, tf_df_5.dataframe) dataframe = resampled_merge(dataframe, df_15m)
dataframe = resampled_merge(dataframe, tf_df_15.dataframe)
dataframe = resampled_merge(dataframe, tf_df_30.dataframe)
dataframe = resampled_merge(dataframe, tf_df_60.dataframe)
dataframe = resampled_merge(dataframe, tf_df_4h.dataframe)
dataframe = resampled_merge(dataframe, tf_df_1d.dataframe)
return dataframe return dataframe
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float, def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
+434
View File
@@ -0,0 +1,434 @@
"""
MakerEdgeProbe Freqtrade Dry-run 探针过渡用
正式 Maker / L2 / Edge 采集已迁移到
nautilus_mm/ NautilusTrader独立 .venv
本策略仍可用于 Freqtrade 侧对照新开发请走 nautilus_mm
运行 Nautilus
cd nautilus_mm && ./scripts/run_probe.sh
分析
cd nautilus_mm && ./scripts/analyze.sh
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import numpy as np
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.persistence import Trade, Order
from freqtrade.strategy import IStrategy
from maker_edge_logger import MakerEdgeLogger
logger = logging.getLogger(__name__)
class MakerEdgeProbe(IStrategy):
INTERFACE_VERSION = 3
timeframe = "1m"
can_short = True
process_only_new_candles = False
startup_candle_count = 60
minimal_roi = {"0": 0.01}
stoploss = -0.002
trailing_stop = False
use_exit_signal = False
order_types = {
"entry": "limit",
"exit": "limit",
"stoploss": "market",
"stoploss_on_exchange": False,
}
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
tick_size = 0.1
quote_depth_ticks = 1
max_leverage = 1.0
stake_pct = 0.003
max_hold_minutes = 5
edge_exit_pct = 0.0002
adverse_exit_pct = 0.0008
cooldown_minutes = 5
ob_levels = 10
trade_lookback = 100
ema_slope_thr = 0.0002
book_sample_every_sec = 2.0
_logger: MakerEdgeLogger | None = None
_last_mid: float | None = None
_last_book_sample: float = 0.0
_last_entry_time: Optional[datetime] = None
_recent_high: float = 0.0
_recent_low: float = 0.0
_pending_quote_id: Optional[str] = None
_fill_by_trade: dict[int, str] = {}
def bot_start(self, **kwargs) -> None:
self._logger = MakerEdgeLogger(levels=self.ob_levels)
self._fill_by_trade = {}
logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir)
def _get_logger(self) -> MakerEdgeLogger:
if self._logger is None:
self._logger = MakerEdgeLogger(levels=self.ob_levels)
return self._logger
def _fetch_trades(self, pair: str) -> list:
try:
ex = self.dp._exchange
if ex is None:
return []
api = getattr(ex, "_api", None) or getattr(ex, "api", None)
if api is None:
return []
return api.fetch_trades(pair, limit=self.trade_lookback) or []
except Exception as e:
logger.debug("fetch_trades failed: %s", e)
return []
def _inventory(self) -> float:
try:
inv = 0.0
for t in Trade.get_open_trades():
amt = float(t.amount or 0.0)
inv += -amt if t.is_short else amt
return inv
except Exception:
return 0.0
def _market_state(self, pair: str) -> dict:
state = {
"trend_state": "UNKNOWN",
"atr_pct": None,
"volatility_regime": "UNKNOWN",
"ema_slope": None,
}
try:
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or len(df) == 0:
return state
last = df.iloc[-1]
slope = float(last.get("ema_slope") or 0.0)
atr_pct = float(last.get("atr_pct") or 0.0)
state["ema_slope"] = slope
state["atr_pct"] = atr_pct
if bool(last.get("trend_block", False)):
state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN"
else:
state["trend_state"] = "RANGE"
# 波动分位代理
if "atr_pct" in df.columns:
med = float(df["atr_pct"].tail(60).median() or 0)
if atr_pct > med * 1.8:
state["volatility_regime"] = "HIGH"
elif atr_pct < med * 0.7:
state["volatility_regime"] = "LOW"
else:
state["volatility_regime"] = "NORMAL"
except Exception:
pass
return state
def _snapshot(self, pair: str):
ob = self.dp.orderbook(pair, self.ob_levels)
trades = self._fetch_trades(pair)
snap = MakerEdgeLogger.snapshot_from_orderbook(
ob,
levels=self.ob_levels,
recent_trades=trades,
last_mid=self._last_mid,
liq_proxy_low=self._recent_low or None,
liq_proxy_high=self._recent_high or None,
)
if snap.mid:
self._last_mid = snap.mid
return snap
def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
if self.dp.runmode.value not in ("live", "dry_run"):
return
pair = self.config["exchange"]["pair_whitelist"][0]
try:
snap = self._snapshot(pair)
tick = self.dp.ticker(pair) or {}
last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is not None and len(df):
self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last)
self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last)
lg = self._get_logger()
now = time.time()
# 盘口历史(成交前5s恶化检测依赖此)
if now - self._last_book_sample >= self.book_sample_every_sec:
self._last_book_sample = now
lg.record_book(snap, now=now)
if last:
lg.update_paths(pair, last, now=now)
except Exception as e:
logger.warning("bot_loop_start probe error: %s", e)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
close, high, low = df["close"], df["high"], df["low"]
volume = df["volume"].astype(float)
close_c = close.clip(lower=low, upper=high)
hl = (high - low).replace(0, np.nan)
buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1)
sell_vol = volume * (1.0 - buy_frac)
buy_vol = volume * buy_frac
df["sell_vol"] = sell_vol
df["buy_vol"] = buy_vol
df["delta"] = buy_vol - sell_vol
vol_ma = volume.rolling(20, min_periods=5).mean()
df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0)
df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0)
drop = (close.shift(3) - low).clip(lower=0) / close.shift(3)
up = (high - close.shift(3)).clip(lower=0) / close.shift(3)
df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace(
[np.inf, -np.inf], np.nan
).fillna(0)
df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace(
[np.inf, -np.inf], np.nan
).fillna(0)
df["ema26"] = ta.EMA(df, timeperiod=26)
df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0)
df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr
df["atr"] = ta.ATR(df, timeperiod=20)
df["atr_pct"] = (df["atr"] / close).fillna(0)
s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean()
df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1))
b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean()
df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1))
df["roll_high"] = high.rolling(60, min_periods=10).max()
df["roll_low"] = low.rolling(60, min_periods=10).min()
return df
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
long_c = (
(~df["trend_block"])
& df["shock_sell"].rolling(5).max().astype(bool)
& (df["de_sell"] > 10)
& df["sell_exhaust"]
)
short_c = (
(~df["trend_block"])
& df["shock_buy"].rolling(5).max().astype(bool)
& (df["de_buy"] > 10)
& df["buy_exhaust"]
)
df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp")
df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp")
return df
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
def custom_entry_price(
self,
pair: str,
trade: Trade | None,
current_time: datetime,
proposed_rate: float,
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
offset = self.quote_depth_ticks * self.tick_size
try:
snap = self._snapshot(pair)
price = snap.best_bid - offset if side == "long" else snap.best_ask + offset
state = self._market_state(pair)
qid = self._get_logger().create_quote(
pair=pair,
side="bid" if side == "long" else "ask",
quote_price=price,
inventory=self._inventory(),
snap=snap,
reason=entry_tag or "entry",
trade_id=trade.id if trade else None,
state=state,
)
self._pending_quote_id = qid
return price
except Exception as e:
logger.debug("custom_entry_price: %s", e)
return proposed_rate - offset if side == "long" else proposed_rate + offset
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: str | None,
side: str,
**kwargs,
) -> bool:
if self._last_entry_time:
last = self._last_entry_time
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
if now - last < timedelta(minutes=self.cooldown_minutes):
return False
try:
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)):
return False
snap = self._snapshot(pair)
if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7:
return False
if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7:
return False
except Exception:
pass
self._last_entry_time = current_time
return True
def check_entry_timeout(
self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs
) -> bool:
"""超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。"""
try:
snap = self._snapshot(pair)
self._get_logger().cancel_quote(
quote_id=self._pending_quote_id,
trade_id=trade.id,
reason="entry_timeout",
snap=snap,
)
except Exception as e:
logger.debug("cancel_quote on timeout: %s", e)
# False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True
return False
def order_filled(
self,
pair: str,
trade: Trade,
order: Order,
current_time: datetime,
**kwargs,
) -> None:
try:
lg = self._get_logger()
# 入场成交
if order.ft_order_side == trade.entry_side:
snap = self._snapshot(pair)
side = "short" if trade.is_short else "long"
# 粗分 fill_reasontime_to_fill 在 logger 内算;这里标 maker_hit
# 若成交前5s盘口已恶化 → toxic_passive 候选
det = lg.book_deterioration(side)
fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit"
if self._pending_quote_id:
lg.bind_trade(self._pending_quote_id, trade.id)
fill_id = lg.log_fill(
pair=pair,
side=side,
fill_price=float(order.safe_price or trade.open_rate),
amount=float(order.safe_filled or order.safe_amount or 0),
inventory=self._inventory(),
snap=snap,
order_type=str(getattr(order, "order_type", None) or "limit"),
quote_id=self._pending_quote_id,
trade_id=trade.id,
fill_reason=fill_reason,
state=self._market_state(pair),
extra={"entry_tag": trade.enter_tag},
)
self._fill_by_trade[trade.id] = fill_id
self._pending_quote_id = None
else:
# 出场:把 exit_reason 挂到入场 fill,供 H2
fill_id = self._fill_by_trade.get(trade.id)
reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit"
if fill_id:
lg.attach_exit_reason(fill_id, str(reason))
except Exception as e:
logger.warning("order_filled log error: %s", e)
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
):
open_time = trade.open_date_utc
if open_time.tzinfo is None:
open_time = open_time.replace(tzinfo=timezone.utc)
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
return "probe_time"
entry = trade.open_rate
edge = (
(current_rate - entry) / entry
if not trade.is_short
else (entry - current_rate) / entry
)
if edge >= self.edge_exit_pct:
return "probe_edge_restore"
if edge <= -self.adverse_exit_pct:
return "probe_adverse"
# 趋势切换 → 撤流动性思维
try:
st = self._market_state(pair)
if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"):
# 持仓方向与趋势相反时更危险
if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or (
trade.is_short and st["trend_state"] == "TREND_UP"
):
return "probe_trend_cancel"
except Exception:
pass
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.max_leverage, float(max_leverage))
def custom_stake_amount(
self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: Optional[float], max_stake: float,
leverage: float, entry_tag: Optional[str], side: str, **kwargs,
) -> float:
try:
if self.wallets:
free = self.wallets.get_free(self.config["stake_currency"])
stake = free * self.stake_pct
if min_stake:
stake = max(stake, min_stake)
return min(stake, max_stake)
except Exception:
pass
return min(proposed_stake * self.stake_pct, max_stake)
+449
View File
@@ -0,0 +1,449 @@
# --- Do not remove these libs ---
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, stoploss_from_absolute
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade trade -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies
# freqtrade backtesting -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies --timerange=20251201-
# freqtrade download-data -c ./user_data/Chan/config/Turtle_BTC.json -t 15m --pairs BTC/USDT:USDT --timerange=20240101-
class Turtle_BTC(IStrategy):
"""
海龟交易法 (Turtle Trading) - 15m 优化版
相对经典日线参数15m 上做了适配
- 通道周期拉长 1 / 2降低噪音假突破
- EMA200 趋势过滤只做顺势方向
- ADX 过滤只在有趋势时开仓
- 突破用向上/向下穿越避免通道内反复信号
- 单单元保证金上限避免低波动时仓位占满账户
- 系统2 优先系统1 补漏S1 带赢利跳过过滤
- trade_side 可限制只做多/只做空默认 short适配近段下跌市
"""
INTERFACE_VERSION = 3
timeframe = "15m"
can_short = True
process_only_new_candles = True
# 需覆盖 S2 入场周期 + EMA200
startup_candle_count = 250
minimal_roi = {"0": 100}
stoploss = -0.99
use_custom_stoploss = True
trailing_stop = False
use_exit_signal = False
exit_profit_only = False
ignore_roi_if_entry_signal = True
position_adjustment_enable = True
max_entry_position_adjustment = 3 # 首仓 + 3 加仓 = 4 单元
# ---- 15m 适配后的默认周期(约 1日 / 2日)----
# 96 根 15m ≈ 1 天;192 根 ≈ 2 天
entry_period_s1 = IntParameter(48, 144, default=96, space="buy", optimize=True)
exit_period_s1 = IntParameter(24, 96, default=48, space="sell", optimize=True)
entry_period_s2 = IntParameter(120, 288, default=192, space="buy", optimize=True)
exit_period_s2 = IntParameter(48, 144, default=96, space="sell", optimize=True)
atr_period = IntParameter(14, 40, default=20, space="buy", optimize=False)
stop_atr_mult = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="sell", optimize=True)
pyramid_atr_mult = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
risk_per_unit = DecimalParameter(0.005, 0.02, default=0.01, decimals=3, space="buy", optimize=False)
adx_threshold = IntParameter(15, 35, default=20, space="buy", optimize=True)
# 单单元保证金占可用资金上限(防止 15m 低波动时打满仓)
max_unit_stake_pct = DecimalParameter(0.15, 0.40, default=0.25, decimals=2, space="buy", optimize=False)
lev = 1.0
use_s1_win_skip = True
use_system1 = True
use_system2 = True
# 趋势 / 强度过滤
use_ema_filter = True
use_adx_filter = True
# None=双向;可用 "long" / "short" 限制单边(勿用单段行情曲线拟合)
trade_side: Optional[str] = None
ema_period = 200
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
ep1 = int(self.entry_period_s1.value)
xp1 = int(self.exit_period_s1.value)
ep2 = int(self.entry_period_s2.value)
xp2 = int(self.exit_period_s2.value)
atr_n = int(self.atr_period.value)
dataframe["atr"] = ta.ATR(dataframe, timeperiod=atr_n)
dataframe["n"] = dataframe["atr"]
dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.ema_period)
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
# 唐奇安通道(shift 1 防 lookahead
dataframe["dc_high_s1"] = dataframe["high"].rolling(ep1).max().shift(1)
dataframe["dc_low_s1"] = dataframe["low"].rolling(ep1).min().shift(1)
dataframe["dc_exit_high_s1"] = dataframe["high"].rolling(xp1).max().shift(1)
dataframe["dc_exit_low_s1"] = dataframe["low"].rolling(xp1).min().shift(1)
dataframe["dc_high_s2"] = dataframe["high"].rolling(ep2).max().shift(1)
dataframe["dc_low_s2"] = dataframe["low"].rolling(ep2).min().shift(1)
dataframe["dc_exit_high_s2"] = dataframe["high"].rolling(xp2).max().shift(1)
dataframe["dc_exit_low_s2"] = dataframe["low"].rolling(xp2).min().shift(1)
# 穿越突破(只在刚突破那根触发)
dataframe["break_up_s1"] = (
(dataframe["close"] > dataframe["dc_high_s1"])
& (dataframe["close"].shift(1) <= dataframe["dc_high_s1"].shift(1))
)
dataframe["break_dn_s1"] = (
(dataframe["close"] < dataframe["dc_low_s1"])
& (dataframe["close"].shift(1) >= dataframe["dc_low_s1"].shift(1))
)
dataframe["break_up_s2"] = (
(dataframe["close"] > dataframe["dc_high_s2"])
& (dataframe["close"].shift(1) <= dataframe["dc_high_s2"].shift(1))
)
dataframe["break_dn_s2"] = (
(dataframe["close"] < dataframe["dc_low_s2"])
& (dataframe["close"].shift(1) >= dataframe["dc_low_s2"].shift(1))
)
# 顺势过滤:价格相对 EMA200
dataframe["trend_long"] = dataframe["close"] > dataframe["ema_trend"]
dataframe["trend_short"] = dataframe["close"] < dataframe["ema_trend"]
dataframe["adx_ok"] = dataframe["adx"] >= float(self.adx_threshold.value)
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * 0.8
if self.use_s1_win_skip:
dataframe["skip_s1_long"] = self._s1_skip_mask(
dataframe, long=True, exit_col="dc_exit_low_s1"
)
dataframe["skip_s1_short"] = self._s1_skip_mask(
dataframe, long=False, exit_col="dc_exit_high_s1"
)
else:
dataframe["skip_s1_long"] = False
dataframe["skip_s1_short"] = False
return dataframe
@staticmethod
def _s1_skip_mask(dataframe: DataFrame, long: bool, exit_col: str) -> pd.Series:
"""系统1:上次同向突破盈利则跳过下一次。"""
n = len(dataframe)
skip = np.zeros(n, dtype=bool)
in_trade = False
entry_price = 0.0
last_was_win = False
closes = dataframe["close"].to_numpy()
breaks = (dataframe["break_up_s1"] if long else dataframe["break_dn_s1"]).fillna(False).to_numpy()
exits = dataframe[exit_col].to_numpy()
for i in range(n):
if np.isnan(exits[i]) or np.isnan(closes[i]):
continue
if in_trade:
hit_exit = closes[i] < exits[i] if long else closes[i] > exits[i]
if hit_exit:
pnl = (closes[i] - entry_price) if long else (entry_price - closes[i])
last_was_win = pnl > 0
in_trade = False
elif breaks[i]:
if last_was_win:
skip[i] = True
last_was_win = False
else:
in_trade = True
entry_price = closes[i]
return pd.Series(skip, index=dataframe.index)
def _entry_filters(self, dataframe: DataFrame, long: bool) -> pd.Series:
base = (
(dataframe["volume"] > 0)
& dataframe["atr"].notna()
& (dataframe["atr"] > 0)
& dataframe["vol_ok"]
)
if self.use_ema_filter:
base &= dataframe["trend_long"] if long else dataframe["trend_short"]
if self.use_adx_filter:
base &= dataframe["adx_ok"]
return base
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
allow_long = self.trade_side in (None, "long")
allow_short = self.trade_side in (None, "short")
long_base = self._entry_filters(dataframe, long=True) if allow_long else False
short_base = self._entry_filters(dataframe, long=False) if allow_short else False
# 系统2优先(更稳),系统1补漏
if self.use_system2:
if allow_long:
long_s2 = long_base & dataframe["break_up_s2"]
dataframe.loc[long_s2, ["enter_long", "enter_tag"]] = (1, "turtle_s2_long")
if allow_short:
short_s2 = short_base & dataframe["break_dn_s2"]
dataframe.loc[short_s2, ["enter_short", "enter_tag"]] = (1, "turtle_s2_short")
if self.use_system1:
if allow_long:
long_s1 = (
long_base & dataframe["break_up_s1"]
& (~dataframe["skip_s1_long"])
& (dataframe["enter_long"] != 1)
)
dataframe.loc[long_s1, ["enter_long", "enter_tag"]] = (1, "turtle_s1_long")
if allow_short:
short_s1 = (
short_base & dataframe["break_dn_s1"]
& (~dataframe["skip_s1_short"])
& (dataframe["enter_short"] != 1)
)
dataframe.loc[short_s1, ["enter_short", "enter_tag"]] = (1, "turtle_s1_short")
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> Optional[str]:
"""按入场系统使用对应退出通道;用 close 与 current_rate 双确认。"""
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
tag = trade.enter_tag or ""
price = min(float(last["close"]), current_rate) if not trade.is_short else max(float(last["close"]), current_rate)
if trade.is_short:
if "s1" in tag and price > float(last["dc_exit_high_s1"]):
return "turtle_s1_exit"
if "s2" in tag and price > float(last["dc_exit_high_s2"]):
return "turtle_s2_exit"
else:
if "s1" in tag and price < float(last["dc_exit_low_s1"]):
return "turtle_s1_exit"
if "s2" in tag and price < float(last["dc_exit_low_s2"]):
return "turtle_s2_exit"
return None
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: Optional[float],
max_stake: float,
leverage: float,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> float:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return proposed_stake
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or current_rate <= 0:
return proposed_stake
wallets = self.wallets
available = wallets.get_total(self.config["stake_currency"]) if wallets else max_stake
risk_amount = available * float(self.risk_per_unit.value)
stop_dist = float(self.stop_atr_mult.value) * atr
notional = risk_amount * current_rate / stop_dist
stake = notional / max(leverage, 1.0)
# 单单元上限,避免低波动打满仓
stake = min(stake, available * float(self.max_unit_stake_pct.value))
if min_stake is not None:
stake = max(stake, min_stake)
stake = min(stake, max_stake)
return stake
def adjust_trade_position(
self,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
min_stake: Optional[float],
max_stake: float,
current_entry_rate: float,
current_exit_rate: float,
current_entry_profit: float,
current_exit_profit: float,
**kwargs,
):
"""每朝有利方向 0.5N 加仓,最多 4 单元;有挂单时不加。"""
if trade.has_open_orders:
return None
if trade.nr_of_successful_entries >= (1 + self.max_entry_position_adjustment):
return None
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0:
return None
entry_n = trade.get_custom_data("entry_n")
if entry_n is None:
entry_n = atr
trade.set_custom_data("entry_n", entry_n)
last_entry_price = trade.get_custom_data("last_entry_price")
if last_entry_price is None:
last_entry_price = trade.open_rate
trade.set_custom_data("last_entry_price", last_entry_price)
# 已规划的下一单元序号(从第 2 单元起)
next_unit = trade.nr_of_successful_entries + 1
step = float(self.pyramid_atr_mult.value) * float(entry_n)
# 相对首仓(或记录的单元锚定价)计算阈值,避免 after_fill 用均价漂移
anchor = float(trade.get_custom_data("unit1_price") or trade.open_rate)
# 第 n 单元触发价 = 首仓 ± (n-1)*0.5N
offset = (next_unit - 1) * step
if trade.is_short:
trigger = anchor - offset
if current_rate > trigger:
return None
else:
trigger = anchor + offset
if current_rate < trigger:
return None
stake = self.custom_stake_amount(
pair=trade.pair,
current_time=current_time,
current_rate=current_rate,
proposed_stake=max_stake,
min_stake=min_stake,
max_stake=max_stake,
leverage=trade.leverage,
entry_tag=trade.enter_tag,
side="short" if trade.is_short else "long",
)
if stake <= 0:
return None
return stake, f"turtle_pyramid_{next_unit}"
def custom_stoploss(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
after_fill: bool,
**kwargs,
) -> Optional[float]:
"""
止损 = 最近一单元入场价 ± 2N
加仓后整体移到新单元的 2N海龟原版
"""
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if after_fill:
filled = trade.nr_of_successful_entries
if filled <= 1:
trade.set_custom_data("unit1_price", current_rate)
trade.set_custom_data("last_entry_price", current_rate)
if atr > 0:
trade.set_custom_data("entry_n", atr)
else:
# 加仓:用本次成交价作为最新单元锚点
trade.set_custom_data("last_entry_price", current_rate)
entry_n = trade.get_custom_data("entry_n")
n = float(entry_n) if entry_n is not None else atr
if n <= 0:
return None
last_entry = trade.get_custom_data("last_entry_price") or trade.open_rate
mult = float(self.stop_atr_mult.value)
if trade.is_short:
stop_price = float(last_entry) + mult * n
else:
stop_price = float(last_entry) - mult * n
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
# 0 表示止损已在价格不利侧之外,保持不变
return sl if sl > 0 else None
def confirm_trade_entry(
self,
pair: str,
order_type: str,
amount: float,
rate: float,
time_in_force: str,
current_time: datetime,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> bool:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return False
row = dataframe.iloc[-1]
if pd.isna(row["atr"]) or row["atr"] <= 0:
return False
if self.trade_side is not None and side != self.trade_side:
return False
if self.use_ema_filter:
if side == "long" and not bool(row["trend_long"]):
return False
if side == "short" and not bool(row["trend_short"]):
return False
if self.use_adx_filter and not bool(row["adx_ok"]):
return False
return True
def leverage(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_leverage: float,
max_leverage: float,
entry_tag: Optional[str],
side: str,
**kwargs,
) -> float:
return min(self.lev, max_leverage)
+368
View File
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE FROZEN
Status: BASELINE FROZEN (live alias of V1_BASELINE)
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究禁止并入本文件调参
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC.json \
# --strategy Wyckoff_BTC --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC(IStrategy):
"""Live alias of V1_BASELINE — 改规则请复制新文件,勿直接改 Baseline。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_SPRING"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)
+197
View File
@@ -0,0 +1,197 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC Market-State Gated SpringDecision Layer
Spring = V1_BASELINEFROZEN
Gate v1.1 = LOCKED default Decision rule:
market_state in {accumulation, markup} -> allow Spring
else -> block
Soft-score 不进默认规则勿改 Spring勿全样本扫 Gate
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from pandas import DataFrame
import pandas as pd
_CHAN = Path(__file__).resolve().parents[1]
if str(_CHAN) not in sys.path:
sys.path.insert(0, str(_CHAN))
from engine.market_state import apply_decision_gate, compute_market_state_8h # noqa: E402
from freqtrade.strategy import merge_informative_pair # noqa: E402
from Wyckoff_BTC_V1_BASELINE import Wyckoff_BTC_V1_BASELINE # noqa: E402
logger = logging.getLogger(__name__)
class Wyckoff_BTC_GATED(Wyckoff_BTC_V1_BASELINE):
"""Baseline Spring + causal Market State Gate。"""
STRATEGY_VERSION = "GATED_V1_1_LOCKED"
SETUP_FAMILY = "SPRING_GATED"
# LOCKED default — 研究脚本可临时改写,跑完必须恢复
gate_mode: str = "state_set"
gate_q_sum: float = 100.0
gate_q_bad: float = 55.0
decision_log_enabled: bool = True
decision_log_path: str = str(_CHAN / "logs" / "wyckoff_decision_events.jsonl")
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_indicators(dataframe, metadata)
pair = metadata["pair"]
btf = self.bias_timeframe or "8h"
raw8 = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
st8 = compute_market_state_8h(raw8)
# 覆盖默认门闩为当前 class 配置(可能已被脚本锁定)
st8 = apply_decision_gate(
st8,
mode=str(self.gate_mode),
q_sum=float(self.gate_q_sum),
q_bad=float(self.gate_q_bad),
)
keep = [
"date",
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
"market_state",
"allow_spring",
"allow_utad",
"ema_slope",
"dist_ema200",
]
st8 = st8[[c for c in keep if c in st8.columns]].copy()
dataframe = merge_informative_pair(dataframe, st8, self.timeframe, btf, ffill=True)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_entry_trend(dataframe, metadata)
bs = f"_{self.bias_timeframe or '8h'}"
allow_s = dataframe.get(f"allow_spring{bs}")
allow_u = dataframe.get(f"allow_utad{bs}")
if allow_s is None or allow_u is None:
return dataframe
allow_s = allow_s.fillna(False).astype(bool)
allow_u = allow_u.fillna(False).astype(bool)
block_long = (dataframe["enter_long"] == 1) & (~allow_s)
block_short = (dataframe["enter_short"] == 1) & (~allow_u)
self._log_decision_events(dataframe, metadata, allow_s, allow_u, bs)
dataframe.loc[block_long, ["enter_long", "enter_tag"]] = (0, "")
dataframe.loc[block_short, ["enter_short", "enter_tag"]] = (0, "")
return dataframe
def _decision_log_active(self) -> bool:
if not bool(getattr(self, "decision_log_enabled", True)):
return False
config = getattr(self, "config", {}) or {}
runmode = config.get("runmode")
runmode_value = getattr(runmode, "value", str(runmode) if runmode is not None else "")
if runmode_value:
return runmode_value == "dry_run"
return bool(config.get("dry_run", False))
def _log_decision_events(
self,
dataframe: DataFrame,
metadata: dict,
allow_s: pd.Series,
allow_u: pd.Series,
bias_suffix: str,
) -> None:
if not self._decision_log_active():
return
pair = metadata.get("pair", "")
long_candidates = dataframe["enter_long"] == 1
short_candidates = dataframe["enter_short"] == 1
if not bool(long_candidates.any() or short_candidates.any()):
return
seen = getattr(self, "_decision_log_seen", None)
if seen is None:
seen = set()
self._decision_log_seen = seen
events = []
for idx in dataframe.index[long_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "SPRING_LONG", bool(allow_s.loc[idx]), bias_suffix))
for idx in dataframe.index[short_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "UTAD_SHORT", bool(allow_u.loc[idx]), bias_suffix))
path = Path(str(getattr(self, "decision_log_path", ""))).expanduser()
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for event in events:
key = (
event["timestamp"],
event["pair"],
event["signal_type"],
event["gate_version"],
)
if key in seen:
continue
seen.add(key)
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
except OSError as exc:
logger.warning("Decision log write failed: %s", exc)
def _decision_event(self, row: pd.Series, pair: str, signal_type: str, allow: bool, bias_suffix: str) -> dict:
state_col = f"market_state{bias_suffix}"
bias_time_col = f"date{bias_suffix}"
state = self._json_value(row.get(state_col))
bias_bar_time = self._json_value(row.get(bias_time_col))
state_missing = state in (None, "", "missing")
block_reason = "" if allow else ("state_missing" if state_missing else "not_in_allow_set")
event = {
"timestamp": self._json_value(row.get("date")),
"pair": pair,
"signal_type": signal_type,
"market_state": state if not state_missing else "missing",
"allow": bool(allow),
"gate_version": self.STRATEGY_VERSION,
"baseline_signal": signal_type,
"block_reason": block_reason,
"bias_bar_time": bias_bar_time,
"would_enter": True,
"order_sent": bool(allow),
}
for score in [
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
]:
event[score] = self._json_value(row.get(f"{score}{bias_suffix}"))
return event
@staticmethod
def _json_value(value):
if value is None:
return None
try:
if pd.isna(value):
return None
except (TypeError, ValueError):
pass
if hasattr(value, "isoformat"):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
+42
View File
@@ -0,0 +1,42 @@
# --- Do not remove these libs ---
"""
ARCHIVED LPS 研究分支已冻结禁止用于 dry-run / 生产
:
user_data/Chan/research/SYSTEM_STATUS.md
user_data/Chan/research/lps_v1_failed/REJECT.md
user_data/Chan/research/lps_v1_1_failed/REJECT.md
user_data/Chan/research/lps_v2_failed/REJECT.md
user_data/Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py
Baseline: Wyckoff_BTC_V1_BASELINESpring-only
"""
from freqtrade.strategy import IStrategy
from pandas import DataFrame
class Wyckoff_BTC_LPS(IStrategy):
"""Stub: LPS archived. Use Wyckoff_BTC_V1_BASELINE."""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "ARCHIVED"
timeframe = "1h"
can_short = True
startup_candle_count = 20
minimal_roi = {"0": 1}
stoploss = -0.99
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
raise RuntimeError(
"LPS research archived (V1/V1.1/V2 all REJECTED). "
"Use Wyckoff_BTC_V1_BASELINE. See user_data/Chan/research/"
)
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
+368
View File
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE FROZEN
Status: BASELINE FROZEN
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究禁止并入本文件调参
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_V1_BASELINE(IStrategy):
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_BASELINE"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)

Some files were not shown because too many files have changed in this diff Show More