Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97aa61705d | ||
|
|
340676bfbd | ||
|
|
9cf625c413 | ||
|
|
18a7f485e6 | ||
|
|
0f6eb92a1f | ||
|
|
9880e236a5 | ||
|
|
ec08de098e | ||
|
|
6c627f009a | ||
|
|
dbb6202325 | ||
|
|
efad2bb333 | ||
|
|
2964d6f230 | ||
|
|
7991a6b2bf | ||
|
|
276481e02c |
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
from __future__ import annotations
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -22,18 +22,25 @@
|
||||
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 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 + EXP;Live 须 Human
|
||||
- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎
|
||||
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 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-001(FROZEN)。
|
||||
|
||||
## 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.
|
||||
@@ -1,5 +1,25 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased — 2026-08-07
|
||||
|
||||
### ECR-009(L2,进行中)
|
||||
|
||||
- 独立页 `/wyckoff_crypto`:移植 A_Share_DP D/W/M 威科夫选股引擎至数字货币
|
||||
- 本地 `data/crypto_wyckoff/`;60s tip;月线由日线 UTC 自然月聚合(provider 无 1M)
|
||||
- API:`/api/wyckoff_crypto/*`;不碰主站 analyze / 缠论叠层
|
||||
|
||||
### ECR-008(L3,Reviewed)
|
||||
|
||||
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
|
||||
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
|
||||
|
||||
### ECR-007(L2,LOOP-RUN-005)
|
||||
|
||||
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||
- Event candidates(Spring/SOS/LPS/UTAD)+ 可解释 confidence;Summary Confirmed/Live 分区
|
||||
- `execution_signal_from_wyckoff` **仅** `source=confirmed`;Live-only → None
|
||||
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
|
||||
|
||||
## Unreleased — 2026-08-06
|
||||
|
||||
### ECR-004(L2,Reviewed)
|
||||
@@ -7,6 +27,7 @@
|
||||
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
|
||||
|
||||
### ECR-003(L2,Reviewed)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 → Engineer(ECR-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。
|
||||
@@ -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)
|
||||
@@ -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。
|
||||
@@ -34,10 +34,11 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化)
|
||||
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewed(chart_tv 拆分)
|
||||
- EXP: N/A
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||
- Memory: `docs/AGENT_MEMORY.md`
|
||||
- Loop archive: `docs/runs/LOOP-RUN-005/`
|
||||
|
||||
## Pointers
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# STATE
|
||||
|
||||
**owner:** idle
|
||||
**active_ecr:** none(ECR-004 Reviewed;待本批提交合入)
|
||||
**phase:** post-review
|
||||
**owner:** engineer
|
||||
**active_ecr:** ECR-009(crypto 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
|
||||
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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_OK(2026-08-07)
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
|
||||
| 语法 | PASS |
|
||||
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
|
||||
|
||||
## Design Compliance
|
||||
|
||||
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
|
||||
@@ -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 测试缺口,已补路由测试与本报告。
|
||||
@@ -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 + 填充 3;bins≤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 |
|
||||
|
||||
@@ -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 candidates:Spring / 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 面板分区
|
||||
- 测例
|
||||
@@ -0,0 +1,76 @@
|
||||
# WYCKOFF-LIVE-VALIDATION-001
|
||||
|
||||
**Status:** DRAFT(待确认执行后 FROZEN)
|
||||
**Depends on:** WYCKOFF-LIVE-STRUCTURE-001(已 FROZEN)
|
||||
**Goal:** 验证 Live 是否有预测价值,而非继续加事件规则
|
||||
|
||||
## 不做
|
||||
|
||||
- 不新增 BC / AR / ST / UT / UTAD(v1 已够)
|
||||
- 不降低 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 文案升级(独立小项)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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+非急跌斜率 → accumulation;bull+正斜率 → 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+markdown;bad = 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)
|
||||
@@ -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** 数据(无 lookahead;merge 后读的是上一根已完成 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 time(UTC) |
|
||||
| `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 DD(kept / 账户) | 应相对 ungated 历史继续偏低 |
|
||||
| range share among blocked | 仅观察;上升不自动改规则 |
|
||||
|
||||
### 2023+ OOS 参考阈值(研究窗,非调参目标)
|
||||
|
||||
| | Gated(研究) | 解读 |
|
||||
|--|---------------|------|
|
||||
| PF | ~1.34(baseline ~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. 不允许事项(硬禁)
|
||||
|
||||
- 不调 Spring(TF / ATR / stoploss / entry 形态)
|
||||
- 不调 soft-score,不把 soft-score 接回默认路径
|
||||
- 不全样本扫 Gate 阈值 / 状态集合
|
||||
- 不因短期 dry-run PF 调规则
|
||||
- 不因 `range` 小样本表现把 range 升格为交易域
|
||||
- 不默认合并 ETH/SOL 进生产路径
|
||||
- 不复活 LPS 分支
|
||||
|
||||
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
|
||||
|
||||
---
|
||||
|
||||
## 6. Go / No-Go(dry-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`
|
||||
@@ -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+ / 2024–25 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
|
||||
|
||||
## Do not
|
||||
|
||||
- 调 Spring / soft-score / Gate 阈值
|
||||
- 因 range 小样本正 PF 开放 range 交易
|
||||
- 复活 LPS / 默认跨资产
|
||||
@@ -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 stack(LOCKED)
|
||||
|
||||
```
|
||||
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 blocked:distribution share **87.5%**,blocked PF 0.37
|
||||
- 多数年份 blocked 以 distribution 为首
|
||||
- 2023+ blocked:distribution + range 并存;range **仅观察**,不改规则
|
||||
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
|
||||
|
||||
**Primary invalidation domain = distribution(稳定)**
|
||||
**range = observation bucket only**
|
||||
@@ -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 disabled(regime_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 only(Range 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
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# LPS V1.1 — REJECTED
|
||||
|
||||
## Hypothesis
|
||||
|
||||
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
|
||||
|
||||
## Result
|
||||
|
||||
- Full: **-1.50%**, n=**2**, 全亏
|
||||
- 过滤方向正确,但过度收缩 → 无统计意义
|
||||
|
||||
## Reject reason
|
||||
|
||||
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
|
||||
@@ -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。
|
||||
定义错误,不是参数问题。
|
||||
@@ -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=trend(Range 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"
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Market State Gate OOS — Baseline vs Gated(Spring 冻结)
|
||||
|
||||
比较:
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 PF(fee 计入)。
|
||||
"""
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase 2:鲁棒性验证(固定当前参数,不再扫参)
|
||||
|
||||
1) Walk-Forward:Train 2023-2024 / Validate 2025 / Test 2026
|
||||
2) 市场状态拆分:bull / bear / range(8h 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>=5;DD 越低越好;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()
|
||||
@@ -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()
|
||||
@@ -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 > 单边手续费 × 3(0.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_ago(1m 用 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 / SL(ROI/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,
|
||||
}
|
||||
]
|
||||
@@ -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_pct;short: 下跌 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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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_reason:time_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)
|
||||
@@ -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)
|
||||
@@ -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 disabled(regime_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 only(Range 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,197 +0,0 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC — Market-State Gated Spring(Decision Layer)
|
||||
|
||||
Spring = V1_BASELINE(FROZEN)
|
||||
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
|
||||
@@ -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_BASELINE(Spring-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
|
||||
@@ -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 disabled(regime_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 only(Range 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,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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]=ACTIVE;phases/events=Confirmed;live=Developing(WYCKOFF-LIVE-STRUCTURE-001);Execution 仅 Confirmed;见 docs/notes/"
|
||||
}
|
||||
|
||||
@@ -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_wyckoff;include_wyckoff=0 可跳过;elements_only 时不返回",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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"])
|
||||
# 模拟 4h:TR.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 不含 candidate;live 可有 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 CONFIRMED;execution.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
Reference in New Issue
Block a user