13 Commits
Author SHA1 Message Date
jackyu66git 97aa61705d fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:17 +08:00
jackyu66gitandCursor 340676bfbd fix(web): 分型框竖边 canvas 绘制,换币对强制全量刷新
LWC 折线无法画真竖线;增量刷新时用坐标采样补刷竖边,避免与横边脱节。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:32:18 +08:00
jackyu66gitandCursor 9cf625c413 fix(web): 小周期切换时对齐标记,避免 LWC Value is null
主周期笔/KLC 分型标记在切到 1m/2m 主图时未对齐 K 线 time;过滤均线无效点并钳制视窗恢复。顺带统一 BI 中枢计算路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:06:31 +08:00
jackyu66gitandCursor 18a7f485e6 feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:45:40 +08:00
jackyu66gitandCursor 0f6eb92a1f test(ECR-009): 补页面/API 路由冒烟与 TEST_REPORT
交付前缺 Flask 常驻与路由断言;现补齐 pytest 与报告。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:03:37 +08:00
jackyu66gitandCursor 9880e236a5 docs(ECR-009): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:46:35 +08:00
jackyu66gitandCursor ec08de098e feat(ECR-009): Crypto Wyckoff Screener 独立页(D/W/M)
移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:46:35 +08:00
jackyu66gitandCursor 6c627f009a docs(ECR-008): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor dbb6202325 feat(ECR-008): 拆分主站 chart_tv.js 为多模块薄门面
行为冻结物理拆分;保留 initTradingView/dispose 对外 API;无打包器。node --check 全绿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor efad2bb333 docs(ECR-007): archive LOOP-RUN-005 and sync STATE
关门收尾:归档 loop/gate 产物至 docs/runs,同步 CURRENT/MEMORY/PROFILE,并忽略工作目录 .gates/loop。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:02:54 +08:00
jackyu66gitandCursor 2964d6f230 docs(ECR-007): mark LOOP-RUN-005 DONE after Final Approval
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:20:39 +08:00
jackyu66gitandCursor 7991a6b2bf docs(ECR-007): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
jackyu66gitandCursor 276481e02c feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live
in engine; Summary partition; execution_signal source=confirmed only.
Keep strategies untouched; do not lower Confirmed thresholds for Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
113 changed files with 2805 additions and 10605 deletions
+7
View File
@@ -40,3 +40,10 @@ feature_meta
.DS_Store
data_provider/._config.json
.gstack/
# ESS gate / engineering-loop working dirs(归档进 docs/runs/
.gates/
loop/
# Crypto Wyckoff Screener local cache
data/crypto_wyckoff/
-171
View File
@@ -1,171 +0,0 @@
"""增量更新:新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)
+36 -40
View File
@@ -86,8 +86,7 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.UNKNOWN
def check_fx(self, klc):
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
if klc.pre and klc.next and klc.next.end_klu is not None:
if klc.pre and klc.next:
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:
klc.set_fx(Chan_FX_TYPE.TOP)
@@ -172,43 +171,6 @@ class KlineBuilderMixin:
def get_kl_data(self, dataframe: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):
klc_list = []
last_klu = None
@@ -236,7 +198,41 @@ class KlineBuilderMixin:
ema_down_list.append(ema_down_count)
#print(last_klu.time, ema_down_count, "DOWN END")
ema_down_count = 0
self._push_klu_into_klc_list(klc_list, klu, last_klu)
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()
#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
klc_list = self.cal_trend(klc_list)
#print(ema52_up_list, ema52_down_list)
+6 -11
View File
@@ -355,12 +355,9 @@ class ZsBuilderMixin:
return bi_zs_list
def get_zs_range(bis):
bis_list = bis[0:3]
zg = min(bi.high for bi in bis_list)
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
zg = min(bi.high for bi in bis)
zd = max(bi.low for bi in bis)
return zg, zd
def is_bi_overlap_range(bi, zg, zd):
return bi.high >= zd and bi.low <= zg
@@ -378,8 +375,8 @@ class ZsBuilderMixin:
zs.bi_list = list(bis)
for bi in zs.bi_list:
bi.set_bi_zs(zs)
#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_gg(max(bi.high for bi in zs.bi_list))
zs.set_dd(min(bi.low for bi in zs.bi_list))
zs.classify_zs()
last_zs = None
@@ -397,7 +394,7 @@ class ZsBuilderMixin:
start_idx += 1
continue
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
zg, zd = get_zs_range([bi1, bi2, bi3])
if zg <= zd:
start_idx += 1
continue
@@ -423,8 +420,6 @@ class ZsBuilderMixin:
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_dd(dd)
zs.set_gg(gg)
set_zs_bi_list(zs, bis_for_zs)
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
-9
View File
@@ -178,15 +178,6 @@ class ChanLun():
def cal_bi_zs_list(self, bi_list):
#return self.tf_df.cal_bi_zs(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):
return self.tf_df.get_bi_zs_list(bi_list)
def get_decimal(self, value):
+1 -4
View File
@@ -31,13 +31,12 @@ from chanlun.core.ChanZS import ChanZS, ChanZS_Big
from chanlun.indicators.ChanMACD import ChanMACD
from chanlun.pipeline.builders.bi import BiBuilderMixin
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.kline import KlineBuilderMixin
from chanlun.pipeline.builders.seg import SegBuilderMixin
from chanlun.pipeline.builders.zs import ZsBuilderMixin
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin):
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin):
def __init__(self, df=None, interval=0, timeframe=None):
if df is not None:
self.init_TF_DF(df, interval, timeframe)
@@ -60,14 +59,12 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
self.klc_list = []
self.bi_list = []
self.zs_list = []
self.bi_zs_list = []
self.bsp_list = []
self.seg_list = []
self.klc_fx_list = []
self.klu_list = self.cal_kl_data(self.dataframe)
self.klc_list = self.get_klc_list(self.klu_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.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
self.big_zs_list = self.get_big_zs_list(self.zs_list)
-1
View File
@@ -1 +0,0 @@
from __future__ import annotations
-141
View File
@@ -1,141 +0,0 @@
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
@@ -1,98 +0,0 @@
{
"$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
@@ -1,98 +0,0 @@
{
"$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
}
}
+2 -9
View File
@@ -39,15 +39,8 @@
"name": "binance",
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
"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"
},
"ccxt_config": {},
"ccxt_async_config": {},
"pair_whitelist": [
"BTC/USDT:USDT"
],
-91
View File
@@ -1,91 +0,0 @@
{
"$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
@@ -1,86 +0,0 @@
{
"$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
@@ -1,86 +0,0 @@
{
"$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
@@ -1,86 +0,0 @@
{
"$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
@@ -1,86 +0,0 @@
{
"$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
@@ -1,86 +0,0 @@
{
"$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
}
}
+10 -3
View File
@@ -22,18 +22,25 @@
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
- ECR-004 ReviewedTR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
- ECR-007 Final Approval / `276481e`Wyckoff Live Structure`live.py`);Confirmed ≠ Liveexecution 仅 confirmed
- ECR-008 Reviewed:主站 `chart_tv.js``chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面
- ECR-009 Implementing`/wyckoff_crypto` 独立选股页(`crypto_wyckoff/`);D/W + 本地月线;60s tip
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
## 硬约束提醒
- `/api/analyze` 字段可增不可删
- 无 ADR 不改笔/段/中枢/买卖点语义
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
- 交易 L2+ → RISK_REVIEW + EXPLive 须 Human
- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎
- Live candidate **不得**进入 execution交易 L2+ → RISK_REVIEW + EXPLive 须 Human
## 已知债务
- `chart_tv.js` 单体巨大 → 后续可选 ECR
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
- 内存泄漏尚无自动化 heap/监听断言
- `macd_config` POST 写本地 global 的历史 quirks(未改)
- 威科夫启发式参数未做 UI 调参
- ECR-007 待 Human 在 Gitea 开 PR 合入 `dev`
- `chart_tv_overlays.js` 仍偏大,可后续再拆
- ECR-009:月线历史受日线深度限制;Cycle 规则在 crypto 上可能偏 Unknown,看效果再调参
@@ -0,0 +1,70 @@
# 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.
+21
View File
@@ -1,5 +1,25 @@
# CHANGELOG
## Unreleased — 2026-08-07
### ECR-009L2,进行中)
- 独立页 `/wyckoff_crypto`:移植 A_Share_DP D/W/M 威科夫选股引擎至数字货币
- 本地 `data/crypto_wyckoff/`60s tip;月线由日线 UTC 自然月聚合(provider 无 1M
- API`/api/wyckoff_crypto/*`;不碰主站 analyze / 缠论叠层
### ECR-008L3Reviewed
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
### 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
### ECR-004L2Reviewed
@@ -7,6 +27,7 @@
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
- 主站 VP Top-8 + bins≤24;填充线减负
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
### ECR-003L2Reviewed
+25
View File
@@ -0,0 +1,25 @@
# CODE_REVIEW — ECR-008
**Role:** REVIEWER
**Date:** 2026-08-07
**Scope:** chart_tv 物理拆分
**Decision:** Approve
## Checklist
| Item | Result | Notes |
|------|--------|-------|
| 行为冻结(仅搬移) | PASS | ctx 编排;无绘制算法改写意图 |
| 对外 API | PASS | `initTradingView` / `disposeTradingViewCharts` 保留 |
| Forbidden | PASS | 无 Vite/TS;无 strategies/config;无 analyze 契约改动 |
| script 顺序 | PASS | lifecycle→shell→indicators→chan→overlays→finalize→门面→sync |
| 测试证据 | PASS | `node --check` ALL_CHECK_OK |
## Findings
1. **Low** 浏览器硬刷新冒烟仍建议 Human 点一次(自动刷新 + Cycle Summary)。不挡 Approve。
2. **Low** `chart_tv_overlays.js` 仍偏大(~2.3k 行);可后续再拆,非本 ECR 范围。
## Decision
**Approve**
@@ -0,0 +1,60 @@
# 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
+61
View File
@@ -0,0 +1,61 @@
# ECR-008
**Title:** 拆分主站巨型 `chart_tv.js`(行为冻结)
**Status:** Done (Reviewed)
**Date:** 2026-08-07
**Change Level:** L3(结构重构;行为冻结)
## Change
`web/static/js/app/chart_tv.js`(≈4700 行)按职责拆为多个无打包 script;薄门面保留 `initTradingView` / `disposeTradingViewCharts``ui.js` 调用。
## Motivation
ECR-001/002 CODE_REVIEW 非阻断债务;威科夫与 Live 叠层继续堆入单体,审阅与回归成本上升。
## Scope
### Allowed
- 新增:`chart_tv_lifecycle.js` / `chart_tv_shell.js` / `chart_tv_indicators.js` / `chart_tv_chan.js` / `chart_tv_overlays.js` / `chart_tv_finalize.js`
- `chart_tv.js` 改为编排门面;`index.html` 调整 script 顺序与 cache bust
- `node --check`;主站手动冒烟
### Forbidden
- Vite / React / TS 构建流水线
- 修改笔 / 线段 / 中枢 / 买卖点算法语义或绘制语义(仅搬移)
- 破坏 `/api/analyze` JSON 字段
- 修改 `config/` / `strategies/`
- 为主站重新引入 WebSocket 实时
## Risk
| Risk | Mitigation |
|------|------------|
| 拆分漏变量 / 作用域错误 | ctx 显式传参;冒烟 dispose + 三周期元素 + 威科夫 |
| script 顺序错误 | index.html 固定 lifecycle→…→门面→sync |
| 缓存旧单体 | bump `?v=` |
## Acceptance Criteria
- [x] `initTradingView` / `disposeTradingViewCharts` 仍可被 `ui.js` 调用
- [x] 自动刷新 dispose 路径保留(含 Cycle Summary 节点保全)
- [x] 主/次/次次 笔段中枢、买卖点、威科夫、ChanMACD 开关行为与拆前一致(搬移;浏览器目测待 Human)
- [x] `node --check` 全部相关 JS PASS
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY / CODE_REVIEW
## Rollback
`git revert` 本 ECR 提交;可恢复单文件 `chart_tv.js`
## Risk Review
N/A(不改交易决策语义)
## Linked
- IDEA: `docs/IDEA/IDEA-006-chart-tv-split.md`
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md`
- HANDOFF: `docs/HANDOFF/ECR-008-architect-to-engineer.md`
- TRACEABILITY: Yes
@@ -0,0 +1,25 @@
# ECR-009
**Title:** Crypto Wyckoff Screener 独立页(D/W/M
**Status:** Implementing
**Date:** 2026-08-07
**Change Level:** L2
## Change
新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存 K 线;60s tip 更新。
周期组合:内置 `8h/4h/1h`(默认)与 `1d/1w/1M`;UI 下拉切换;可添加自定义高/中/低组合(规则引擎仍按 D/W/M 角色映射)。
## Forbidden
- 改缠论算法、主站叠层、`/api/analyze``config/`/`strategies/`
- 自动下单
## Acceptance
- [ ] 页面可列出扫描结果(decision/cycle/phase/event
- [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan
- [ ] 调度可跑 tip 更新
- [ ] Decision 门闩单测通过
- [ ] 下拉可选 `8h/4h/1h`,可添加新组合
@@ -0,0 +1,26 @@
# 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.
@@ -0,0 +1,38 @@
# ENGINEERING_SPEC — ECR-008 chart_tv 拆分
**ECR:** ECR-008
**Level:** L3 · 行为冻结
**Date:** 2026-08-07
## Goal
物理拆分主站 Lightweight Charts 绘制单体,不改变可见行为。
## Module map
| File | Responsibility |
|------|----------------|
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts`cleanup 数组与 chart.remove |
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)`:容器、createChart、K 线主系列 |
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)`:成交量 / ATR / ChanMACD |
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)`:笔 / 线段 / 中枢(含未完成与 BI) |
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)`:结构区、威科夫、BSP/分型、布林等 |
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)`:时间轴同步、bindSync、视图恢复、tooltip |
| `chart_tv.js` | `initTradingView`:组 ctx → 顺序调用上述步骤 |
## Context object
`ctx` 至少携带:`symbol``timeframe``symbolConfig`、周期开关、`candles`、各 chart/container、`showMacd`。全局 `currentData` / `tvWidget` 仍按现网约定使用。
## HTML load order
`lifecycle → shell → indicators → chan → overlays → finalize → chart_tv.js → chart_sync.js → …`
## Tests
1. `node --check` 各新文件 + 门面
2. 人工:首屏、自动刷新、威科夫开关、三周期笔段中枢、Cycle Summary
## Out of scope
Live 验证批跑、威科夫算法调参、analyze JSON 快照、`chart_sync` 大改。
@@ -0,0 +1,31 @@
# ENGINEERING_SPEC — ECR-009 Crypto Wyckoff Screener
**Level:** L2 · 独立页
**Date:** 2026-08-07
## Goal
数字货币 D/W/M 威科夫选股观察页(A_Share_DP 引擎语义);24/7 tip 每分钟更新。
## Package
`crypto_wyckoff/`features → cycle/phase/event/signal → decision → plan;本地 `data/crypto_wyckoff/`
## API
- `GET /wyckoff_crypto`
- `GET /api/wyckoff_crypto/meta|status|scan`
- `GET /api/wyckoff_crypto/symbol/<symbol>`
- `POST /api/wyckoff_crypto/tick`
## Env
- `CRYPTO_WYCKOFF_DISABLE=1` 关闭调度
- `CRYPTO_WYCKOFF_INTERVAL=60`
- `CRYPTO_WYCKOFF_MAX_SYMBOLS=N` 小样本调试
- `DATA_SERVICE_URL` 默认 provider.jackyu66.com
## Crypto calendar
UTC 连续盘;回填不做 A 股周末放大。
**月线**provider 无 `1M`,由本地日线按 **UTC 自然月** OHLCV 聚合;日/周直接拉 `1d`/`1w`
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,27 @@
# 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**
@@ -0,0 +1,14 @@
# 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.
@@ -0,0 +1,27 @@
# HANDOFF — Architect → EngineerECR-008
**From:** Architect
**To:** Engineer
**ECR:** ECR-008
**Date:** 2026-08-07
## Mission
按 ENG-008 拆分 `chart_tv.js`;剪切粘贴优先;禁止改绘制语义。
## Steps
1. 抽出 `disposeTradingViewCharts``chart_tv_lifecycle.js`
2. 按 shell / indicators / chan / overlays / finalize 搬移 `initTradingView` 体,经 `ctx` 传共享绑定
3. 门面 `initTradingView` 仅:dispose → build ctx → 顺序调用
4. 更新 `index.html` script 顺序与 `?v=`
5. `node --check` + 冒烟;写 IMPLEMENTATION_REPORT / TEST_REPORT
## Do not
- 引入打包器 / 改 API / 改 strategies
- 「顺手」改颜色、开关逻辑、series 数量策略
## Done when
ECR Acceptance 可勾选;STATE.owner → reviewer。
+27
View File
@@ -0,0 +1,27 @@
# Idea: 拆分主站巨型 chart_tv.js
## Problem
`web/static/js/app/chart_tv.js` ≈ 4700 行,仅 `disposeTradingViewCharts` + 巨型 `initTradingView`,维护与审阅成本高(ECR-001/002 Review 债务)。
## Observation
ECR-002 明确将 chart_tv 拆分列为可选且未做;后续威科夫/Live 改动都挤在同一文件。
## Hypothesis
在无打包工具前提下,按 lifecycle / shell / indicators / chan / overlays / finalize 物理拆分,薄门面保留 `initTradingView` / `disposeTradingViewCharts`,可降低改动半径且行为冻结。
## Expected Impact
主站前端可维护性提升;与 `chart_sync` / `chart_view` 边界更清晰。
## Change Level Guess
**L3**(结构重构;行为冻结)
## Next
- [x] ECR-008 Draft → Human Approve(计划执行即 Approve
- [ ] ENGINEERING_SPEC / HANDOFF
- [ ] 实现与 CODE_REVIEW
@@ -0,0 +1,13 @@
# Idea: Crypto Wyckoff Screener(独立页)
## Problem
主站威科夫是图叠层;需要 A_Share_DP 式 D/W/M 多周期选股/决策观察,用于数字货币。
## Hypothesis
独立包 + 独立页,币对来自 DATA_SERVICE,本地缓存 1d/1w/1M,每分钟 tip 更新,不碰缠论主链路。
## Change Level Guess
**L2**(新行为面;不改 strategies
+29
View File
@@ -0,0 +1,29 @@
# IMPLEMENTATION_REPORT — ECR-008
**Status:** Implemented
**Date:** 2026-08-07
**Branch:** `feature/ECR-008-chart-tv-split`
## Change summary
`chart_tv.js` 单体拆为:
| File | Role |
|------|------|
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts` |
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)` |
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)` |
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)` |
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)` |
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)` |
| `chart_tv.js` | `initTradingView` 薄门面 |
`index.html` 按 ENG 顺序加载;cache `?v=20260807f`
## Method
剪切粘贴原 `initTradingView` 体段;共享绑定经 `ctx`;绘制语义未改。
## Not changed
缠论算法、`/api/analyze``config/``strategies/`、主站 WS。
+2 -1
View File
@@ -34,10 +34,11 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
## Active anchors
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化
- ECR: ECR-002/003/004 ReviewedECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewedchart_tv 拆分
- EXP: N/A
- TRACEABILITY: `docs/TRACEABILITY.md`
- Memory: `docs/AGENT_MEMORY.md`
- Loop archive: `docs/runs/LOOP-RUN-005/`
## Pointers
+9 -5
View File
@@ -1,11 +1,11 @@
# STATE
**owner:** idle
**active_ecr:** noneECR-004 Reviewed;待本批提交合入
**phase:** post-review
**owner:** engineer
**active_ecr:** ECR-009crypto wyckoff screener
**phase:** implementing
**system_version:** v1.0.0
**strategy_version:** unchanged
**updated:** 2026-08-06
**updated:** 2026-08-07
## Recent
@@ -16,8 +16,12 @@
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
| ECR-007 | L2 | Done (Final Approval) | Live Structure · 待合入 `dev` |
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 |
| ECR-009 | L2 | Implementing | `/wyckoff_crypto` · D/W/M |
## Notes
- ECR-004**Approve**14 passed);无币种独立参数
- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」
- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线仍由日线 UTC 聚合
- 未请求新 system tag
+10
View File
@@ -0,0 +1,10 @@
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/
+7
View File
@@ -0,0 +1,7 @@
ecr: ECR-008
owner: idle
phase: done
updated: 2026-08-07
change_level: L3
decision: Approve
notes: chart_tv split Reviewed · node --check PASS · browser smoke pending Human
+5
View File
@@ -0,0 +1,5 @@
ecr: ECR-009
owner: engineer
phase: implementing
updated: 2026-08-07
notes: crypto wyckoff screener · D/W/M · 24/7 tip
+15
View File
@@ -0,0 +1,15 @@
task_id: ECR-008
title: 拆分主站 chart_tv.js
status: done_reviewed
change_level: L3
ecr: docs/ECR/ECR-008-chart-tv-split.md
engineering_spec: docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md
handoff: docs/HANDOFF/ECR-008-architect-to-engineer.md
code_review: docs/CODE_REVIEW/ECR-008.md
decision: Approve
gates:
- node --check all chart_tv*.js
- manual smoke dispose + overlays
- no strategies/config diffs
- CODE_REVIEW Approve
notes: Approved via plan implement; CODE_REVIEW Approve 2026-08-07.
+33
View File
@@ -0,0 +1,33 @@
# 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
+34
View File
@@ -0,0 +1,34 @@
# TEST_REPORT — ECR-008
**Date:** 2026-08-07
**ECR:** ECR-008
## Commands
```bash
node --check web/static/js/app/chart_tv_lifecycle.js
node --check web/static/js/app/chart_tv_shell.js
node --check web/static/js/app/chart_tv_indicators.js
node --check web/static/js/app/chart_tv_chan.js
node --check web/static/js/app/chart_tv_overlays.js
node --check web/static/js/app/chart_tv_finalize.js
node --check web/static/js/app/chart_tv.js
```
## Result
```text
ALL_CHECK_OK2026-08-07
```
## Manual smoke checklist
| Case | Result |
|------|--------|
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
| 语法 | PASS |
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
## Design Compliance
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
+25
View File
@@ -0,0 +1,25 @@
# TEST_REPORT — ECR-009
**Date:** 2026-08-07
## Commands
```bash
PYTHONPATH=. python -m pytest tests/test_crypto_wyckoff_decision.py -q
CRYPTO_WYCKOFF_DISABLE=1 PYTHONPATH=.:web python -m pytest web/tests/test_wyckoff_crypto_routes.py -q
# Manual / live:
# cd web && CRYPTO_WYCKOFF_MAX_SYMBOLS=5 PYTHONPATH=..:. python app.py
# curl -I http://127.0.0.1:8128/wyckoff_crypto
```
## Result
| Check | Result |
|-------|--------|
| Decision gate unit | 2 passed |
| Route page/meta/scan | 补测(本文件) |
| Live HTTP 2026-08-07 | `GET /wyckoff_crypto` → 200(需先启动 web |
## Note
此前冒烟只做了引擎 tick,**未**在交付前保持 Flask 常驻并给浏览器 URL——属 ESS 测试缺口,已补路由测试与本报告。
+25
View File
@@ -44,3 +44,28 @@
| 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 | 阶段最小长度 + 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 |
## ECR-008
| ECR | Requirement | Spec | Code | Test | Commit |
|-----|-------------|------|------|------|--------|
| ECR-008 | 拆分 chart_tv 单体 | ENG-008 | `chart_tv_*.js` + 薄门面 | `node --check` | dbb6202 |
| ECR-008 | 对外 API 不变 | ENG-008 | `initTradingView` / `disposeTradingViewCharts` | ui.js 调用点 | dbb6202 |
| ECR-008 | 无打包器 | PROFILE | `index.html` script 顺序 | 人工 | dbb6202 |
## ECR-009
| ECR | Requirement | Spec | Code | Test | Commit |
|-----|-------------|------|------|------|--------|
| ECR-009 | Crypto D/W/M screener 独立页 | ENG-009 | `crypto_wyckoff/` + `/wyckoff_crypto` | `test_crypto_wyckoff_decision` | ec08de0 |
| ECR-009 | 月线本地聚合 | ENG-009 | `io.rebuild_monthly_from_daily` | smoke tip | ec08de0 |
| ECR-009 | 不碰 analyze/缠论 | ECR-009 Forbidden | 新 API 前缀 | 人工 | ec08de0 |
+72
View File
@@ -0,0 +1,72 @@
# 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
@@ -0,0 +1,76 @@
# 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
@@ -0,0 +1,62 @@
# 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`
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
@@ -0,0 +1,18 @@
{
"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
@@ -0,0 +1,18 @@
# 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/` 已忽略,勿再提交。
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,18 @@
{
"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"
}
@@ -0,0 +1,84 @@
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.
@@ -0,0 +1,10 @@
{
"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"
}
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,77 @@
# 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
@@ -1,9 +0,0 @@
"""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
@@ -1,155 +0,0 @@
"""
Market State Engine v1 — 因果可计算(无未来函数)
仅使用截至当前 8h K 线已收盘信息:
EMA50/200、ADX、EMA slope、价格相对 MA200 距离
输出 0–100 分数 + 主导状态标签(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
@@ -1,155 +0,0 @@
# 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
@@ -1,63 +0,0 @@
# 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
@@ -1,85 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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(延历史 / 多品种),不改规则。
@@ -1,86 +0,0 @@
{
"$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
}
}
@@ -1,368 +0,0 @@
# --- 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)
@@ -1,460 +0,0 @@
{
"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"
}
}
@@ -1,141 +0,0 @@
{
"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
@@ -1,14 +0,0 @@
# LPS V1.1 — REJECTED
## Hypothesis
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
## Result
- Full: **-1.50%**, n=**2**, 全亏
- 过滤方向正确,但过度收缩 → 无统计意义
## Reject reason
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
-15
View File
@@ -1,15 +0,0 @@
# 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
@@ -1,43 +0,0 @@
# 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
成交量分布 / 订单流 / 资金费率等新信息源进入假设。
@@ -1,492 +0,0 @@
# --- 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)
@@ -1,150 +0,0 @@
{
"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
@@ -1,221 +0,0 @@
#!/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
@@ -1,240 +0,0 @@
#!/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
@@ -1,239 +0,0 @@
#!/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
@@ -1,145 +0,0 @@
#!/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
@@ -1,206 +0,0 @@
#!/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
@@ -1,113 +0,0 @@
#!/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
@@ -1,185 +0,0 @@
#!/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
@@ -1,261 +0,0 @@
#!/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
@@ -1,382 +0,0 @@
#!/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
@@ -1,305 +0,0 @@
#!/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
@@ -1,226 +0,0 @@
#!/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
@@ -1,458 +0,0 @@
"""
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
@@ -1,488 +0,0 @@
"""
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,
},
]
+15 -8
View File
@@ -77,7 +77,7 @@ class ChanLun_BTC_15(IStrategy):
trailing_only_offset_is_reached = False
position_adjustment_enable = True
startup_candle_count = 1000
startup_candle_count = 100
time5 = 5
time15 = 15
@@ -88,14 +88,21 @@ class ChanLun_BTC_15(IStrategy):
time5 = 1440
last_time = datetime.now()
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df_5m = resample_to_interval(dataframe, self.time5)
df_15m = resample_to_interval(dataframe, self.time15)
dataframe = TF_DF.add_indicators(dataframe)
df_5m = TF_DF.add_indicators(df_5m)
df_15m = TF_DF.add_indicators(df_15m)
tf_df_5 = TF_DF(dataframe, self.time5, '5m')
tf_df_15 = TF_DF(dataframe, self.time15, '15m')
tf_df_30 = TF_DF(dataframe, self.time30, '30m')
tf_df_60 = TF_DF(dataframe, self.time60, '60m')
tf_df_4h = TF_DF(dataframe, self.time4h, '4h')
tf_df_1d = TF_DF(dataframe, self.time1d, '1d')
dataframe = resampled_merge(dataframe, df_5m)
dataframe = resampled_merge(dataframe, df_15m)
dataframe = resampled_merge(dataframe, tf_df_5.dataframe)
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
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
-434
View File
@@ -1,434 +0,0 @@
"""
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
@@ -1,449 +0,0 @@
# --- 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
@@ -1,368 +0,0 @@
# --- 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
@@ -1,197 +0,0 @@
# --- 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
@@ -1,42 +0,0 @@
# --- 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
@@ -1,368 +0,0 @@
# --- 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)
-321
View File
@@ -1,321 +0,0 @@
#!/usr/bin/env python3
"""
Maker Edge Report v0.1
章节
1. Fill Quality
2. Adverse Selection
3. MAE/MFE (Price + Time)
4. State Attribution
5. Spread Capture / Quote Lifecycle
假设
H1: P(ret_30s 有利) > 50%
H2: restore exit 优于 all fills
H3: 亏损集中在某类状态 应撤单而非止损
用法
python user_data/Chan/strategies/analyze_maker_edge.py
python user_data/Chan/strategies/analyze_maker_edge.py --report
python user_data/Chan/strategies/analyze_maker_edge.py --min-fills 500
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
def load_events(log_dir: Path) -> pd.DataFrame:
rows = []
files = sorted(log_dir.glob("*.jsonl"))
if not files:
raise FileNotFoundError(f"No jsonl in {log_dir}")
for f in files:
for line in f.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
rows.append(json.loads(line))
return pd.DataFrame(rows)
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
"""多头:价格涨为正;空头:价格跌为正。"""
raw = (px - fill) / fill
return np.where(side == "long", raw, -raw)
def report(df: pd.DataFrame, min_fills: int = 500, out_path: Path | None = None) -> None:
fills = df[df["event"] == "fill"].copy() if "event" in df.columns else pd.DataFrame()
paths = df[df["event"] == "fill_path"].copy() if "event" in df.columns else pd.DataFrame()
created = df[df["event"] == "quote_created"].copy() if "event" in df.columns else pd.DataFrame()
canceled = df[df["event"] == "quote_canceled"].copy() if "event" in df.columns else pd.DataFrame()
qfilled = df[df["event"] == "quote_filled"].copy() if "event" in df.columns else pd.DataFrame()
exits = df[df["event"] == "fill_exit"].copy() if "event" in df.columns else pd.DataFrame()
lines: list[str] = []
def p(s: str = ""):
lines.append(s)
print(s)
p("=" * 72)
p("Maker Edge Report v0.1")
p("=" * 72)
p(f"quote_created : {len(created)}")
p(f"quote_canceled: {len(canceled)}")
p(f"quote_filled : {len(qfilled)}")
p(f"fills : {len(fills)}")
p(f"fill_paths : {len(paths)} (需成交后≥5m)")
p(f"target fills : ≥{min_fills} [{'OK' if len(fills) >= min_fills else 'COLLECTING'}]")
if fills.empty:
p("\n尚无 fill。先跑 Dry-run 探针。")
return
# merge exit_reason onto paths
if not exits.empty and not paths.empty and "fill_id" in exits.columns:
er = exits.drop_duplicates("fill_id").set_index("fill_id")["exit_reason"]
if "exit_reason" not in paths.columns or paths["exit_reason"].isna().all():
paths = paths.merge(er.rename("exit_reason_x"), left_on="fill_id", right_index=True, how="left")
if "exit_reason" not in paths.columns:
paths["exit_reason"] = paths.get("exit_reason_x")
else:
paths["exit_reason"] = paths["exit_reason"].fillna(paths.get("exit_reason_x"))
# merge fill meta into paths
if not paths.empty:
cols = [
c
for c in [
"side",
"fill_price",
"fill_reason",
"time_to_fill",
"trend_state",
"volatility_regime",
"pre_5s_deteriorated",
"obi",
"trade_imbalance",
"spread",
"entry_tag",
]
if c in fills.columns
]
if cols and "fill_id" in fills.columns:
meta = fills.drop_duplicates("fill_id")[["fill_id"] + cols]
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
# -------------------- 1. Fill Quality --------------------
p("\n" + "-" * 72)
p("1. Fill Quality")
p("-" * 72)
if "time_to_fill" in fills.columns:
ttf = fills["time_to_fill"].dropna()
if len(ttf):
p(
f"time_to_fill mean={ttf.mean():.1f}s median={ttf.median():.1f}s "
f"p90={ttf.quantile(0.9):.1f}s"
)
fast = fills[fills["time_to_fill"].fillna(1e9) <= 10]
slow = fills[fills["time_to_fill"].fillna(0) > 30]
p(f"fast fills (≤10s): {len(fast)} slow fills (>30s): {len(slow)}")
if "fill_reason" in fills.columns:
p("fill_reason: " + str(fills["fill_reason"].value_counts().to_dict()))
if "pre_5s_deteriorated" in fills.columns:
det = fills["pre_5s_deteriorated"].fillna(False).astype(bool)
p(f"pre_5s book deteriorated: {det.mean()*100:.1f}% of fills")
n_created = max(len(created), 1)
p(f"fill rate (filled/created): {len(qfilled)/n_created*100:.1f}%")
if len(canceled):
p(f"cancel rate: {len(canceled)/n_created*100:.1f}%")
# -------------------- 2. Adverse Selection --------------------
p("\n" + "-" * 72)
p("2. Adverse Selection (fill 后收益分布)")
p("-" * 72)
if paths.empty:
p("等待 fill_path 完成(成交后 ≥5 分钟)…")
else:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fp = paths["fill_price"]
for label, col in [
("10s", "after_10s_price"),
("30s", "after_30s_price"),
("1m", "after_1m_price"),
("5m", "after_5m_price"),
]:
if col not in paths.columns:
continue
fav = pd.Series(_fav_ret(side, fp, paths[col]), index=paths.index)
p(
f" +{label:3s} mean={fav.mean()*100:+.4f}% "
f"median={fav.median()*100:+.4f}% "
f"P(fav)={ (fav>0).mean()*100:.1f}% n={fav.notna().sum()}"
)
# toxic: 10s 立刻不利
if "after_10s_price" in paths.columns:
fav10 = pd.Series(_fav_ret(side, fp, paths["after_10s_price"]), index=paths.index)
p(f" toxic@10s (fav<0): { (fav10<0).mean()*100:.1f}% → 接毒比例")
# -------------------- 3. MAE / MFE --------------------
p("\n" + "-" * 72)
p("3. MAE / MFE (Price + Time)")
p("-" * 72)
if not paths.empty:
if "price_mae" in paths.columns:
p(
f"Price MAE mean={paths['price_mae'].mean():+.2f} "
f"Price MFE mean={paths['price_mfe'].mean():+.2f}"
)
for h in ["10s", "30s", "1m", "5m"]:
mae_c, mfe_c = f"mae_{h}", f"mfe_{h}"
if mae_c in paths.columns and mfe_c in paths.columns:
p(
f" Time@{h:3s} MAE={paths[mae_c].mean()*100:+.4f}% "
f"MFE={paths[mfe_c].mean()*100:+.4f}%"
)
if "mae_5m" in paths.columns and "mfe_5m" in paths.columns:
ratio = paths["mfe_5m"].mean() / abs(paths["mae_5m"].mean()) if paths["mae_5m"].mean() != 0 else np.nan
p(f" MFE/|MAE| @5m = {ratio:.2f}")
# -------------------- 4. State Attribution --------------------
p("\n" + "-" * 72)
p("4. State Attribution (亏损集中在哪?)")
p("-" * 72)
if not paths.empty and "after_5m_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
paths = paths.copy()
paths["_fav5"] = fav5
paths["_loss"] = fav5 < 0
loss_rate = float(paths["_loss"].mean())
p(f"overall loss@5m: {loss_rate*100:.1f}%")
for col in ["trend_state", "volatility_regime", "fill_reason", "pre_5s_deteriorated"]:
c = col if col in paths.columns else (col + "_f" if col + "_f" in paths.columns else None)
if not c:
continue
p(f"\n by {c}:")
g = paths.groupby(c).agg(
n=("_fav5", "count"),
loss_rate=("_loss", "mean"),
mean_ret=("_fav5", "mean"),
)
for idx, row in g.iterrows():
p(
f" {idx}: n={int(row['n'])} loss={row['loss_rate']*100:.1f}% "
f"E[ret]={row['mean_ret']*100:+.4f}%"
)
# -------------------- 5. Spread Capture / Lifecycle --------------------
p("\n" + "-" * 72)
p("5. Spread Capture / Quote Lifecycle")
p("-" * 72)
if "spread" in fills.columns and fills["spread"].notna().any():
mid = (fills.get("bid_price", 0) + fills.get("ask_price", 0)) / 2
# 简化:相对价差
p(f"spread at fill mean={fills['spread'].mean():.4f} ({(fills['spread']/fills['fill_price']).mean()*100:.5f}%)")
if not paths.empty and "after_30s_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
p(f"mean edge@30s (proxy spread capture): {fav30.mean()*100:+.4f}%")
# -------------------- Hypotheses --------------------
p("\n" + "-" * 72)
p("Hypotheses")
p("-" * 72)
# H1
h1 = None
if not paths.empty and "after_30s_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
h1 = float((fav30 > 0).mean())
p(f"H1 P(fav@30s)>50%: {h1*100:.1f}% [{'PASS' if h1>0.5 else 'FAIL'}]")
else:
p("H1: insufficient fill_path with after_30s")
# H2 restore vs all
if not paths.empty and "after_5m_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
er_col = "exit_reason" if "exit_reason" in paths.columns else None
if er_col and paths[er_col].notna().any():
restore_mask = paths[er_col].astype(str).str.contains("restore", case=False, na=False)
if restore_mask.any():
r_all = float(fav5.mean())
r_res = float(fav5[restore_mask].mean())
verdict = (
"PASS"
if r_res > r_all + 1e-12
else ("INCONCLUSIVE" if abs(r_res - r_all) < 1e-12 else "FAIL")
)
p(
f"H2 restore vs all @5m: restore={r_res*100:+.4f}% all={r_all*100:+.4f}% "
f"[{verdict}] n_restore={int(restore_mask.sum())}"
)
else:
p("H2: no restore exits tagged yet")
else:
p("H2: exit_reason not linked yet (need closed trades)")
else:
p("H2: waiting for paths")
# H3 concentrated losses
if not paths.empty and "_loss" in paths.columns and paths["_loss"].any():
losses = paths[paths["_loss"]]
for col in ["trend_state", "volatility_regime", "fill_reason"]:
c = col if col in losses.columns else None
if c and losses[c].notna().any():
top = losses[c].value_counts(normalize=True).head(1)
if len(top):
k, v = top.index[0], float(top.iloc[0])
p(f"H3 loss concentration: {v*100:.1f}% of losses in {c}={k} "
f"[{'ACTION: cancel in this state' if v>=0.5 else 'diffuse'}]")
else:
p("H3: need completed paths with losses")
p("\n" + "=" * 72)
p("Next: accumulate ≥500 fills (ideal 1000) before designing quote model / v1.2.")
p("=" * 72)
if out_path:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\nReport saved: {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--dir",
type=str,
default=str(Path(__file__).resolve().parents[2] / "logs" / "maker_edge"),
)
ap.add_argument("--min-fills", type=int, default=500)
ap.add_argument("--report", action="store_true", help="also write markdown/txt report")
args = ap.parse_args()
log_dir = Path(args.dir)
if not log_dir.exists():
print(f"日志目录不存在: {log_dir}")
return
try:
df = load_events(log_dir)
except FileNotFoundError as e:
print(e)
return
out = None
if args.report:
out = Path(__file__).resolve().parents[2] / "logs" / "maker_edge" / "Maker_Edge_Report_v0.1.txt"
report(df, min_fills=args.min_fills, out_path=out)
if __name__ == "__main__":
main()
-566
View File
@@ -1,566 +0,0 @@
"""
Maker Edge 事件记录器Dry-run / Live Execution Reality Layer
事件
- quote_created / quote_canceled / quote_filled 报价生命周期
- book_tick 可选心跳用于成交前5s盘口
- fill 成交瞬间 + 盘口状态
- fill_path 10s/30s/1m/5m + Price/Time MAE/MFE
输出user_data/logs/maker_edge/YYYYMMDD.jsonl
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _iso(ts: datetime | float | None = None) -> str:
if ts is None:
t = _utc_now()
elif isinstance(ts, (int, float)):
t = datetime.fromtimestamp(ts, tz=timezone.utc)
else:
t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
return t.isoformat()
@dataclass
class MicroSnapshot:
best_bid: float = 0.0
best_ask: float = 0.0
mid: float = 0.0
spread: float = 0.0
bid_depth_1: float = 0.0
ask_depth_1: float = 0.0
bid_depth_5: float = 0.0
ask_depth_5: float = 0.0
bid_depth: float = 0.0 # top-N
ask_depth: float = 0.0
obi: float = 0.0
delta: float = 0.0
trade_imbalance: float = 0.0 # (buy-sell)/(buy+sell) on recent trades
delta_efficiency: float = 0.0
liquidation_distance: float = 0.0
def to_book_fields(self) -> dict[str, float]:
return {
"bid_price": self.best_bid,
"ask_price": self.best_ask,
"mid": self.mid,
"spread": self.spread,
"bid_depth_1": self.bid_depth_1,
"ask_depth_1": self.ask_depth_1,
"bid_depth_5": self.bid_depth_5,
"ask_depth_5": self.ask_depth_5,
"bid_depth": self.bid_depth,
"ask_depth": self.ask_depth,
"obi": self.obi,
"delta": self.delta,
"trade_imbalance": self.trade_imbalance,
"delta_efficiency": self.delta_efficiency,
"liquidation_distance": self.liquidation_distance,
# 兼容旧字段
"buy1_depth": self.bid_depth_1,
"sell1_depth": self.ask_depth_1,
}
@dataclass
class ActiveQuote:
quote_id: str
pair: str
side: str # bid / ask
quote_price: float
created_ts: float
reason: str = ""
trade_id: Optional[int] = None
status: str = "open" # open / filled / canceled
@dataclass
class PendingFillPath:
fill_id: str
pair: str
side: str
fill_price: float
fill_ts: float
quote_id: Optional[str] = None
exit_reason: Optional[str] = None
# horizon prices
after_10s_price: Optional[float] = None
after_30s_price: Optional[float] = None
after_1m_price: Optional[float] = None
after_5m_price: Optional[float] = None
# running extrema
min_price: float = 0.0
max_price: float = 0.0
# time-MAE: worst adverse excursion seen by each horizon (signed, adverse negative for long)
mae_10s: Optional[float] = None
mae_30s: Optional[float] = None
mae_1m: Optional[float] = None
mae_5m: Optional[float] = None
mfe_10s: Optional[float] = None
mfe_30s: Optional[float] = None
mfe_1m: Optional[float] = None
mfe_5m: Optional[float] = None
done: bool = False
def __post_init__(self):
self.min_price = self.fill_price
self.max_price = self.fill_price
def signed_excursions(self) -> tuple[float, float]:
"""Return (mae, mfe) at current min/max. mae<=0 adverse, mfe>=0 favorable."""
if self.side == "long":
mae = (self.min_price - self.fill_price) / self.fill_price
mfe = (self.max_price - self.fill_price) / self.fill_price
else:
mae = (self.fill_price - self.max_price) / self.fill_price
mfe = (self.fill_price - self.min_price) / self.fill_price
return mae, mfe
class MakerEdgeLogger:
def __init__(
self,
log_dir: str | Path | None = None,
levels: int = 10,
book_history_sec: float = 30.0,
):
root = Path(__file__).resolve().parents[2]
self.log_dir = Path(log_dir) if log_dir else root / "logs" / "maker_edge"
self.log_dir.mkdir(parents=True, exist_ok=True)
self.levels = levels
self.book_history_sec = book_history_sec
self._pending: dict[str, PendingFillPath] = {}
self._quotes: dict[str, ActiveQuote] = {} # quote_id -> ActiveQuote
self._quotes_by_trade: dict[int, str] = {} # trade_id -> quote_id
self._book_hist: deque[tuple[float, MicroSnapshot]] = deque(maxlen=2000)
def _file(self) -> Path:
return self.log_dir / f"{_utc_now().strftime('%Y%m%d')}.jsonl"
def write(self, event: dict[str, Any]) -> None:
event.setdefault("ts", _iso())
event.setdefault("ts_epoch", time.time())
with self._file().open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
# ------------------------------------------------------------------ #
# Snapshot
# ------------------------------------------------------------------ #
@staticmethod
def snapshot_from_orderbook(
ob: dict,
levels: int = 10,
recent_trades: list | None = None,
last_mid: float | None = None,
liq_proxy_low: float | None = None,
liq_proxy_high: float | None = None,
) -> MicroSnapshot:
bids = (ob.get("bids") or [])[:levels]
asks = (ob.get("asks") or [])[:levels]
if not bids or not asks:
return MicroSnapshot()
best_bid = float(bids[0][0])
best_ask = float(asks[0][0])
mid = (best_bid + best_ask) / 2.0
spread = best_ask - best_bid
def depth(levels_side, n):
return sum(float(x[1]) for x in levels_side[:n])
bid_depth_1 = depth(bids, 1)
ask_depth_1 = depth(asks, 1)
bid_depth_5 = depth(bids, 5)
ask_depth_5 = depth(asks, 5)
bid_depth = depth(bids, levels)
ask_depth = depth(asks, levels)
tot = bid_depth + ask_depth
obi = ((bid_depth - ask_depth) / tot) if tot > 0 else 0.0
buy_v = sell_v = 0.0
if recent_trades:
for t in recent_trades:
amt = float(t.get("amount") or t.get("qty") or 0.0)
side = (t.get("side") or "").lower()
if side in ("buy", "b"):
buy_v += amt
elif side in ("sell", "s"):
sell_v += amt
delta = buy_v - sell_v
timb_den = buy_v + sell_v
trade_imbalance = ((buy_v - sell_v) / timb_den) if timb_den > 0 else 0.0
de = 0.0
if last_mid and mid and abs(delta) > 1e-12:
de = ((mid - last_mid) / last_mid) / delta
liq_dist = 0.0
if liq_proxy_low and liq_proxy_high and mid:
rng = liq_proxy_high - liq_proxy_low
if rng > 0:
liq_dist = ((mid - liq_proxy_low) / rng) * 2 - 1
return MicroSnapshot(
best_bid=best_bid,
best_ask=best_ask,
mid=mid,
spread=spread,
bid_depth_1=bid_depth_1,
ask_depth_1=ask_depth_1,
bid_depth_5=bid_depth_5,
ask_depth_5=ask_depth_5,
bid_depth=bid_depth,
ask_depth=ask_depth,
obi=obi,
delta=delta,
trade_imbalance=trade_imbalance,
delta_efficiency=de,
liquidation_distance=liq_dist,
)
def record_book(self, snap: MicroSnapshot, now: float | None = None) -> None:
now = now or time.time()
self._book_hist.append((now, snap))
# trim old
cutoff = now - self.book_history_sec
while self._book_hist and self._book_hist[0][0] < cutoff:
self._book_hist.popleft()
def book_at(self, target_ts: float) -> Optional[MicroSnapshot]:
"""取最接近 target_ts 的历史盘口(用于成交前5s)。"""
if not self._book_hist:
return None
best = min(self._book_hist, key=lambda x: abs(x[0] - target_ts))
return best[1]
def book_deterioration(self, side: str, now: float | None = None, lookback: float = 5.0) -> dict:
"""
成交前 lookback 秒盘口是否恶化
long: bid_depth 下降 / ask_depth 上升 / mid 下跌 恶化
"""
now = now or time.time()
cur = self.book_at(now)
past = self.book_at(now - lookback)
if not cur or not past or past.mid <= 0:
return {"book_ok": False}
mid_chg = (cur.mid - past.mid) / past.mid
bid5_chg = (cur.bid_depth_5 - past.bid_depth_5) / past.bid_depth_5 if past.bid_depth_5 else 0.0
ask5_chg = (cur.ask_depth_5 - past.ask_depth_5) / past.ask_depth_5 if past.ask_depth_5 else 0.0
obi_chg = cur.obi - past.obi
if side == "long":
deteriorated = (mid_chg < -0.00005) or (bid5_chg < -0.15) or (obi_chg < -0.1)
else:
deteriorated = (mid_chg > 0.00005) or (ask5_chg < -0.15) or (obi_chg > 0.1)
return {
"book_ok": True,
"pre_5s_mid_chg": mid_chg,
"pre_5s_bid_depth_5_chg": bid5_chg,
"pre_5s_ask_depth_5_chg": ask5_chg,
"pre_5s_obi_chg": obi_chg,
"pre_5s_deteriorated": bool(deteriorated),
"pre_5s_bid_depth_1": past.bid_depth_1,
"pre_5s_ask_depth_1": past.ask_depth_1,
"pre_5s_bid_depth_5": past.bid_depth_5,
"pre_5s_ask_depth_5": past.ask_depth_5,
"pre_5s_obi": past.obi,
"pre_5s_spread": past.spread,
"pre_5s_trade_imbalance": past.trade_imbalance,
}
# ------------------------------------------------------------------ #
# Quote lifecycle
# ------------------------------------------------------------------ #
def create_quote(
self,
pair: str,
side: str,
quote_price: float,
inventory: float,
snap: MicroSnapshot,
reason: str = "",
trade_id: Optional[int] = None,
state: dict | None = None,
) -> str:
qid = uuid.uuid4().hex[:16]
now = time.time()
q = ActiveQuote(
quote_id=qid,
pair=pair,
side=side,
quote_price=quote_price,
created_ts=now,
reason=reason,
trade_id=trade_id,
status="open",
)
self._quotes[qid] = q
if trade_id is not None:
self._quotes_by_trade[trade_id] = qid
ev = {
"event": "quote_created",
"quote_id": qid,
"pair": pair,
"side": side,
"quote_price": quote_price,
"quote_created_time": _iso(now),
"quote_created_epoch": now,
"inventory": inventory,
"reason": reason,
"trade_id": trade_id,
"status": "open",
"filled": False,
}
ev.update(snap.to_book_fields())
if state:
ev.update(state)
self.write(ev)
return qid
def cancel_quote(
self,
quote_id: str | None = None,
trade_id: Optional[int] = None,
reason: str = "timeout",
snap: MicroSnapshot | None = None,
) -> None:
q = None
if quote_id and quote_id in self._quotes:
q = self._quotes[quote_id]
elif trade_id is not None and trade_id in self._quotes_by_trade:
q = self._quotes.get(self._quotes_by_trade[trade_id])
if q is None or q.status != "open":
return
now = time.time()
q.status = "canceled"
ev = {
"event": "quote_canceled",
"quote_id": q.quote_id,
"pair": q.pair,
"side": q.side,
"quote_price": q.quote_price,
"quote_created_time": _iso(q.created_ts),
"quote_cancel_time": _iso(now),
"quote_cancel_epoch": now,
"time_alive_sec": now - q.created_ts,
"cancel_reason": reason,
"filled": False,
"status": "canceled",
"trade_id": q.trade_id,
}
if snap:
ev.update(snap.to_book_fields())
self.write(ev)
def bind_trade(self, quote_id: str, trade_id: int) -> None:
if quote_id in self._quotes:
self._quotes[quote_id].trade_id = trade_id
self._quotes_by_trade[trade_id] = quote_id
# ------------------------------------------------------------------ #
# Fill + path
# ------------------------------------------------------------------ #
def log_fill(
self,
pair: str,
side: str,
fill_price: float,
amount: float,
inventory: float,
snap: MicroSnapshot,
order_type: str = "limit",
quote_id: str | None = None,
trade_id: Optional[int] = None,
fill_reason: str = "maker_hit",
state: dict | None = None,
extra: dict | None = None,
) -> str:
now = time.time()
fill_id = uuid.uuid4().hex[:16]
# resolve quote lifecycle
q: Optional[ActiveQuote] = None
if quote_id and quote_id in self._quotes:
q = self._quotes[quote_id]
elif trade_id is not None and trade_id in self._quotes_by_trade:
q = self._quotes.get(self._quotes_by_trade[trade_id])
time_to_fill = None
quote_created_time = None
quote_price = fill_price
if q is not None:
q.status = "filled"
time_to_fill = now - q.created_ts
quote_created_time = _iso(q.created_ts)
quote_price = q.quote_price
quote_id = q.quote_id
det = self.book_deterioration(side, now=now, lookback=5.0)
ev = {
"event": "fill",
"fill_id": fill_id,
"quote_id": quote_id,
"pair": pair,
"side": side,
"fill_price": fill_price,
"quote_price": quote_price,
"amount": amount,
"inventory": inventory,
"order_type": order_type,
"fill_reason": fill_reason,
"quote_created_time": quote_created_time,
"quote_fill_time": _iso(now),
"time_to_fill": time_to_fill,
"trade_id": trade_id,
"filled": True,
}
ev.update(snap.to_book_fields())
ev.update(det)
if state:
ev.update(state)
if extra:
ev.update(extra)
self.write(ev)
# also emit quote_filled lifecycle event
if q is not None:
self.write(
{
"event": "quote_filled",
"quote_id": q.quote_id,
"fill_id": fill_id,
"pair": pair,
"side": q.side,
"quote_price": q.quote_price,
"quote_created_time": _iso(q.created_ts),
"quote_fill_time": _iso(now),
"time_to_fill": time_to_fill,
"fill_reason": fill_reason,
"filled": True,
"status": "filled",
"trade_id": trade_id,
**snap.to_book_fields(),
**det,
}
)
self._pending[fill_id] = PendingFillPath(
fill_id=fill_id,
pair=pair,
side=side,
fill_price=fill_price,
fill_ts=now,
quote_id=quote_id,
)
return fill_id
def attach_exit_reason(self, fill_id: str, exit_reason: str) -> None:
if fill_id in self._pending:
self._pending[fill_id].exit_reason = exit_reason
# also write lightweight annotation
self.write(
{
"event": "fill_exit",
"fill_id": fill_id,
"exit_reason": exit_reason,
}
)
def update_paths(self, pair: str, last_price: float, now: float | None = None) -> None:
now = now or time.time()
finished = []
for fid, p in self._pending.items():
if p.pair != pair or p.done:
continue
p.min_price = min(p.min_price, last_price)
p.max_price = max(p.max_price, last_price)
mae, mfe = p.signed_excursions()
age = now - p.fill_ts
def mark(horizon_attr_price, horizon_mae, horizon_mfe, sec, price_val):
if getattr(p, horizon_attr_price) is None and age >= sec:
setattr(p, horizon_attr_price, price_val)
setattr(p, horizon_mae, mae)
setattr(p, horizon_mfe, mfe)
mark("after_10s_price", "mae_10s", "mfe_10s", 10, last_price)
mark("after_30s_price", "mae_30s", "mfe_30s", 30, last_price)
mark("after_1m_price", "mae_1m", "mfe_1m", 60, last_price)
if p.after_5m_price is None and age >= 300:
p.after_5m_price = last_price
p.mae_5m = mae
p.mfe_5m = mfe
p.done = True
# Price MAE absolute
if p.side == "long":
price_mae = p.min_price - p.fill_price
price_mfe = p.max_price - p.fill_price
else:
price_mae = p.fill_price - p.max_price # negative if adverse up
price_mfe = p.fill_price - p.min_price
self.write(
{
"event": "fill_path",
"fill_id": p.fill_id,
"quote_id": p.quote_id,
"pair": p.pair,
"side": p.side,
"fill_price": p.fill_price,
"exit_reason": p.exit_reason,
"after_10s_price": p.after_10s_price,
"after_30s_price": p.after_30s_price,
"after_1m_price": p.after_1m_price,
"after_5m_price": p.after_5m_price,
"min_price": p.min_price,
"max_price": p.max_price,
# percent
"mae_10s": p.mae_10s,
"mae_30s": p.mae_30s,
"mae_1m": p.mae_1m,
"mae_5m": p.mae_5m,
"mfe_10s": p.mfe_10s,
"mfe_30s": p.mfe_30s,
"mfe_1m": p.mfe_1m,
"mfe_5m": p.mfe_5m,
# absolute price
"price_mae": price_mae,
"price_mfe": price_mfe,
"price_mae_pct": mae,
"price_mfe_pct": mfe,
}
)
finished.append(fid)
for fid in finished:
self._pending.pop(fid, None)
@property
def pending_count(self) -> int:
return len(self._pending)
# 兼容旧 API
def log_quote(self, *args, **kwargs):
"""Deprecated wrapper → create_quote for live quotes; heartbeat uses book only."""
return self.create_quote(*args, **kwargs)
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env python3
"""
BTC Maker Micro Scalper 回测结果统计
重点指标Net Expectancy不是胜率
E = 胜率×平均盈利 - 失败率×平均亏损 - 手续费 - 滑点
用法
python user_data/Chan/strategies/mms_stats.py
python user_data/Chan/strategies/mms_stats.py --file user_data/backtest_results/xxx.zip
python user_data/Chan/strategies/mms_stats.py --slippage 0.00005
"""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
def _latest_backtest(results_dir: Path) -> Path | None:
zips = sorted(results_dir.glob("backtest-result-*.zip"), key=lambda p: p.stat().st_mtime)
return zips[-1] if zips else None
def _load_trades(path: Path) -> tuple[pd.DataFrame, dict[str, Any]]:
meta: dict[str, Any] = {}
if path.suffix == ".zip":
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
# prefer meta + trades json inside zip
trade_name = next((n for n in names if n.endswith(".json") and "meta" not in n), None)
meta_name = next((n for n in names if n.endswith(".meta.json")), None)
if meta_name:
meta = json.loads(zf.read(meta_name))
if not trade_name:
raise FileNotFoundError(f"No trades json in {path}")
payload = json.loads(zf.read(trade_name))
else:
payload = json.loads(path.read_text())
# Freqtrade formats: {"strategy": {"BTC_...": {"trades": [...]}}}
# or flat list / {"trades": [...]}
trades = None
if isinstance(payload, list):
trades = payload
elif isinstance(payload, dict):
if "trades" in payload:
trades = payload["trades"]
elif isinstance(payload.get("strategy"), dict):
# freqtrade zip: {"strategy": {"BTC_Maker_Micro_Scalper": {"trades": [...]}}}
for name, v in payload["strategy"].items():
if isinstance(v, dict) and "trades" in v:
trades = v["trades"]
meta.setdefault("strategy", name)
break
if trades is None:
for _k, v in payload.items():
if isinstance(v, dict) and "trades" in v:
trades = v["trades"]
meta.setdefault("strategy", _k)
break
if trades is None:
raise ValueError(f"Cannot parse trades from {path}")
df = pd.DataFrame(trades)
return df, meta
def summarize(df: pd.DataFrame, fee_rate: float = 0.00016, slippage: float = 0.0) -> dict[str, Any]:
if df.empty:
return {"error": "no trades"}
# profit_ratio is net of fees in freqtrade; also keep absolute
profit_col = "profit_ratio" if "profit_ratio" in df.columns else "close_profit"
profits = df[profit_col].astype(float)
wins = profits[profits > 0]
losses = profits[profits <= 0]
n = len(profits)
win_rate = len(wins) / n if n else 0.0
loss_rate = 1.0 - win_rate
avg_win = float(wins.mean()) if len(wins) else 0.0
avg_loss = float(losses.mean()) if len(losses) else 0.0 # negative or 0
avg_loss_abs = abs(avg_loss)
gross_profit = float(wins.sum()) if len(wins) else 0.0
gross_loss = float((-losses).sum()) if len(losses) else 0.0
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf")
# 手续费:freqtrade 的 profit 已扣费;这里单独估算双边 maker 占比
# 每笔双边 fee ≈ 2 * fee_rate(相对名义)
fee_per_trade = 2.0 * fee_rate
total_fee_est = n * fee_per_trade
# 滑点假设(每边)
slip_per_trade = 2.0 * slippage
total_slip_est = n * slip_per_trade
# Net Expectancy(每笔期望,比率)
# E = WR*avg_win - LR*avg_loss_abs - fee - slip
expectancy = win_rate * avg_win - loss_rate * avg_loss_abs - fee_per_trade - slip_per_trade
# 注意:若 profit_ratio 已含手续费,上式 fee 会双重扣除。
# 提供两个版本:
# 1) E_raw:用毛期望再减 fee/slip(假设 profit 含 fee → 用 E_from_net
# 2) E_from_net:直接用已实现平均利润(已含 fee)再减额外滑点假设
e_from_net = float(profits.mean()) - slip_per_trade
# 最大回撤(权益曲线,相对)
equity = (1.0 + profits).cumprod()
peak = equity.cummax()
dd = (equity - peak) / peak
max_dd = float(dd.min()) if len(dd) else 0.0
# 持仓时间
hold_min = None
if "open_date" in df.columns and "close_date" in df.columns:
od = pd.to_datetime(df["open_date"], utc=True)
cd = pd.to_datetime(df["close_date"], utc=True)
hold_min = float(((cd - od).dt.total_seconds() / 60.0).mean())
# Maker 成交率:若有 order_type / is_short 等字段无法直接得,默认限价策略按 100% 标注
maker_rate = 1.0
if "exit_reason" in df.columns:
# 无法精确时保持 1.0;实盘可从策略 _maker_fills 导出
pass
# 手续费占毛利
fee_share = None
if "fee_open" in df.columns and "fee_close" in df.columns:
fees = df["fee_open"].astype(float).fillna(0) + df["fee_close"].astype(float).fillna(0)
abs_pnl = df.get("profit_abs", profits).astype(float).abs().sum()
fee_share = float(fees.sum() / abs_pnl) if abs_pnl else None
total_fee_est = float(fees.sum())
return {
"total_trades": n,
"win_rate": win_rate,
"avg_win": avg_win,
"avg_loss": avg_loss,
"profit_factor": profit_factor,
"max_drawdown": max_dd,
"fee_est_total_ratio_units": total_fee_est,
"fee_share_of_abs_pnl": fee_share,
"maker_fill_rate_assumed": maker_rate,
"avg_hold_minutes": hold_min,
"net_expectancy_from_realized": e_from_net,
"net_expectancy_formula_rebuild": expectancy,
"total_profit_ratio_sum": float(profits.sum()),
"avg_profit": float(profits.mean()),
"slippage_assumed_per_side": slippage,
"note": (
"优先看 net_expectancy_from_realized(已含 freqtrade 手续费)。"
"net_expectancy_formula_rebuild 会再减一遍 fee,仅作分解参考。"
),
}
def print_report(stats: dict[str, Any], source: str) -> None:
print("=" * 60)
print("BTC Maker Micro Scalper — Backtest Stats")
print(f"source: {source}")
print("=" * 60)
if "error" in stats:
print(stats["error"])
return
def pct(x):
return f"{x * 100:.4f}%" if x is not None else "n/a"
print(f"总交易次数 : {stats['total_trades']}")
print(f"胜率 : {pct(stats['win_rate'])} (勿作为主指标)")
print(f"平均盈利 : {pct(stats['avg_win'])}")
print(f"平均亏损 : {pct(stats['avg_loss'])}")
print(f"Profit Factor : {stats['profit_factor']:.4f}")
print(f"最大回撤 : {pct(stats['max_drawdown'])}")
print(f"手续费占比(abs pnl) : {stats['fee_share_of_abs_pnl']}")
print(f"Maker成交率(假设) : {pct(stats['maker_fill_rate_assumed'])}")
print(f"平均持仓时间(分钟) : {stats['avg_hold_minutes']}")
print("-" * 60)
print(f"Net Expectancy/笔 : {pct(stats['net_expectancy_from_realized'])} ★主指标")
print(f"公式重建 E(参考) : {pct(stats['net_expectancy_formula_rebuild'])}")
print(f"累计收益(比率和) : {pct(stats['total_profit_ratio_sum'])}")
print(f"平均单笔 : {pct(stats['avg_profit'])}")
print("-" * 60)
print(stats["note"])
print("=" * 60)
def export_equity_csv(df: pd.DataFrame, out: Path) -> None:
if df.empty or "profit_ratio" not in df.columns:
return
profits = df["profit_ratio"].astype(float)
equity = (1.0 + profits).cumprod()
out_df = pd.DataFrame({
"close_date": df.get("close_date"),
"profit_ratio": profits,
"equity": equity,
})
out.parent.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out, index=False)
print(f"净收益曲线已导出: {out}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--file", type=str, default=None, help="backtest zip/json path")
ap.add_argument("--slippage", type=float, default=0.0, help="per-side slippage ratio")
ap.add_argument("--fee", type=float, default=0.00016, help="per-side maker fee ratio")
ap.add_argument(
"--equity-out",
type=str,
default="user_data/plot/mms_equity.csv",
help="equity curve csv",
)
args = ap.parse_args()
root = Path(__file__).resolve().parents[3] # freqtrade root
results_dir = root / "user_data" / "backtest_results"
path = Path(args.file) if args.file else _latest_backtest(results_dir)
if path is None or not path.exists():
print("未找到回测结果。请先运行 backtesting,或用 --file 指定。")
print(
"示例:\n"
" freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\\n"
" --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\\n"
" --timerange=20260101- --fee 0.00016 --enable-protections"
)
return
df, meta = _load_trades(path)
stats = summarize(df, fee_rate=args.fee, slippage=args.slippage)
print_report(stats, str(path))
if meta:
print(f"meta keys: {list(meta.keys())[:8]}")
export_equity_csv(df, root / args.equity_out)
if __name__ == "__main__":
main()
+8 -4
View File
@@ -14,11 +14,11 @@
"uncompleted_bi_list",
"uncompleted_seg_list",
"uncompleted_zs_list",
"wyckoff",
"zs_list"
],
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"]
"include_structure_zones": ["structure_zones"]
},
"wyckoff_keys": [
"trading_range",
@@ -26,6 +26,10 @@
"phases",
"events",
"volume_profile",
"volume_confirm"
]
"volume_confirm",
"cycles",
"live",
"lifecycle"
],
"notes": "wyckoff 默认返回;cycles[0]=ACTIVEphases/events=Confirmedlive=DevelopingWYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
}
+2 -1
View File
@@ -148,11 +148,11 @@ def analyze_contract_keys() -> dict:
"macd",
"chan_macd",
"klc_trend",
"wyckoff",
]
),
"optional_when": {
"include_structure_zones": ["structure_zones"],
"include_wyckoff": ["wyckoff"],
},
"wyckoff_keys": [
"trading_range",
@@ -162,6 +162,7 @@ def analyze_contract_keys() -> dict:
"volume_profile",
"volume_confirm",
],
"notes": "wyckoff 随主周期 analyze 默认返回;有次/次次周期时另附 element_wyckoff / sub_sub_wyckoffinclude_wyckoff=0 可跳过;elements_only 时不返回",
}
+31
View File
@@ -0,0 +1,31 @@
"""Unit tests for TF combo validation."""
from __future__ import annotations
import pytest
from crypto_wyckoff.combos import (
add_combo,
delete_combo,
get_combo,
list_combos,
validate_combo,
)
def test_builtin_default_is_h8_4_1():
c = get_combo(None)
assert c["id"] == "h8_4_1"
assert (c["high"], c["mid"], c["low"]) == ("8h", "4h", "1h")
def test_validate_order():
assert validate_combo("8h", "4h", "1h") is None
assert validate_combo("1h", "4h", "8h") is not None
assert validate_combo("8h", "8h", "1h") is not None
def test_list_includes_dwm():
ids = {c["id"] for c in list_combos()}
assert "h8_4_1" in ids
assert "d_w_m" in ids
+66
View File
@@ -0,0 +1,66 @@
"""Decision engine MTF gate tests (ported semantics)."""
from crypto_wyckoff.domain_models import (
DecisionSignal,
EngineResult,
WyckoffCycle,
WyckoffEvent,
WyckoffPhase,
)
from crypto_wyckoff.decision import DecisionEngine
def _er(name, payload, score=70, confidence=70):
return EngineResult(name=name, score=score, confidence=confidence, payload=payload)
def test_monthly_distribution_daily_spring_is_watch():
eng = DecisionEngine()
monthly = _er("Cycle", {"cycle": WyckoffCycle.DISTRIBUTION.value, "trend_score": 40}, score=40)
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 70}, score=70)
weekly_p = _er(
"Phase",
{"phase": WyckoffPhase.B.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 65},
score=65,
)
weekly_e = _er("Event", {"current_event": WyckoffEvent.ST.value, "recent_events": ["SC", "AR", "ST"]}, score=60)
daily_e = _er(
"Event",
{"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring"], "entry_score": 92},
score=92,
confidence=92,
)
daily_s = _er("Signal", {"signal_label": "Spring", "current_event": "Spring"}, confidence=92, score=92)
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
assert out.payload["decision_signal"] == DecisionSignal.WATCH.value
assert out.payload["d_event"] == WyckoffEvent.SPRING.value
def test_bull_alignment_can_strong_buy():
eng = DecisionEngine()
monthly = _er("Cycle", {"cycle": WyckoffCycle.MARKUP.value, "trend_score": 90}, score=90, confidence=90)
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 85}, score=85, confidence=85)
weekly_p = _er(
"Phase",
{"phase": WyckoffPhase.D.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 88},
score=88,
confidence=88,
)
weekly_e = _er("Event", {"current_event": WyckoffEvent.SOS.value, "recent_events": ["SOS"]}, score=85, confidence=85)
daily_e = _er(
"Event",
{
"current_event": WyckoffEvent.SPRING.value,
"recent_events": ["SC", "AR", "ST", "Spring", "Test"],
"active_events": ["SC", "AR", "ST", "Spring"],
"entry_score": 92,
},
score=92,
confidence=92,
)
daily_s = _er("Signal", {"signal_label": "Spring"}, confidence=92, score=92)
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
assert out.payload["decision_signal"] in (
DecisionSignal.STRONG_BUY.value,
DecisionSignal.BUY.value,
)
+2 -2
View File
@@ -43,9 +43,9 @@ def test_analyze_contract_keys_file():
)
)
keys = doc["required"] if isinstance(doc, dict) and "required" in doc else doc
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list"):
for k in ("kline_data", "bi_list", "seg_list", "zs_list", "bsp_list", "wyckoff"):
assert k in keys
if isinstance(doc, dict):
assert "include_wyckoff" in doc.get("optional_when", {})
assert "include_wyckoff" not in doc.get("optional_when", {})
for k in ("trading_range", "phases", "events", "volume_profile"):
assert k in doc.get("wyckoff_keys", [])
+259 -1
View File
@@ -11,7 +11,11 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from chanlun.analysis.wyckoff import analyze_wyckoff # noqa: E402
from chanlun.analysis.wyckoff.range import detect_trading_range # noqa: E402
from chanlun.analysis.wyckoff.range import ( # noqa: E402
detect_trading_range,
detect_trading_ranges,
_overlap_ratio,
)
def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFrame:
@@ -87,6 +91,7 @@ def _box_df(n_box: int = 60, spring: bool = True, sos: bool = True) -> pd.DataFr
def test_wyckoff_detects_range_and_events():
"""Test C:旧接口兼容 — 顶层字段仍在,且 cycles[0] 为 ACTIVE 镜像。"""
df = _box_df()
out = analyze_wyckoff(df, lookback=200)
assert out["trading_range"] is not None
@@ -105,6 +110,55 @@ def test_wyckoff_detects_range_and_events():
assert len(out["phases"]) >= 3
keys = [(p["start_time"], p["end_time"]) for p in out["phases"]]
assert len(keys) == len(set(keys)), "phases must not share identical start/end"
# cycles 契约
assert len(out.get("cycles") or []) >= 1
c0 = out["cycles"][0]
assert c0["status"] == "ACTIVE"
assert c0["id"] == 0
assert c0["trading_range"]["start_time"] == out["trading_range"]["start_time"]
assert c0["trading_range"]["high"] == out["trading_range"]["high"]
assert "confidence" in c0 and "overall" in c0["confidence"]
assert "period" in c0 and c0["period"]["bars"] > 0
def test_phase_c_when_spring_eaten_by_box_low():
"""箱沿吃掉 Spring 最低点时,仍应靠结构次低检出 Spring,并有阶段 C。"""
rng = np.random.default_rng(1)
t0 = pd.Timestamp("2024-06-01", tz="UTC")
rows = []
box_lo, box_hi = 40.0, 60.0
for i in range(60):
c = box_lo + (box_hi - box_lo) * (0.3 + 0.4 * rng.random())
o = c
h = min(box_hi, max(o, c) + 1)
l = max(box_lo, min(o, c) - 1)
if i % 7 == 0:
h = box_hi - 0.2
if i % 7 == 3:
l = box_lo + 0.2
rows.append((t0 + pd.Timedelta(hours=4 * i), o, h, l, c, 100.0))
# 箱内假破:最低点 38,收回到 43
rows[45] = (rows[45][0], 42.0, 45.0, 38.0, 43.0, 80.0)
for j in range(3):
rows.append((t0 + pd.Timedelta(hours=4 * (60 + j)), 61.0, 63.0, 60.5, 62.0, 150.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
# 模拟 4hTR.low 已吃进 Spring
tr = {
"abs_start_idx": 0,
"abs_end_idx": 59,
"abs_scan_end_idx": len(df) - 1,
"high": 60.0,
"low": 38.0,
"mid": 49.0,
"tol": 1.0,
}
from chanlun.analysis.wyckoff.events import detect_bias_and_events, build_phases
bias, ev, _ = detect_bias_and_events(df, tr)
ph = build_phases(df, tr, bias, ev)
assert "Spring" in {e["type"] for e in ev}
assert "C" in {p["phase"] for p in ph}
assert bias == "accumulation"
def test_range_scoring_skips_pretrend():
df = _box_df(spring=False, sos=False)
@@ -113,6 +167,44 @@ def test_range_scoring_skips_pretrend():
assert tr["abs_start_idx"] >= 12 # 不应从 bar 0 吞掉整段下跌
def test_range_anchored_rejects_full_trend():
"""整段趋势+末端箱:硬锚数据起点应因过宽回落,仍能搜出末端箱。"""
rng = np.random.default_rng(0)
t0 = pd.Timestamp("2024-06-01", tz="UTC")
rows = []
price = 100.0
for i in range(200):
price += 0.4 + rng.random() * 0.2
o, c = price - 0.1, price
h, l = max(o, c) + 0.3, min(o, c) - 0.3
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
lo, hi = price - 5, price + 5
for i in range(80):
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
o = c + rng.normal(0, 0.3)
h = min(hi + 0.5, max(o, c) + 0.4)
l = max(lo - 0.5, min(o, c) - 0.4)
if i % 8 == 0:
h = hi - 0.1
if i % 8 == 3:
l = lo + 0.1
rows.append((t0 + pd.Timedelta(hours=200 + i), o, h, l, c, 90.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
# 硬锚整段 → 应回落自由搜索,起点落在箱体附近而非 bar0
tr = detect_trading_range(df, lookback=len(df), range_start_time=df["date"].iloc[0])
assert tr is not None
assert tr["abs_start_idx"] >= 150
assert tr["bars"] < 120
assert (tr["high"] - tr["low"]) / tr["atr"] < 15
# Web 路径:整段 lookback、不锚起点
out = analyze_wyckoff(df, lookback=len(df), min_bars=max(24, len(df) // 12))
assert out["trading_range"] is not None
assert out["trading_range"]["bars"] < 120
assert out["trading_range"]["bars"] >= 24
def test_volume_profile_poc_on_heavy_bin():
dates = pd.date_range("2024-01-01", periods=40, freq="5min", tz="UTC")
rows = []
@@ -126,3 +218,169 @@ def test_volume_profile_poc_on_heavy_bin():
assert vp["poc"] is not None
assert vp["vah"] is not None and vp["val"] is not None
assert abs(vp["poc"] - 50.0) < 1.0
def test_live_does_not_pollute_confirmed_events():
"""Live 形成中:confirmed.events 不含 candidatelive 可有 Spring candidate。"""
from chanlun.analysis.wyckoff.live import analyze_live_structure
rng = np.random.default_rng(11)
t0 = pd.Timestamp("2024-05-01", tz="UTC")
rows = []
lo, hi = 40.0, 60.0
for i in range(40):
c = lo + (hi - lo) * (0.35 + 0.3 * rng.random())
o = c
h = min(hi, max(o, c) + 0.8)
l = max(lo, min(o, c) - 0.8)
rows.append((t0 + pd.Timedelta(hours=i), o, h, l, c, 100.0))
# 正在测下沿:长下影,尚未形成 Confirmed Spring 所需的刺破+收回序列写进 events 引擎
rows.append((t0 + pd.Timedelta(hours=40), 42.0, 44.0, 39.5, 42.5, 70.0))
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
tr = {
"abs_start_idx": 0,
"abs_end_idx": 39,
"abs_scan_end_idx": 40,
"high": 60.0,
"low": 40.0,
"mid": 50.0,
"tol": 1.0,
"atr": 1.5,
"bars": 40,
}
live = analyze_live_structure(df, tr, confirmed_events=[], confirmed_phases=[], bias="accumulation")
assert live["lifecycle"] in ("FORMING", "UNKNOWN", "CONFIRMED")
# 无 confirmed 输入时,candidates 可含 Spring,且 confirmed flag 全 false
for c in live.get("event_candidates") or []:
assert c.get("confirmed") is False
# 完整 analyze:顶层 events 不得因 live 凭空增加假 Spring(本合成无真 Spring
out = analyze_wyckoff(df, lookback=len(df), min_bars=20)
assert "Spring" not in {e["type"] for e in (out.get("events") or [])} or out["lifecycle"] == "CONFIRMED"
# live 与 confirmed 分离
c0 = (out.get("cycles") or [{}])[0]
if c0.get("live") and c0["live"].get("event_candidates"):
for c in c0["live"]["event_candidates"]:
assert c.get("confirmed") is False
confirmed_types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or []}
for c in c0["live"]["event_candidates"]:
# candidate 不应出现在 confirmed(同 type 且仅 candidate
if c["type"] not in confirmed_types:
pass
def test_confirmed_upgrade_and_execution_isolation():
"""有 Spring+SOS 确认 → lifecycle CONFIRMEDexecution.source==confirmed。"""
from chanlun.analysis.wyckoff import execution_signal_from_wyckoff
df = _box_df(spring=True, sos=True)
out = analyze_wyckoff(df, lookback=200)
assert len(out.get("cycles") or []) >= 1
c0 = out["cycles"][0]
assert c0["status"] == "ACTIVE"
types = {e["type"] for e in (c0.get("confirmed") or {}).get("events") or out.get("events") or []}
assert "Spring" in types and "SOS" in types
assert c0.get("lifecycle") == "CONFIRMED"
# live 不得把已确认事件再标为 candidate
for c in (c0.get("live") or {}).get("event_candidates") or []:
assert c["type"] not in types
sig = execution_signal_from_wyckoff(out)
assert sig is not None
assert sig["source"] == "confirmed"
# 仅 live、无 confirmed 时不得给 execution
empty_live_only = {
"cycles": [{
"id": 0,
"lifecycle": "FORMING",
"confirmed": {"events": [], "phases": []},
"live": {"event_candidates": [{"type": "Spring", "confirmed": False}]},
}],
"events": [],
}
assert execution_signal_from_wyckoff(empty_live_only) is None
def _make_box_segment(t0, n, lo, hi, freq_hours, rng, base_i=0):
rows = []
for i in range(n):
c = lo + (hi - lo) * (0.3 + 0.4 * rng.random())
o = c + rng.normal(0, 0.2)
h = min(hi + 0.3, max(o, c) + 0.4)
l = max(lo - 0.3, min(o, c) - 0.4)
if i % 8 == 0:
h = hi - 0.1
if i % 8 == 3:
l = lo + 0.1
rows.append((t0 + pd.Timedelta(hours=freq_hours * (base_i + i)), o, h, l, c, 90.0))
return rows
def test_multi_cycle_two_boxes_with_trend():
"""Test A:双箱 + 中间趋势;cycles[0] 更新、不重叠、顶层镜像 cycles[0]。"""
rng = np.random.default_rng(3)
t0 = pd.Timestamp("2024-01-01", tz="UTC")
rows = []
# 早箱 100-110
rows += _make_box_segment(t0, 50, 100.0, 110.0, 1, rng, 0)
# 中间上涨趋势
price = 110.0
for i in range(40):
price += 0.8 + rng.random() * 0.3
o, c = price - 0.2, price
h, l = max(o, c) + 0.3, min(o, c) - 0.3
rows.append((t0 + pd.Timedelta(hours=50 + i), o, h, l, c, 100.0))
# 近端箱
lo2, hi2 = price - 4, price + 4
rows += _make_box_segment(t0, 50, lo2, hi2, 1, rng, 90)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
out = analyze_wyckoff(df, lookback=len(df), min_bars=24, max_cycles=8)
cycles = out.get("cycles") or []
assert len(cycles) >= 2
assert cycles[0]["status"] == "ACTIVE"
assert cycles[1]["status"] == "HISTORICAL"
# 时间倒序:C0.end > C1.end
e0 = pd.Timestamp(cycles[0]["period"]["end_time"])
e1 = pd.Timestamp(cycles[1]["period"]["end_time"])
assert e0 > e1
# 不重叠
a0 = cycles[0]["trading_range"]
# 用引擎内部 abs 不在 payload;用 period 时间近似
s0 = pd.Timestamp(cycles[0]["period"]["start_time"])
s1 = pd.Timestamp(cycles[1]["period"]["start_time"])
# C1 应完全在 C0 之前
assert e1 <= s0 or (e1 - s0).total_seconds() <= 3600
# 顶层 == cycles[0]
assert out["trading_range"]["start_time"] == cycles[0]["trading_range"]["start_time"]
assert out["trading_range"]["high"] == cycles[0]["trading_range"]["high"]
assert out["trading_range"]["low"] == cycles[0]["trading_range"]["low"]
def test_multi_cycle_nested_box_no_overlap():
"""Test B:大箱套小箱不得产出 overlap_ratio>=0.2 的两段。"""
rng = np.random.default_rng(5)
t0 = pd.Timestamp("2024-03-01", tz="UTC")
# 大箱 80 根
rows = _make_box_segment(t0, 80, 40.0, 60.0, 1, rng, 0)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume"])
trs = detect_trading_ranges(df, lookback=len(df), min_bars=20, max_cycles=8)
# 任意两段 overlap < 0.2
for i in range(len(trs)):
for j in range(i + 1, len(trs)):
r = _overlap_ratio(
int(trs[i]["abs_start_idx"]),
int(trs[i]["abs_end_idx"]),
int(trs[j]["abs_start_idx"]),
int(trs[j]["abs_end_idx"]),
)
assert r < 0.2, f"overlap {r} between {i} and {j}"
out = analyze_wyckoff(df, lookback=len(df), min_bars=20, max_cycles=8)
cycles = out.get("cycles") or []
assert len(cycles) >= 1
assert cycles[0]["status"] == "ACTIVE"
# 若有两段,时间窗也不应高度重叠
if len(cycles) >= 2:
# period 不重叠:历史 end <= active start(允许 1h 容差)
assert pd.Timestamp(cycles[1]["period"]["end_time"]) <= pd.Timestamp(
cycles[0]["period"]["start_time"]
) + pd.Timedelta(hours=2)

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