Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e16edd2c9 | ||
|
|
5c10e35b76 | ||
|
|
8c165f11cd | ||
|
|
97e77847d0 | ||
|
|
90499533fb | ||
|
|
8ee11317d3 |
@@ -40,7 +40,3 @@ feature_meta
|
||||
.DS_Store
|
||||
data_provider/._config.json
|
||||
.gstack/
|
||||
|
||||
# ESS gate / engineering-loop working dirs(归档进 docs/runs/)
|
||||
.gates/
|
||||
loop/
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
|
||||
|
||||
不改 init_TF_DF 的整段语义。笔必须整表重扫:最后一笔 is_sure 允许收回
|
||||
(OWN_CHAN_ZS_001 上 60 天出现 7 次)。笔中枢用 cal_bi_zs_list_pure。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
|
||||
|
||||
class IncrementalBuilderMixin:
|
||||
def init_stream(self, df, interval=1, timeframe=None):
|
||||
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
|
||||
if df is None or df.empty:
|
||||
raise ValueError("DataFrame for stream is empty.")
|
||||
if "date" not in df.columns:
|
||||
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
|
||||
self.timeframe = timeframe
|
||||
self.interval = interval
|
||||
if interval == 1:
|
||||
self.dataframe = df.copy()
|
||||
else:
|
||||
self.dataframe = resample_to_interval(df, interval)
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self.klu_list = []
|
||||
self.klc_list = []
|
||||
self.bi_list = []
|
||||
self.bi_zs_list = []
|
||||
self.seg_list = []
|
||||
self.zs_list = []
|
||||
self.bsp_list = []
|
||||
self.klc_fx_list = []
|
||||
self.big_zs_list = []
|
||||
self._klc_feed_last_klu = None
|
||||
for i in range(len(self.dataframe)):
|
||||
self._append_row_at(i, rebuild=False)
|
||||
self.rebuild_bi_zs()
|
||||
return self
|
||||
|
||||
def append_bar(self, row):
|
||||
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
|
||||
self._ensure_stream_state()
|
||||
item = self._normalize_row(row)
|
||||
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
|
||||
return self.replace_last_bar(item)
|
||||
self._append_item_to_dataframe(item)
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
|
||||
return self
|
||||
|
||||
def replace_last_bar(self, row):
|
||||
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
|
||||
self._ensure_stream_state()
|
||||
if not self.klu_list:
|
||||
return self.append_bar(row)
|
||||
item = self._normalize_row(row)
|
||||
idx = self.dataframe.index[-1]
|
||||
for key, val in item.items():
|
||||
self.dataframe.at[idx, key] = val
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
|
||||
self._rebuild_klc_from_klu()
|
||||
self.rebuild_bi_zs()
|
||||
return self
|
||||
|
||||
def rebuild_bi_zs(self):
|
||||
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
|
||||
self._reset_klc_bi_marks(self.klc_list)
|
||||
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
|
||||
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
|
||||
return self.bi_zs_list
|
||||
|
||||
def _ensure_stream_state(self):
|
||||
if not hasattr(self, "klu_list") or self.klu_list is None:
|
||||
self.klu_list = []
|
||||
if not hasattr(self, "klc_list") or self.klc_list is None:
|
||||
self.klc_list = []
|
||||
if not hasattr(self, "dataframe") or self.dataframe is None:
|
||||
self.dataframe = DataFrame(
|
||||
columns=["date", "open", "high", "low", "close", "volume"]
|
||||
)
|
||||
if not hasattr(self, "_klc_feed_last_klu"):
|
||||
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
|
||||
if not hasattr(self, "bi_zs_list"):
|
||||
self.bi_zs_list = []
|
||||
|
||||
def _rebuild_klc_from_klu(self):
|
||||
self.klc_list = []
|
||||
last_klu = None
|
||||
for klu in self.klu_list:
|
||||
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
|
||||
last_klu = klu
|
||||
self._klc_feed_last_klu = last_klu
|
||||
|
||||
def _append_row_at(self, idx, rebuild=True):
|
||||
item = self.dataframe.iloc[idx]
|
||||
klu = self._klu_from_item(item, idx)
|
||||
if self.klu_list:
|
||||
self.klu_list[-1].set_next(klu)
|
||||
klu.set_pre(self.klu_list[-1])
|
||||
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
|
||||
self._klc_feed_last_klu = klu
|
||||
self.klu_list.append(klu)
|
||||
if rebuild:
|
||||
self.rebuild_bi_zs()
|
||||
|
||||
def _klu_from_item(self, item, idx):
|
||||
klu = ChanKLU(
|
||||
self._item_time_str(item),
|
||||
item["open"],
|
||||
item["high"],
|
||||
item["low"],
|
||||
item["close"],
|
||||
item["volume"],
|
||||
)
|
||||
klu.set_idx(idx)
|
||||
if not hasattr(klu, "ema13"):
|
||||
klu.ema13 = 0
|
||||
if "macd" in item:
|
||||
klu.set_indicators(item)
|
||||
return klu
|
||||
|
||||
def _apply_item_to_klu(self, klu, item):
|
||||
klu.time = self._item_time_str(item)
|
||||
klu.open = item["open"]
|
||||
klu.high = item["high"]
|
||||
klu.low = item["low"]
|
||||
klu.close = item["close"]
|
||||
klu.volume = item["volume"]
|
||||
klu.range = klu.high - klu.low
|
||||
klu.body = abs(klu.close - klu.open)
|
||||
if "macd" in item:
|
||||
klu.set_indicators(item)
|
||||
|
||||
def _reset_klc_bi_marks(self, klc_list):
|
||||
for klc in klc_list:
|
||||
klc.fx = Chan_FX_TYPE.UNKNOWN
|
||||
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
|
||||
klc.klc_state = Chan_KLC_STATE.UNKNOWN
|
||||
klc.bi = None
|
||||
klc.fx_confirmed = False
|
||||
|
||||
def _item_time_str(self, item):
|
||||
date = item["date"]
|
||||
if hasattr(date, "to_pydatetime"):
|
||||
date = date.to_pydatetime()
|
||||
if isinstance(date, datetime):
|
||||
return date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(date)
|
||||
|
||||
def _row_time_str(self, item):
|
||||
return self._item_time_str(item)
|
||||
|
||||
def _normalize_row(self, row):
|
||||
if isinstance(row, pd.Series):
|
||||
return row
|
||||
return pd.Series(row)
|
||||
|
||||
def _append_item_to_dataframe(self, item):
|
||||
row_df = DataFrame([item])
|
||||
if self.dataframe is None or self.dataframe.empty:
|
||||
self.dataframe = row_df
|
||||
else:
|
||||
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
|
||||
@@ -86,7 +86,8 @@ class KlineBuilderMixin:
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
def check_fx(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
|
||||
if klc.pre and klc.next and klc.next.end_klu is not None:
|
||||
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
@@ -171,6 +172,43 @@ 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
|
||||
@@ -198,41 +236,7 @@ class KlineBuilderMixin:
|
||||
ema_down_list.append(ema_down_count)
|
||||
#print(last_klu.time, ema_down_count, "DOWN END")
|
||||
ema_down_count = 0
|
||||
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)
|
||||
self._push_klu_into_klc_list(klc_list, klu, last_klu)
|
||||
last_klu = klu
|
||||
klc_list = self.cal_trend(klc_list)
|
||||
#print(ema52_up_list, ema52_down_list)
|
||||
|
||||
@@ -355,9 +355,12 @@ class ZsBuilderMixin:
|
||||
return bi_zs_list
|
||||
|
||||
def get_zs_range(bis):
|
||||
zg = min(bi.high for bi in bis)
|
||||
zd = max(bi.low for bi in bis)
|
||||
return zg, zd
|
||||
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
|
||||
|
||||
def is_bi_overlap_range(bi, zg, zd):
|
||||
return bi.high >= zd and bi.low <= zg
|
||||
@@ -375,8 +378,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
|
||||
@@ -394,7 +397,7 @@ class ZsBuilderMixin:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg, zd = get_zs_range([bi1, bi2, bi3])
|
||||
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3])
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
@@ -420,6 +423,8 @@ 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,6 +178,15 @@ 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,12 +31,13 @@ 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):
|
||||
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin, IncrementalBuilderMixin):
|
||||
def __init__(self, df=None, interval=0, timeframe=None):
|
||||
if df is not None:
|
||||
self.init_TF_DF(df, interval, timeframe)
|
||||
@@ -59,12 +60,14 @@ 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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
_CHAN = Path(__file__).resolve().parents[2]
|
||||
if str(_CHAN) not in sys.path:
|
||||
sys.path.insert(0, str(_CHAN))
|
||||
|
||||
from chanlun.pipeline.timeframe import TF_DF # noqa: E402
|
||||
|
||||
|
||||
def _zigzag_df(n=160, step=8):
|
||||
dates = pd.date_range("2024-01-01", periods=n, freq="5min")
|
||||
rows = []
|
||||
price = 100.0
|
||||
for i, date in enumerate(dates):
|
||||
up = (i // step) % 2 == 0
|
||||
if up:
|
||||
o = price
|
||||
c = price + 1.5
|
||||
h = c + 0.3
|
||||
l = o - 0.2
|
||||
else:
|
||||
o = price
|
||||
c = price - 1.5
|
||||
h = o + 0.2
|
||||
l = c - 0.3
|
||||
price = c
|
||||
rows.append(
|
||||
{
|
||||
"date": date,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": 1.0,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _sure_bi_key(bi):
|
||||
return (str(bi.start_time), bi.dir.name, round(float(bi.high), 6), round(float(bi.low), 6))
|
||||
|
||||
|
||||
def _zs_key(zs):
|
||||
return (
|
||||
str(zs.start_time),
|
||||
round(float(zs.zg), 6),
|
||||
round(float(zs.zd), 6),
|
||||
len(zs.bi_list),
|
||||
)
|
||||
|
||||
|
||||
class TestIncremental(unittest.TestCase):
|
||||
def test_init_stream_matches_batch_push(self):
|
||||
df = _zigzag_df()
|
||||
stream = TF_DF()
|
||||
stream.init_stream(df, 1, "5m")
|
||||
|
||||
batch = TF_DF()
|
||||
indexed = batch.add_indicators(df.copy())
|
||||
klu = batch.cal_kl_data(indexed)
|
||||
klc = []
|
||||
last = None
|
||||
for k in klu:
|
||||
batch._push_klu_into_klc_list(klc, k, last)
|
||||
last = k
|
||||
batch.klc_list = klc
|
||||
batch.rebuild_bi_zs()
|
||||
|
||||
self.assertEqual(len(stream.klu_list), len(klu))
|
||||
self.assertEqual(len(stream.klc_list), len(klc))
|
||||
self.assertEqual(
|
||||
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||
[_sure_bi_key(b) for b in batch.bi_list if b.is_sure],
|
||||
)
|
||||
self.assertEqual(
|
||||
[_zs_key(z) for z in stream.bi_zs_list],
|
||||
[_zs_key(z) for z in batch.bi_zs_list],
|
||||
)
|
||||
|
||||
def test_append_bar_matches_init_stream(self):
|
||||
df = _zigzag_df()
|
||||
stream = TF_DF()
|
||||
stream.init_stream(df, 1, "5m")
|
||||
|
||||
inc = TF_DF()
|
||||
for _, row in df.iterrows():
|
||||
inc.append_bar(row)
|
||||
|
||||
self.assertEqual(len(inc.klu_list), len(stream.klu_list))
|
||||
self.assertEqual(len(inc.klc_list), len(stream.klc_list))
|
||||
self.assertEqual(
|
||||
[_sure_bi_key(b) for b in inc.bi_list if b.is_sure],
|
||||
[_sure_bi_key(b) for b in stream.bi_list if b.is_sure],
|
||||
)
|
||||
self.assertEqual(
|
||||
[_zs_key(z) for z in inc.bi_zs_list],
|
||||
[_zs_key(z) for z in stream.bi_zs_list],
|
||||
)
|
||||
|
||||
def test_replace_last_bar_keeps_count(self):
|
||||
df = _zigzag_df(n=80)
|
||||
tf = TF_DF()
|
||||
tf.init_stream(df, 1, "5m")
|
||||
n_klu = len(tf.klu_list)
|
||||
last = df.iloc[-1].copy()
|
||||
last["close"] = float(last["close"]) + 0.01
|
||||
last["high"] = max(float(last["high"]), float(last["close"]))
|
||||
tf.replace_last_bar(last)
|
||||
self.assertEqual(len(tf.klu_list), n_klu)
|
||||
self.assertGreater(len(tf.klc_list), 0)
|
||||
|
||||
def test_check_fx_skips_forming_right_wing(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE
|
||||
|
||||
tf = TF_DF()
|
||||
pre = SimpleNamespace(high=10, low=8)
|
||||
nxt_open = SimpleNamespace(high=11, low=7, end_klu=None)
|
||||
nxt_done = SimpleNamespace(high=11, low=7, end_klu=object())
|
||||
center = SimpleNamespace(
|
||||
pre=pre,
|
||||
next=nxt_open,
|
||||
high=12,
|
||||
low=9,
|
||||
set_fx=lambda *_a, **_k: None,
|
||||
)
|
||||
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.UNKNOWN)
|
||||
center.next = nxt_done
|
||||
self.assertEqual(tf.check_fx(center), Chan_FX_TYPE.TOP)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "BTC_Maker_Micro_Scalper",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": true,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 1,
|
||||
"exit": 1,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8821,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "change_me_mms_v1",
|
||||
"ws_token": "change_me_mms_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "BTC_Maker_Micro_Scalper",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "BTC_Maker_Micro_Scalper_v11",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.btc_maker_micro_scalper_v11.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": true,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 3,
|
||||
"exit": 2,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8822,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "change_me_mms_v11",
|
||||
"ws_token": "change_me_mms_v11_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "BTC_Maker_Micro_Scalper_v11",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,15 @@
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {},
|
||||
"ccxt_async_config": {},
|
||||
"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"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"strategy": "MakerEdgeProbe",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.maker_edge_probe.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1m",
|
||||
"process_only_new_candles": false,
|
||||
"fee": 0.00016,
|
||||
"unfilledtimeout": {
|
||||
"entry": 3,
|
||||
"exit": 2,
|
||||
"exit_timeout_count": 3,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "market",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"order_time_in_force": {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "YOUR_BINANCE_API_KEY",
|
||||
"secret": "YOUR_BINANCE_API_SECRET",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": true,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "maker_edge_probe_change_me",
|
||||
"ws_token": "maker_edge_probe_ws",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "MakerEdgeProbe",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.turtle_btc.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "15m",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 15,
|
||||
"exit": 15,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8822,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "turtle-btc-change-me",
|
||||
"ws_token": "turtle-btc-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "turtle_btc",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-btc-change-me",
|
||||
"ws_token": "wyckoff-btc-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_gated.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8825,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-gated-change-me",
|
||||
"ws_token": "wyckoff-gated-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_gated",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_lps.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8824,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-lps-change-me",
|
||||
"ws_token": "wyckoff-lps-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_lps",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_v1_baseline",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0)."""
|
||||
|
||||
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||
|
||||
__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Walk-forward Wyckoff phase/event annotations for chart overlay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.cycle import CycleEngine
|
||||
from crypto_wyckoff.event import EventEngine
|
||||
from crypto_wyckoff.features import FeatureEngine
|
||||
from crypto_wyckoff.phase import PhaseEngine
|
||||
|
||||
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||
|
||||
_NOTABLE_EVENTS = {
|
||||
WyckoffEvent.PS.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.BC.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
}
|
||||
|
||||
|
||||
def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame:
|
||||
n = end_idx + 1
|
||||
return OHLCVFrame(
|
||||
ts_code=frame.ts_code,
|
||||
timeframe=frame.timeframe,
|
||||
trade_dates=frame.trade_dates[:n],
|
||||
open=frame.open[:n],
|
||||
high=frame.high[:n],
|
||||
low=frame.low[:n],
|
||||
close=frame.close[:n],
|
||||
volume=frame.volume[:n],
|
||||
amount=frame.amount[:n] if frame.amount else [],
|
||||
)
|
||||
|
||||
|
||||
def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
|
||||
"""points: [(date_iso, phase), ...] → segments."""
|
||||
if not points:
|
||||
return []
|
||||
segs: list[dict] = []
|
||||
start, phase = points[0]
|
||||
prev = start
|
||||
for d, p in points[1:]:
|
||||
if p != phase:
|
||||
segs.append({"start": start, "end": prev, "phase": phase})
|
||||
start, phase = d, p
|
||||
prev = d
|
||||
segs.append({"start": start, "end": prev, "phase": phase})
|
||||
return segs
|
||||
|
||||
|
||||
def annotate_frame(
|
||||
frame: OHLCVFrame,
|
||||
step: int | None = None,
|
||||
*,
|
||||
role: str | None = None,
|
||||
) -> dict:
|
||||
"""Pure annotation: phase bands + event markers + latest levels.
|
||||
|
||||
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe.
|
||||
``step`` defaults by role to keep interactive charts snappy.
|
||||
"""
|
||||
tf = role or frame.timeframe
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
if step is None:
|
||||
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
|
||||
|
||||
empty = {
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"levels": {},
|
||||
"bars": len(frame),
|
||||
"timeframe": tf,
|
||||
}
|
||||
if frame.empty or len(frame) < min_bars:
|
||||
return empty
|
||||
|
||||
feat_eng = FeatureEngine()
|
||||
cycle_eng = CycleEngine()
|
||||
phase_eng = PhaseEngine()
|
||||
event_eng = EventEngine()
|
||||
|
||||
phase_points: list[tuple[str, str]] = []
|
||||
events: list[dict] = []
|
||||
last_event: str | None = None
|
||||
levels: dict = {}
|
||||
|
||||
# Ensure last bar is always evaluated
|
||||
indices = list(range(min_bars - 1, len(frame), step))
|
||||
if indices[-1] != len(frame) - 1:
|
||||
indices.append(len(frame) - 1)
|
||||
|
||||
for i in indices:
|
||||
sub = _slice_frame(frame, i)
|
||||
f = feat_eng.run(sub, tf)
|
||||
c = cycle_eng.run(f, tf)
|
||||
p = phase_eng.run(c, f, tf)
|
||||
e = event_eng.run(c, p, f, tf)
|
||||
|
||||
d = str(frame.trade_dates[i])[:10]
|
||||
phase = p.payload.get("phase") or WyckoffPhase.NONE.value
|
||||
phase_points.append((d, phase))
|
||||
|
||||
cur = e.payload.get("current_event") or WyckoffEvent.NONE.value
|
||||
if cur in _NOTABLE_EVENTS and cur != last_event:
|
||||
events.append({
|
||||
"date": d,
|
||||
"event": cur,
|
||||
"price": float(frame.close[i]),
|
||||
"low": float(frame.low[i]),
|
||||
"high": float(frame.high[i]),
|
||||
})
|
||||
last_event = cur
|
||||
elif cur == WyckoffEvent.NONE.value:
|
||||
last_event = None
|
||||
|
||||
if i == len(frame) - 1 and not f.payload.get("insufficient"):
|
||||
levels = {
|
||||
k: f.payload.get(k)
|
||||
for k in (
|
||||
"range_high", "range_low", "ma20", "ma60",
|
||||
"swing_high", "swing_low", "close",
|
||||
)
|
||||
if f.payload.get(k) is not None
|
||||
}
|
||||
levels["phase"] = phase
|
||||
levels["cycle"] = c.payload.get("cycle")
|
||||
levels["current_event"] = cur
|
||||
|
||||
return {
|
||||
"phases": _compress_phases(phase_points),
|
||||
"events": events,
|
||||
"levels": levels,
|
||||
"bars": len(frame),
|
||||
"timeframe": tf,
|
||||
}
|
||||
|
||||
|
||||
_RANGE_CYCLES = {
|
||||
WyckoffCycle.ACCUMULATION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
}
|
||||
|
||||
|
||||
def _build_range_zones(
|
||||
price_frame: OHLCVFrame,
|
||||
cycle_segs: list[dict],
|
||||
levels: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build price boxes (high/low × date span) for accum/distrib ranges."""
|
||||
if price_frame.empty:
|
||||
return []
|
||||
dates = [str(d)[:10] for d in price_frame.trade_dates]
|
||||
highs = price_frame.high
|
||||
lows = price_frame.low
|
||||
zones: list[dict] = []
|
||||
|
||||
for seg in cycle_segs or []:
|
||||
cy = seg.get("cycle")
|
||||
if cy not in _RANGE_CYCLES:
|
||||
continue
|
||||
start, end = seg["start"], seg["end"]
|
||||
idxs = [i for i, d in enumerate(dates) if start <= d <= end]
|
||||
if not idxs:
|
||||
# weekly bar date may sit between daily bars — take nearest window
|
||||
i0 = next((i for i, d in enumerate(dates) if d >= start), None)
|
||||
if i0 is None:
|
||||
continue
|
||||
i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1
|
||||
idxs = list(range(i0, max(i0, i1) + 1))
|
||||
if not idxs:
|
||||
continue
|
||||
# pad short weekly hits to at least ~1 week of dailies for visibility
|
||||
if len(idxs) < 5 and idxs[-1] + 1 < len(dates):
|
||||
extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1])
|
||||
idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra)))
|
||||
hi = max(highs[i] for i in idxs)
|
||||
lo = min(lows[i] for i in idxs)
|
||||
if hi <= lo:
|
||||
continue
|
||||
zones.append({
|
||||
"kind": cy,
|
||||
"start": dates[idxs[0]],
|
||||
"end": dates[idxs[-1]],
|
||||
"high": float(hi),
|
||||
"low": float(lo),
|
||||
"current": False,
|
||||
})
|
||||
|
||||
# Always expose the latest trading-range box from feature snapshot
|
||||
levels = levels or {}
|
||||
rh, rl = levels.get("range_high"), levels.get("range_low")
|
||||
if rh is not None and rl is not None and float(rh) > float(rl):
|
||||
look = min(60, len(dates))
|
||||
cy = levels.get("cycle") or "Unknown"
|
||||
if cy not in _RANGE_CYCLES:
|
||||
# Phase B/C in a range → treat as accumulation-style TR for display
|
||||
ph = levels.get("phase") or ""
|
||||
if ph in ("A", "B", "C"):
|
||||
cy = WyckoffCycle.ACCUMULATION.value
|
||||
elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh):
|
||||
cy = WyckoffCycle.ACCUMULATION.value
|
||||
else:
|
||||
cy = "Range"
|
||||
zones.append({
|
||||
"kind": cy,
|
||||
"start": dates[-look],
|
||||
"end": dates[-1],
|
||||
"high": float(rh),
|
||||
"low": float(rl),
|
||||
"current": True,
|
||||
})
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def annotate_symbol(
|
||||
ts_code: str,
|
||||
freq: str,
|
||||
end_date: date | None = None,
|
||||
lookback: int = 180,
|
||||
*,
|
||||
combo_id: str | None = None,
|
||||
) -> dict:
|
||||
"""IO + annotate for one symbol (used by API).
|
||||
|
||||
For the combo *low* chart, phase bands come from **mid** structure,
|
||||
while event markers / levels come from the low TF.
|
||||
"""
|
||||
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo
|
||||
from crypto_wyckoff.io import load_frame
|
||||
|
||||
combo = get_combo(combo_id)
|
||||
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||
if freq not in allowed:
|
||||
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
|
||||
empty = {
|
||||
"ts_code": ts_code,
|
||||
"freq": freq,
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"levels": {},
|
||||
"zones": [],
|
||||
"bars": 0,
|
||||
"phase_source": freq,
|
||||
"cycles": [],
|
||||
"combo_id": combo["id"],
|
||||
}
|
||||
_ = end_date
|
||||
|
||||
if freq == combo["low"]:
|
||||
low = load_frame(ts_code, combo["low"], lookback)
|
||||
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3))
|
||||
if low is None:
|
||||
return empty
|
||||
d_ann = annotate_frame(low, role=ROLE_LOW)
|
||||
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []}
|
||||
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else []
|
||||
levels = d_ann.get("levels") or {}
|
||||
if cycles:
|
||||
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")}
|
||||
for p in reversed(w_ann.get("phases") or []):
|
||||
if p.get("phase") not in (None, "None"):
|
||||
levels = {**levels, "phase": p["phase"]}
|
||||
break
|
||||
return {
|
||||
"ts_code": ts_code,
|
||||
"freq": freq,
|
||||
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None,
|
||||
"phases": w_ann.get("phases") or [],
|
||||
"events": d_ann.get("events") or [],
|
||||
"levels": d_ann.get("levels") or {},
|
||||
"zones": _build_range_zones(low, cycles, levels),
|
||||
"bars": d_ann.get("bars", 0),
|
||||
"phase_source": combo["mid"],
|
||||
"cycles": cycles,
|
||||
"combo_id": combo["id"],
|
||||
}
|
||||
|
||||
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH
|
||||
frame = load_frame(ts_code, freq, lookback)
|
||||
if frame is None:
|
||||
return empty
|
||||
out = annotate_frame(frame, role=role)
|
||||
out["ts_code"] = ts_code
|
||||
out["freq"] = freq
|
||||
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None
|
||||
out["phase_source"] = freq
|
||||
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID)
|
||||
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
|
||||
out["combo_id"] = combo["id"]
|
||||
if role == ROLE_HIGH:
|
||||
if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
|
||||
out["phases"] = [
|
||||
{"start": c["start"], "end": c["end"], "phase": c["cycle"]}
|
||||
for c in out["cycles"]
|
||||
if c.get("cycle") and c["cycle"] != "Unknown"
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _cycle_segments(
|
||||
frame: OHLCVFrame,
|
||||
step: int | None = None,
|
||||
*,
|
||||
role: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Walk-forward cycle labels compressed to segments."""
|
||||
tf = role or frame.timeframe
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
if step is None:
|
||||
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
|
||||
if frame.empty or len(frame) < min_bars:
|
||||
return []
|
||||
|
||||
feat_eng = FeatureEngine()
|
||||
cycle_eng = CycleEngine()
|
||||
points: list[tuple[str, str]] = []
|
||||
indices = list(range(min_bars - 1, len(frame), step))
|
||||
if indices[-1] != len(frame) - 1:
|
||||
indices.append(len(frame) - 1)
|
||||
for i in indices:
|
||||
sub = _slice_frame(frame, i)
|
||||
f = feat_eng.run(sub, tf)
|
||||
c = cycle_eng.run(f, tf)
|
||||
points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown"))
|
||||
segs = _compress_phases(points)
|
||||
return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Multi-timeframe combo presets for Crypto Wyckoff Screener.
|
||||
|
||||
Roles (engine rule aliases stay D/W/M):
|
||||
high → Cycle (rules as 1M)
|
||||
mid → Phase (rules as 1w)
|
||||
low → Event (rules as 1d)
|
||||
|
||||
Actual bar TFs come from the combo (e.g. 8h/4h/1h).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.io import DATA_DIR, ensure_dirs
|
||||
|
||||
ROLE_LOW = "1d"
|
||||
ROLE_MID = "1w"
|
||||
ROLE_HIGH = "1M"
|
||||
|
||||
# Minutes for ordering / validation (provider labels)
|
||||
_TF_MINUTES: dict[str, int] = {
|
||||
"1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5,
|
||||
"10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45,
|
||||
"1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300,
|
||||
"6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600,
|
||||
"11h": 660, "12h": 720, "16h": 960, "20h": 1200,
|
||||
"1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640,
|
||||
"1w": 10080, "2w": 20160, "3w": 30240,
|
||||
"1M": 43200,
|
||||
}
|
||||
|
||||
# TFs we allow in custom combos (provider-backed + local 1M)
|
||||
ALLOWED_TFS: tuple[str, ...] = (
|
||||
"1h", "2h", "3h", "4h", "6h", "8h", "12h",
|
||||
"1d", "2d", "3d", "1w", "1M",
|
||||
)
|
||||
|
||||
BUILTIN: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "h8_4_1",
|
||||
"label": "8h / 4h / 1h",
|
||||
"high": "8h",
|
||||
"mid": "4h",
|
||||
"low": "1h",
|
||||
"builtin": True,
|
||||
},
|
||||
{
|
||||
"id": "d_w_m",
|
||||
"label": "1d / 1w / 1M",
|
||||
"high": "1M",
|
||||
"mid": "1w",
|
||||
"low": "1d",
|
||||
"builtin": True,
|
||||
},
|
||||
]
|
||||
|
||||
_COMBOS_FILE = DATA_DIR / "combos.json"
|
||||
_lock = threading.Lock()
|
||||
_cache: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def tf_minutes(tf: str) -> int | None:
|
||||
if tf in _TF_MINUTES:
|
||||
return _TF_MINUTES[tf]
|
||||
# tolerate provider typo "10" → skip
|
||||
m = re.fullmatch(r"(\d+)([mhdwM])", tf)
|
||||
if not m:
|
||||
return None
|
||||
n, u = int(m.group(1)), m.group(2)
|
||||
mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u]
|
||||
return n * mult
|
||||
|
||||
|
||||
def combo_id_for(high: str, mid: str, low: str) -> str:
|
||||
def _tok(t: str) -> str:
|
||||
return t.replace("/", "_")
|
||||
|
||||
return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}"
|
||||
|
||||
|
||||
def validate_combo(high: str, mid: str, low: str) -> str | None:
|
||||
"""Return error message or None if ok."""
|
||||
for tf in (high, mid, low):
|
||||
if tf not in ALLOWED_TFS:
|
||||
return f"不支持的周期: {tf}"
|
||||
if len({high, mid, low}) < 3:
|
||||
return "高/中/低周期必须互不相同"
|
||||
hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low)
|
||||
if hm is None or mm is None or lm is None:
|
||||
return "无法解析周期长度"
|
||||
if not (hm > mm > lm):
|
||||
return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
high, mid, low = row.get("high"), row.get("mid"), row.get("low")
|
||||
if not high or not mid or not low:
|
||||
return None
|
||||
err = validate_combo(str(high), str(mid), str(low))
|
||||
if err:
|
||||
return None
|
||||
cid = str(row.get("id") or combo_id_for(high, mid, low))
|
||||
label = str(row.get("label") or f"{high} / {mid} / {low}")
|
||||
return {
|
||||
"id": cid,
|
||||
"label": label,
|
||||
"high": str(high),
|
||||
"mid": str(mid),
|
||||
"low": str(low),
|
||||
"builtin": bool(row.get("builtin", False)),
|
||||
}
|
||||
|
||||
|
||||
def _load_raw() -> list[dict[str, Any]]:
|
||||
ensure_dirs()
|
||||
if not _COMBOS_FILE.exists():
|
||||
return deepcopy(BUILTIN)
|
||||
try:
|
||||
data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8"))
|
||||
items = data.get("combos") if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return deepcopy(BUILTIN)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return deepcopy(BUILTIN)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for b in BUILTIN:
|
||||
out.append(deepcopy(b))
|
||||
seen.add(b["id"])
|
||||
for row in items:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
norm = _normalize(row)
|
||||
if not norm or norm["id"] in seen:
|
||||
continue
|
||||
if norm["id"] in {b["id"] for b in BUILTIN}:
|
||||
continue
|
||||
norm["builtin"] = False
|
||||
out.append(norm)
|
||||
seen.add(norm["id"])
|
||||
return out
|
||||
|
||||
|
||||
def _save(combos: list[dict[str, Any]]) -> None:
|
||||
ensure_dirs()
|
||||
custom = [c for c in combos if not c.get("builtin")]
|
||||
payload = {"combos": custom}
|
||||
tmp = _COMBOS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(_COMBOS_FILE)
|
||||
|
||||
|
||||
def list_combos() -> list[dict[str, Any]]:
|
||||
global _cache
|
||||
with _lock:
|
||||
if _cache is None:
|
||||
_cache = _load_raw()
|
||||
return deepcopy(_cache)
|
||||
|
||||
|
||||
def get_combo(combo_id: str | None) -> dict[str, Any]:
|
||||
combos = list_combos()
|
||||
if combo_id:
|
||||
for c in combos:
|
||||
if c["id"] == combo_id:
|
||||
return deepcopy(c)
|
||||
return deepcopy(combos[0])
|
||||
|
||||
|
||||
def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]:
|
||||
err = validate_combo(high, mid, low)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
cid = combo_id_for(high, mid, low)
|
||||
row = {
|
||||
"id": cid,
|
||||
"label": label or f"{high} / {mid} / {low}",
|
||||
"high": high,
|
||||
"mid": mid,
|
||||
"low": low,
|
||||
"builtin": False,
|
||||
}
|
||||
with _lock:
|
||||
combos = _load_raw()
|
||||
for c in combos:
|
||||
if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low):
|
||||
_cache = combos
|
||||
return deepcopy(c)
|
||||
combos.append(row)
|
||||
_save(combos)
|
||||
_cache = combos
|
||||
return deepcopy(row)
|
||||
|
||||
|
||||
def delete_combo(combo_id: str) -> bool:
|
||||
with _lock:
|
||||
combos = _load_raw()
|
||||
kept: list[dict[str, Any]] = []
|
||||
removed = False
|
||||
for c in combos:
|
||||
if c["id"] == combo_id:
|
||||
if c.get("builtin"):
|
||||
raise ValueError("内置组合不可删除")
|
||||
removed = True
|
||||
continue
|
||||
kept.append(c)
|
||||
if removed:
|
||||
_save(kept)
|
||||
_cache = kept
|
||||
return removed
|
||||
|
||||
|
||||
def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]:
|
||||
"""Unique TFs needed by active combos (stable order)."""
|
||||
rows = combos if combos is not None else list_combos()
|
||||
seen: list[str] = []
|
||||
for c in rows:
|
||||
for k in ("low", "mid", "high"):
|
||||
tf = c[k]
|
||||
if tf not in seen:
|
||||
seen.append(tf)
|
||||
return seen
|
||||
|
||||
|
||||
def lookback_for(tf: str) -> int:
|
||||
defaults = {
|
||||
"1h": 500,
|
||||
"2h": 400,
|
||||
"3h": 350,
|
||||
"4h": 300,
|
||||
"6h": 280,
|
||||
"8h": 250,
|
||||
"12h": 220,
|
||||
"1d": 250,
|
||||
"2d": 200,
|
||||
"3d": 180,
|
||||
"1w": 104,
|
||||
"1M": 60,
|
||||
}
|
||||
return defaults.get(tf, 200)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Cycle Engine — monthly/weekly macro cycle via Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle
|
||||
from crypto_wyckoff.rules.base import RuleHit
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
|
||||
def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]:
|
||||
"""Accumulation vs Distribution overlap → mutually exclusive by MA120 position."""
|
||||
accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value]
|
||||
dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value]
|
||||
if not (accum and dist):
|
||||
return hits
|
||||
|
||||
close = float(features.get("close") or 0)
|
||||
ma120 = float(features.get("ma120") or close) or close
|
||||
others = [
|
||||
h for h in hits
|
||||
if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value)
|
||||
]
|
||||
# Below MA120 → accumulation; above → distribution; equal band uses relative position
|
||||
if close < ma120 * 0.995:
|
||||
return others + accum
|
||||
if close > ma120 * 1.005:
|
||||
return others + dist
|
||||
# Tight band: keep higher confidence only
|
||||
best_a = max(accum, key=lambda h: h.confidence)
|
||||
best_d = max(dist, key=lambda h: h.confidence)
|
||||
return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d])
|
||||
|
||||
|
||||
class CycleEngine:
|
||||
name = "Cycle"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||
features = feature.payload
|
||||
if features.get("insufficient"):
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=15.0,
|
||||
score=40.0,
|
||||
reasons=[f"{timeframe} 数据不足,Cycle=Unknown"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||
"timeframe": timeframe,
|
||||
"trend_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {"features": features, "timeframe": timeframe}
|
||||
hits: list[RuleHit] = []
|
||||
for rule in rule_registry.by_category("cycle", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.cycle:
|
||||
hits.append(hit)
|
||||
|
||||
hits = _resolve_range_conflict(hits, features)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=30.0,
|
||||
score=40.0,
|
||||
reasons=["无匹配周期规则,标记 Unknown"],
|
||||
payload={
|
||||
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||
"timeframe": timeframe,
|
||||
"trend_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
best = max(hits, key=lambda h: h.confidence)
|
||||
trend_score = best.score
|
||||
if best.cycle == WyckoffCycle.MARKUP.value:
|
||||
trend_score = max(trend_score, 75.0)
|
||||
elif best.cycle == WyckoffCycle.ACCUMULATION.value:
|
||||
trend_score = max(60.0, trend_score * 0.9)
|
||||
elif best.cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||
trend_score = min(45.0, 100 - trend_score * 0.5)
|
||||
elif best.cycle == WyckoffCycle.MARKDOWN.value:
|
||||
trend_score = min(30.0, 100 - trend_score)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=best.confidence,
|
||||
score=trend_score,
|
||||
reasons=best.reasons,
|
||||
metrics=best.metrics,
|
||||
payload={
|
||||
"cycle": best.cycle,
|
||||
"timeframe": timeframe,
|
||||
"rule_id": best.rule_id,
|
||||
"trend_score": trend_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import (
|
||||
DecisionSignal,
|
||||
EngineResult,
|
||||
RiskLevel,
|
||||
WyckoffCycle,
|
||||
WyckoffEvent,
|
||||
WyckoffPhase,
|
||||
)
|
||||
|
||||
BULL_CYCLES = {
|
||||
WyckoffCycle.ACCUMULATION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.MARKUP.value,
|
||||
}
|
||||
BEAR_CYCLES = {
|
||||
WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
WyckoffCycle.MARKDOWN.value,
|
||||
}
|
||||
|
||||
|
||||
class DecisionEngine:
|
||||
name = "Decision"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(
|
||||
self,
|
||||
monthly_cycle: EngineResult,
|
||||
weekly_cycle: EngineResult,
|
||||
weekly_phase: EngineResult,
|
||||
weekly_event: EngineResult,
|
||||
daily_event: EngineResult,
|
||||
daily_signal: EngineResult,
|
||||
) -> EngineResult:
|
||||
m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||
w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||
w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value)
|
||||
w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
|
||||
trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score))
|
||||
structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score))
|
||||
entry_score = float(daily_event.payload.get("entry_score", daily_event.score))
|
||||
|
||||
overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score
|
||||
|
||||
reasons: list[str] = []
|
||||
warnings: list[str] = []
|
||||
alignment = 50.0
|
||||
|
||||
m_bull = m_cycle in BULL_CYCLES
|
||||
m_bear = m_cycle in BEAR_CYCLES
|
||||
w_bull = w_cycle in BULL_CYCLES
|
||||
d_bullish_event = d_event in {
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
}
|
||||
d_bearish_event = d_event in {
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
}
|
||||
|
||||
# Alignment scoring
|
||||
if m_bull and w_bull and d_bullish_event:
|
||||
alignment = 92.0
|
||||
reasons.append("✓ 月/周多头结构与日线多头事件一致")
|
||||
elif m_bull and d_bullish_event:
|
||||
alignment = 78.0
|
||||
reasons.append("✓ 月线支持,日线有入场事件")
|
||||
if not w_bull:
|
||||
warnings.append("周线结构未完全确认")
|
||||
alignment -= 8
|
||||
elif m_bear and d_bullish_event:
|
||||
alignment = 35.0
|
||||
reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹")
|
||||
elif m_bear and d_bearish_event:
|
||||
alignment = 85.0
|
||||
reasons.append("✓ 空头多周期一致")
|
||||
else:
|
||||
alignment = 55.0
|
||||
reasons.append("○ 多周期部分一致,需观察")
|
||||
|
||||
if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull:
|
||||
alignment = min(98.0, alignment + 6)
|
||||
reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})")
|
||||
active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or []
|
||||
if d_event == WyckoffEvent.SPRING.value and len(active) >= 3:
|
||||
alignment = min(98.0, alignment + 4)
|
||||
reasons.append("✓ 日线多重事件同时确认")
|
||||
|
||||
# Decision signal — hard gate on monthly bear + daily spring
|
||||
decision = DecisionSignal.WATCH.value
|
||||
risk = RiskLevel.MEDIUM.value
|
||||
|
||||
if m_bear and d_event == WyckoffEvent.SPRING.value:
|
||||
decision = DecisionSignal.WATCH.value
|
||||
risk = RiskLevel.HIGH.value
|
||||
overall_score = min(overall_score, 55.0)
|
||||
reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)")
|
||||
elif m_bear and d_bullish_event:
|
||||
decision = DecisionSignal.AVOID.value
|
||||
risk = RiskLevel.HIGH.value
|
||||
overall_score = min(overall_score, 48.0)
|
||||
reasons.append("→ 决策:回避(逆大周期多头事件)")
|
||||
elif (
|
||||
m_bull
|
||||
and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value)
|
||||
and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value)
|
||||
and alignment >= 85
|
||||
and overall_score >= 80
|
||||
):
|
||||
decision = DecisionSignal.STRONG_BUY.value
|
||||
risk = RiskLevel.LOW.value
|
||||
reasons.append("→ 决策:强烈买入(三级共振)")
|
||||
elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70:
|
||||
decision = DecisionSignal.BUY.value
|
||||
risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value
|
||||
reasons.append("→ 决策:买入")
|
||||
elif m_bear and d_bearish_event and overall_score >= 65:
|
||||
decision = DecisionSignal.SELL.value
|
||||
risk = RiskLevel.MEDIUM.value
|
||||
reasons.append("→ 决策:卖出")
|
||||
else:
|
||||
decision = DecisionSignal.WATCH.value
|
||||
reasons.append("→ 决策:观察")
|
||||
|
||||
# Stars from score + alignment
|
||||
combo = 0.6 * overall_score + 0.4 * alignment
|
||||
if combo >= 90:
|
||||
stars = 5
|
||||
elif combo >= 80:
|
||||
stars = 4
|
||||
elif combo >= 65:
|
||||
stars = 3
|
||||
elif combo >= 50:
|
||||
stars = 2
|
||||
else:
|
||||
stars = 1
|
||||
|
||||
overall_confidence = (
|
||||
0.25 * monthly_cycle.confidence
|
||||
+ 0.25 * weekly_phase.confidence
|
||||
+ 0.25 * daily_event.confidence
|
||||
+ 0.25 * daily_signal.confidence
|
||||
)
|
||||
# Weak event pulls overall down
|
||||
if daily_event.confidence < 60:
|
||||
overall_confidence = min(overall_confidence, daily_event.confidence + 15)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=overall_confidence,
|
||||
score=overall_score,
|
||||
reasons=reasons,
|
||||
warnings=warnings,
|
||||
metrics={
|
||||
"trend_score": trend_score,
|
||||
"structure_score": structure_score,
|
||||
"entry_score": entry_score,
|
||||
"alignment": alignment,
|
||||
"stars": stars,
|
||||
},
|
||||
payload={
|
||||
"decision_signal": decision,
|
||||
"alignment": alignment,
|
||||
"stars": stars,
|
||||
"risk": risk,
|
||||
"overall_score": overall_score,
|
||||
"overall_confidence": overall_confidence,
|
||||
"trend_score": trend_score,
|
||||
"structure_score": structure_score,
|
||||
"entry_score": entry_score,
|
||||
"m_cycle": m_cycle,
|
||||
"w_cycle": w_cycle,
|
||||
"w_phase": w_phase,
|
||||
"w_event": w_event,
|
||||
"d_event": d_event,
|
||||
# Facts preserved — never overwritten
|
||||
"facts": {
|
||||
"monthly": {"cycle": m_cycle},
|
||||
"weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event},
|
||||
"daily": {"event": d_event},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class WyckoffCycle(str, Enum):
|
||||
ACCUMULATION = "Accumulation"
|
||||
RE_ACCUMULATION = "ReAccumulation"
|
||||
MARKUP = "Markup"
|
||||
DISTRIBUTION = "Distribution"
|
||||
RE_DISTRIBUTION = "ReDistribution"
|
||||
MARKDOWN = "Markdown"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
class WyckoffPhase(str, Enum):
|
||||
A = "A"
|
||||
B = "B"
|
||||
C = "C"
|
||||
D = "D"
|
||||
E = "E"
|
||||
NONE = "None"
|
||||
|
||||
|
||||
class WyckoffEvent(str, Enum):
|
||||
PS = "PS"
|
||||
SC = "SC"
|
||||
AR = "AR"
|
||||
ST = "ST"
|
||||
SPRING = "Spring"
|
||||
TEST = "Test"
|
||||
SOS = "SOS"
|
||||
LPS = "LPS"
|
||||
JUMP = "Jump"
|
||||
BACKUP = "Backup"
|
||||
BC = "BC"
|
||||
UTAD = "UTAD"
|
||||
SOW = "SOW"
|
||||
LPSY = "LPSY"
|
||||
NONE = "None"
|
||||
|
||||
|
||||
class DecisionSignal(str, Enum):
|
||||
STRONG_BUY = "StrongBuy"
|
||||
BUY = "Buy"
|
||||
WATCH = "Watch"
|
||||
AVOID = "Avoid"
|
||||
SELL = "Sell"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
LOW = "Low"
|
||||
MEDIUM = "Medium"
|
||||
HIGH = "High"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineResult:
|
||||
"""Unified result envelope for every Wyckoff engine (v1.0 contract)."""
|
||||
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
confidence: float = 0.0
|
||||
score: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
metrics: dict[str, Any] = field(default_factory=dict)
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"confidence": self.confidence,
|
||||
"score": self.score,
|
||||
"reasons": self.reasons,
|
||||
"warnings": self.warnings,
|
||||
"metrics": self.metrics,
|
||||
"payload": self.payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OHLCVFrame:
|
||||
"""In-memory OHLCV for one symbol one timeframe. Engines never touch DB."""
|
||||
|
||||
ts_code: str
|
||||
timeframe: str # "1d" | "1w" | "1M"
|
||||
trade_dates: list[date]
|
||||
open: list[float]
|
||||
high: list[float]
|
||||
low: list[float]
|
||||
close: list[float]
|
||||
volume: list[float]
|
||||
amount: list[float] = field(default_factory=list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.close)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return len(self.close) == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WyckoffScanRow:
|
||||
"""Persisted scan row for wyckoff_scan table."""
|
||||
|
||||
trade_date: date
|
||||
ts_code: str
|
||||
name: str = ""
|
||||
industry: str = ""
|
||||
engine_version: str = "v1.0.0"
|
||||
combo_id: str = "d_w_m"
|
||||
|
||||
m_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||
cycle_confidence: float = 0.0
|
||||
trend_score: float = 0.0
|
||||
|
||||
w_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||
w_phase: str = WyckoffPhase.NONE.value
|
||||
w_current_event: str = WyckoffEvent.NONE.value
|
||||
w_recent_events_json: str = "[]"
|
||||
phase_confidence: float = 0.0
|
||||
structure_score: float = 0.0
|
||||
|
||||
d_current_event: str = WyckoffEvent.NONE.value
|
||||
d_recent_events_json: str = "[]"
|
||||
event_confidence: float = 0.0
|
||||
entry_score: float = 0.0
|
||||
|
||||
entry: Optional[float] = None
|
||||
stop: Optional[float] = None
|
||||
target1: Optional[float] = None
|
||||
target2: Optional[float] = None
|
||||
rr: Optional[float] = None
|
||||
|
||||
alignment: float = 0.0
|
||||
stars: int = 1
|
||||
decision_signal: str = DecisionSignal.WATCH.value
|
||||
signal_confidence: float = 0.0
|
||||
overall_confidence: float = 0.0
|
||||
overall_score: float = 0.0
|
||||
risk: str = RiskLevel.MEDIUM.value
|
||||
reasons_json: str = "[]"
|
||||
|
||||
feature_snapshot_json: str = "{}"
|
||||
markers_json: str = "[]"
|
||||
scanned_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Event Engine — active concurrent events via Rule Registry.
|
||||
|
||||
Note: `active_events` are rules that fire on the latest bar snapshot,
|
||||
NOT a historical SC→AR→ST timeline. Do not present as chronological chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
# Display order only (not temporal history)
|
||||
_DISPLAY_ORDER = [
|
||||
WyckoffEvent.PS.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.BC.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
]
|
||||
|
||||
# Dominant event: highest confidence wins; ties broken by this priority
|
||||
_DOMINANCE_PRIORITY = [
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
]
|
||||
|
||||
|
||||
class EventEngine:
|
||||
name = "Event"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(
|
||||
self,
|
||||
cycle: EngineResult,
|
||||
phase: EngineResult,
|
||||
feature: EngineResult,
|
||||
timeframe: str,
|
||||
) -> EngineResult:
|
||||
if feature.payload.get("insufficient"):
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=20.0,
|
||||
score=30.0,
|
||||
reasons=["特征不足,跳过事件识别"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"current_event": WyckoffEvent.NONE.value,
|
||||
"active_events": [],
|
||||
"recent_events": [], # alias for DB/API compat; same as active_events
|
||||
"timeframe": timeframe,
|
||||
"entry_score": 30.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"features": feature.payload,
|
||||
"cycle": cycle.payload,
|
||||
"phase": phase.payload,
|
||||
"timeframe": timeframe,
|
||||
}
|
||||
hits = []
|
||||
for rule in rule_registry.by_category("event", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.event:
|
||||
hits.append(hit)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=35.0,
|
||||
score=40.0,
|
||||
reasons=["无显著事件"],
|
||||
payload={
|
||||
"current_event": WyckoffEvent.NONE.value,
|
||||
"active_events": [],
|
||||
"recent_events": [],
|
||||
"timeframe": timeframe,
|
||||
"entry_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
by_event: dict[str, float] = {}
|
||||
reasons: list[str] = []
|
||||
metrics: dict = {}
|
||||
for h in hits:
|
||||
prev = by_event.get(h.event, -1.0)
|
||||
if h.confidence >= prev:
|
||||
by_event[h.event] = h.confidence
|
||||
reasons.extend(h.reasons)
|
||||
metrics.update(h.metrics)
|
||||
|
||||
active = [e for e in _DISPLAY_ORDER if e in by_event]
|
||||
for e in by_event:
|
||||
if e not in active:
|
||||
active.append(e)
|
||||
|
||||
# Dominant = max confidence; tie-break by dominance priority index
|
||||
def _dom_key(ev: str) -> tuple:
|
||||
conf = by_event[ev]
|
||||
try:
|
||||
prio = _DOMINANCE_PRIORITY.index(ev)
|
||||
except ValueError:
|
||||
prio = 99
|
||||
return (conf, -prio)
|
||||
|
||||
current = max(by_event.keys(), key=_dom_key)
|
||||
event_conf = by_event[current]
|
||||
co_bonus = min(12.0, max(0, len(active) - 1) * 3)
|
||||
entry_score = min(98.0, event_conf + co_bonus)
|
||||
if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event:
|
||||
entry_score = min(98.0, entry_score + 5)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=event_conf,
|
||||
score=entry_score,
|
||||
reasons=list(dict.fromkeys(reasons))[:8],
|
||||
warnings=["active_events_are_concurrent_not_timeline"],
|
||||
metrics=metrics,
|
||||
payload={
|
||||
"current_event": current,
|
||||
"active_events": active,
|
||||
"recent_events": active, # persisted column name; semantic = active
|
||||
"event_scores": by_event,
|
||||
"timeframe": timeframe,
|
||||
"entry_score": entry_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame
|
||||
|
||||
|
||||
def _sma(arr: np.ndarray, n: int) -> float:
|
||||
if len(arr) < n:
|
||||
return float(arr[-1]) if len(arr) else 0.0
|
||||
return float(np.mean(arr[-n:]))
|
||||
|
||||
|
||||
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||
if len(close) < 2:
|
||||
return 0.0
|
||||
prev_close = close[:-1]
|
||||
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close)))
|
||||
if len(tr) < n:
|
||||
return float(np.mean(tr)) if len(tr) else 0.0
|
||||
return float(np.mean(tr[-n:]))
|
||||
|
||||
|
||||
def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||
"""Simplified ADX approximation."""
|
||||
if len(close) < n + 2:
|
||||
return 15.0
|
||||
up = high[1:] - high[:-1]
|
||||
down = low[:-1] - low[1:]
|
||||
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
|
||||
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
|
||||
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])))
|
||||
atr = np.mean(tr[-n:]) or 1e-9
|
||||
plus_di = 100 * np.mean(plus_dm[-n:]) / atr
|
||||
minus_di = 100 * np.mean(minus_dm[-n:]) / atr
|
||||
denom = plus_di + minus_di
|
||||
if denom < 1e-9:
|
||||
return 10.0
|
||||
dx = 100 * abs(plus_di - minus_di) / denom
|
||||
return float(min(60.0, dx))
|
||||
|
||||
|
||||
def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]:
|
||||
"""Compute technical snapshot dict from OHLCV (no I/O)."""
|
||||
if frame.empty or len(frame) < 5:
|
||||
return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)}
|
||||
|
||||
close = np.asarray(frame.close, dtype=float)
|
||||
high = np.asarray(frame.high, dtype=float)
|
||||
low = np.asarray(frame.low, dtype=float)
|
||||
volume = np.asarray(frame.volume, dtype=float)
|
||||
open_ = np.asarray(frame.open, dtype=float)
|
||||
|
||||
ma20 = _sma(close, 20)
|
||||
ma60 = _sma(close, 60)
|
||||
ma120 = _sma(close, min(120, len(close)))
|
||||
atr = _atr(high, low, close, 14)
|
||||
vol_ma20 = _sma(volume, 20) or 1e-9
|
||||
volume_ratio = float(volume[-1] / vol_ma20)
|
||||
|
||||
look = min(60, len(close))
|
||||
window_h = high[-look:]
|
||||
window_l = low[-look:]
|
||||
range_high = float(np.max(window_h))
|
||||
range_low = float(np.min(window_l))
|
||||
rng = max(range_high - range_low, 1e-9)
|
||||
range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0
|
||||
range_position = float((close[-1] - range_low) / rng)
|
||||
|
||||
# Spring / UTAD hints
|
||||
pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0
|
||||
# if previous bars broke below and last close back in range
|
||||
prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2])
|
||||
pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1]))
|
||||
close_back_in_range = 1.0 if close[-1] >= range_low else 0.0
|
||||
reclaim_speed = 0.0
|
||||
if pierce_below > 0 and close[-1] >= range_low:
|
||||
reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2)
|
||||
|
||||
pierce_above = max(0.0, (high[-1] - range_high) / close[-1])
|
||||
fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0
|
||||
breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0
|
||||
|
||||
# pullback hold: close near ma20 from above after being higher
|
||||
pullback_hold = 0.0
|
||||
if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5:
|
||||
pullback_hold = 0.8
|
||||
|
||||
ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60
|
||||
ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9)
|
||||
|
||||
# volume trend: recent 10 vs prior 10
|
||||
if len(volume) >= 20:
|
||||
volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0)
|
||||
else:
|
||||
volume_trend = 0.0
|
||||
|
||||
bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9))
|
||||
bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0
|
||||
gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0
|
||||
after_strength = 0.0
|
||||
if len(close) >= 4 and close[-3] > close[-4]:
|
||||
after_strength = 0.7
|
||||
|
||||
spring_score_hint = 0.0
|
||||
if pierce_below >= 0.002 and close_back_in_range:
|
||||
spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20)
|
||||
utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0
|
||||
|
||||
# swing
|
||||
swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1])
|
||||
swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1])
|
||||
|
||||
return {
|
||||
"ts_code": frame.ts_code,
|
||||
"timeframe": frame.timeframe,
|
||||
"bars": len(frame),
|
||||
"close": float(close[-1]),
|
||||
"open": float(open_[-1]),
|
||||
"high": float(high[-1]),
|
||||
"low": float(low[-1]),
|
||||
"volume": float(volume[-1]),
|
||||
"ma20": ma20,
|
||||
"ma60": ma60,
|
||||
"ma120": ma120,
|
||||
"ma60_slope": float(ma60_slope),
|
||||
"atr": atr,
|
||||
"adx": _adx(high, low, close),
|
||||
"volume_ma20": float(vol_ma20),
|
||||
"volume_ratio": volume_ratio,
|
||||
"volume_trend": volume_trend,
|
||||
"range_high": range_high,
|
||||
"range_low": range_low,
|
||||
"range_pct_60": range_pct_60,
|
||||
"range_position": range_position,
|
||||
"pierce_below_range": pierce_below,
|
||||
"pierce_above_range": pierce_above,
|
||||
"close_back_in_range": close_back_in_range,
|
||||
"reclaim_speed": reclaim_speed,
|
||||
"fail_back_into_range": fail_back,
|
||||
"breakout_above_range": breakout_above,
|
||||
"pullback_hold": pullback_hold,
|
||||
"bar_range_atr": bar_range_atr,
|
||||
"bounce_from_low": bounce_from_low,
|
||||
"gap_up_pct": gap_up_pct,
|
||||
"after_strength": after_strength,
|
||||
"spring_score_hint": spring_score_hint,
|
||||
"utad_score_hint": utad_score_hint,
|
||||
"swing_high": swing_high,
|
||||
"swing_low": swing_low,
|
||||
"trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None,
|
||||
}
|
||||
|
||||
|
||||
# Minimum bars before a timeframe is considered usable (no cross-TF borrow)
|
||||
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||
|
||||
|
||||
class FeatureEngine:
|
||||
"""Pure Feature Engine — no database access."""
|
||||
|
||||
name = "Feature"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult:
|
||||
tf = timeframe or (frame.timeframe if frame else "1d")
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
|
||||
if frame is None or frame.empty or len(frame) < min_bars:
|
||||
bars = 0 if frame is None or frame.empty else len(frame)
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=10.0,
|
||||
score=10.0,
|
||||
reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"],
|
||||
warnings=["insufficient_features"],
|
||||
metrics={"bars": bars, "min_bars": min_bars},
|
||||
payload={
|
||||
"ts_code": getattr(frame, "ts_code", ""),
|
||||
"timeframe": tf,
|
||||
"bars": bars,
|
||||
"insufficient": True,
|
||||
},
|
||||
)
|
||||
|
||||
snap = compute_feature_snapshot(frame)
|
||||
snap["insufficient"] = False
|
||||
conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5)
|
||||
warnings = []
|
||||
if snap.get("bars", 0) < 60:
|
||||
warnings.append("bars偏少,特征可靠性中等")
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=[f"computed {snap.get('bars', 0)} bars {tf}"],
|
||||
warnings=warnings,
|
||||
metrics={"bars": snap.get("bars", 0)},
|
||||
payload=snap,
|
||||
)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
|
||||
from crypto_wyckoff.domain_models import OHLCVFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff")))
|
||||
BARS_DB = DATA_DIR / "bars.sqlite"
|
||||
SCAN_DB = DATA_DIR / "scan.sqlite"
|
||||
|
||||
DATA_SERVICE_URL = os.environ.get(
|
||||
"DATA_SERVICE_URL",
|
||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||
).rstrip("/")
|
||||
|
||||
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
|
||||
# Provider has many TFs; 1M is resampled locally from daily UTC months.
|
||||
LOOKBACK = {
|
||||
"1h": 500,
|
||||
"2h": 400,
|
||||
"4h": 300,
|
||||
"6h": 280,
|
||||
"8h": 250,
|
||||
"12h": 220,
|
||||
"1d": 250,
|
||||
"1w": 104,
|
||||
"1M": 60,
|
||||
}
|
||||
# Default D/W/M stack (kept for compat); combos may request more TFs from provider.
|
||||
TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w")
|
||||
TF_LIST = ("1d", "1w", "1M")
|
||||
LOCAL_ONLY_TFS = frozenset({"1M"})
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _symbol_key(symbol: str) -> str:
|
||||
return symbol.replace("/", "_").replace(":", "_")
|
||||
|
||||
|
||||
def _bars_conn() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
conn = sqlite3.connect(str(BARS_DB), timeout=60)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bars (
|
||||
symbol TEXT NOT NULL,
|
||||
tf TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||
PRIMARY KEY (symbol, tf, ts)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)")
|
||||
return conn
|
||||
|
||||
|
||||
def fetch_candles(
|
||||
symbol: str,
|
||||
tf: str,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
start_ms: int | None = None,
|
||||
end_ms: int | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> list[dict]:
|
||||
params: dict = {"symbol": symbol, "tf": tf}
|
||||
if limit is not None:
|
||||
params["limit"] = int(limit)
|
||||
if start_ms is not None:
|
||||
params["start"] = int(start_ms)
|
||||
if end_ms is not None:
|
||||
params["end"] = int(end_ms)
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out = []
|
||||
for row in data:
|
||||
try:
|
||||
ts = int(float(row["timestamp"]))
|
||||
out.append(
|
||||
{
|
||||
"ts": ts,
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("volume") or 0),
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
out.sort(key=lambda r: r["ts"])
|
||||
return out
|
||||
|
||||
|
||||
def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(symbol, tf, ts) DO UPDATE SET
|
||||
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||
close=excluded.close, volume=excluded.volume
|
||||
""",
|
||||
[
|
||||
(symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"])
|
||||
for r in rows
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_intraday_tf(tf: str) -> bool:
|
||||
"""True for minute/hour TFs that need clock time on charts."""
|
||||
t = (tf or "").strip()
|
||||
return t.endswith("m") or t.endswith("h")
|
||||
|
||||
|
||||
def load_bars_with_ts(
|
||||
symbol: str, tf: str, lookback: int | None = None
|
||||
) -> list[dict]:
|
||||
"""Return OHLCV rows with UTC ms ts (for chart labels).
|
||||
|
||||
``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
tz_cn = ZoneInfo("Asia/Shanghai")
|
||||
if lookback is None:
|
||||
try:
|
||||
from crypto_wyckoff.combos import lookback_for
|
||||
|
||||
lookback = lookback_for(tf)
|
||||
except Exception:
|
||||
lookback = LOOKBACK.get(tf, 100)
|
||||
lookback = lookback or LOOKBACK.get(tf, 100)
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf=?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
""",
|
||||
(symbol, tf, lookback),
|
||||
)
|
||||
rows = list(reversed(cur.fetchall()))
|
||||
finally:
|
||||
conn.close()
|
||||
out = []
|
||||
for ts, o, h, l, c, v in rows:
|
||||
dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||
dt_cn = dt_utc.astimezone(tz_cn)
|
||||
out.append(
|
||||
{
|
||||
"ts": int(ts),
|
||||
"datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
|
||||
"date": dt_cn.strftime("%Y-%m-%d"),
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
|
||||
rows = load_bars_with_ts(symbol, tf, lookback)
|
||||
if not rows:
|
||||
return None
|
||||
return OHLCVFrame(
|
||||
ts_code=symbol,
|
||||
timeframe=tf,
|
||||
trade_dates=[
|
||||
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows
|
||||
],
|
||||
open=[r["open"] for r in rows],
|
||||
high=[r["high"] for r in rows],
|
||||
low=[r["low"] for r in rows],
|
||||
close=[r["close"] for r in rows],
|
||||
volume=[r["volume"] for r in rows],
|
||||
)
|
||||
|
||||
|
||||
def bar_count(symbol: str, tf: str) -> int:
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf)
|
||||
)
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def rebuild_monthly_from_daily(symbol: str) -> int:
|
||||
"""Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M)."""
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf='1d' ORDER BY ts ASC
|
||||
""",
|
||||
(symbol,),
|
||||
)
|
||||
daily = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
if not daily:
|
||||
return 0
|
||||
|
||||
months: dict[tuple[int, int], dict] = {}
|
||||
for ts, o, h, l, c, v in daily:
|
||||
dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||
key = (dt.year, dt.month)
|
||||
# month bar open timestamp = first day 00:00 UTC
|
||||
month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
if key not in months:
|
||||
months[key] = {
|
||||
"ts": month_ts,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v or 0.0,
|
||||
}
|
||||
else:
|
||||
m = months[key]
|
||||
m["high"] = max(m["high"], h)
|
||||
m["low"] = min(m["low"], l)
|
||||
m["close"] = c
|
||||
m["volume"] = (m["volume"] or 0) + (v or 0)
|
||||
|
||||
rows = sorted(months.values(), key=lambda r: r["ts"])
|
||||
# drop stale months then upsert
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return upsert_bars(symbol, "1M", rows)
|
||||
|
||||
|
||||
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
|
||||
"""Pull history for requested TFs; monthly derived from daily when needed."""
|
||||
wanted = list(dict.fromkeys(tfs))
|
||||
stats: dict = {}
|
||||
need_monthly = "1M" in wanted
|
||||
if need_monthly and "1d" not in wanted:
|
||||
wanted = ["1d", *wanted]
|
||||
|
||||
for tf in wanted:
|
||||
if tf in LOCAL_ONLY_TFS:
|
||||
continue
|
||||
need = LOOKBACK.get(tf, 100)
|
||||
if tf == "1d" and need_monthly:
|
||||
need = max(need, LOOKBACK["1M"] * 31)
|
||||
try:
|
||||
rows = fetch_candles(symbol, tf, limit=need)
|
||||
n = upsert_bars(symbol, tf, rows)
|
||||
stats[tf] = n
|
||||
except Exception as e:
|
||||
logger.warning("backfill %s %s failed: %s", symbol, tf, e)
|
||||
stats[tf] = 0
|
||||
time.sleep(0.05)
|
||||
|
||||
if need_monthly:
|
||||
try:
|
||||
stats["1M"] = rebuild_monthly_from_daily(symbol)
|
||||
except Exception as e:
|
||||
logger.warning("monthly rebuild %s failed: %s", symbol, e)
|
||||
stats["1M"] = 0
|
||||
return stats
|
||||
|
||||
|
||||
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
|
||||
"""Update forming tip bars (limit=3). Returns True if any bar changed."""
|
||||
wanted = list(dict.fromkeys(tfs))
|
||||
changed = False
|
||||
for tf in wanted:
|
||||
if tf in LOCAL_ONLY_TFS:
|
||||
continue
|
||||
try:
|
||||
rows = fetch_candles(symbol, tf, limit=3)
|
||||
if not rows:
|
||||
continue
|
||||
before = _tip_fingerprint(symbol, tf)
|
||||
upsert_bars(symbol, tf, rows)
|
||||
after = _tip_fingerprint(symbol, tf)
|
||||
if before != after:
|
||||
changed = True
|
||||
except Exception as e:
|
||||
logger.debug("tip %s %s: %s", symbol, tf, e)
|
||||
time.sleep(0.02)
|
||||
if "1M" in wanted:
|
||||
before_m = _tip_fingerprint(symbol, "1M")
|
||||
try:
|
||||
rebuild_monthly_from_daily(symbol)
|
||||
except Exception as e:
|
||||
logger.debug("monthly tip %s: %s", symbol, e)
|
||||
after_m = _tip_fingerprint(symbol, "1M")
|
||||
if before_m != after_m:
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _tip_fingerprint(symbol: str, tf: str) -> tuple | None:
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1
|
||||
""",
|
||||
(symbol, tf),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return tuple(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_symbols_from_provider() -> list[str]:
|
||||
try:
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||
return [s for s in symbols if isinstance(s, str)]
|
||||
except Exception as e:
|
||||
logger.warning("health symbols failed: %s", e)
|
||||
return []
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase Engine — Phase A–E via Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
|
||||
class PhaseEngine:
|
||||
name = "Phase"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||
if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown":
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=20.0,
|
||||
score=30.0,
|
||||
reasons=["数据/周期不足,Phase=None"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"phase": WyckoffPhase.NONE.value,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"structure_score": 30.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"features": feature.payload,
|
||||
"cycle": cycle.payload,
|
||||
"timeframe": timeframe,
|
||||
}
|
||||
hits = []
|
||||
for rule in rule_registry.by_category("phase", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.phase:
|
||||
hits.append(hit)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=40.0,
|
||||
score=cycle.score * 0.5,
|
||||
reasons=["未识别明确 Phase"],
|
||||
payload={
|
||||
"phase": WyckoffPhase.NONE.value,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"structure_score": cycle.score * 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
best = max(hits, key=lambda h: h.confidence)
|
||||
structure_score = best.score
|
||||
# Phase D/E stronger structure
|
||||
if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value):
|
||||
structure_score = max(structure_score, 80.0)
|
||||
elif best.phase == WyckoffPhase.C.value:
|
||||
structure_score = max(structure_score, 72.0)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=best.confidence,
|
||||
score=structure_score,
|
||||
reasons=best.reasons,
|
||||
metrics=best.metrics,
|
||||
payload={
|
||||
"phase": best.phase,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"rule_id": best.rule_id,
|
||||
"structure_score": structure_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Scan pipeline: load local frames → engines → store (per TF combo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for
|
||||
from crypto_wyckoff.cycle import CycleEngine
|
||||
from crypto_wyckoff.decision import DecisionEngine
|
||||
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||
from crypto_wyckoff.event import EventEngine
|
||||
from crypto_wyckoff.features import FeatureEngine
|
||||
from crypto_wyckoff.io import load_frame
|
||||
from crypto_wyckoff.phase import PhaseEngine
|
||||
from crypto_wyckoff.plan import PlanEngine
|
||||
from crypto_wyckoff.signal import SignalEngine
|
||||
from crypto_wyckoff.store import upsert_row
|
||||
from crypto_wyckoff.symbols_cn import display_name_cn
|
||||
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def analyze_symbol(
|
||||
low_frame,
|
||||
mid_frame,
|
||||
high_frame,
|
||||
*,
|
||||
feature_eng: FeatureEngine,
|
||||
cycle_eng: CycleEngine,
|
||||
phase_eng: PhaseEngine,
|
||||
event_eng: EventEngine,
|
||||
signal_eng: SignalEngine,
|
||||
decision_eng: DecisionEngine,
|
||||
plan_eng: PlanEngine,
|
||||
) -> dict:
|
||||
"""Run engines with D/W/M *role* aliases so existing rules match.
|
||||
|
||||
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles.
|
||||
"""
|
||||
f_d = feature_eng.run(low_frame, ROLE_LOW)
|
||||
f_w = feature_eng.run(mid_frame, ROLE_MID)
|
||||
f_m = feature_eng.run(high_frame, ROLE_HIGH)
|
||||
|
||||
c_m = cycle_eng.run(f_m, ROLE_HIGH)
|
||||
c_w = cycle_eng.run(f_w, ROLE_MID)
|
||||
|
||||
p_w = phase_eng.run(c_w, f_w, ROLE_MID)
|
||||
p_d = phase_eng.run(c_w, f_d, ROLE_LOW)
|
||||
|
||||
e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID)
|
||||
e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW)
|
||||
|
||||
s_d = signal_eng.run(e_d, p_d)
|
||||
decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
|
||||
plan = plan_eng.run(f_d, decision)
|
||||
|
||||
return {
|
||||
"f_d": f_d, "f_w": f_w, "f_m": f_m,
|
||||
"c_m": c_m, "c_w": c_w, "p_w": p_w,
|
||||
"e_w": e_w, "e_d": e_d, "s_d": s_d,
|
||||
"decision": decision, "plan": plan,
|
||||
}
|
||||
|
||||
|
||||
def _to_row(
|
||||
trade_date: date,
|
||||
symbol: str,
|
||||
result: dict,
|
||||
*,
|
||||
combo_id: str,
|
||||
combo_label: str,
|
||||
) -> WyckoffScanRow:
|
||||
d = result["decision"]
|
||||
p = result["plan"]
|
||||
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
|
||||
e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"]
|
||||
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
|
||||
|
||||
snapshot = {
|
||||
"combo_id": combo_id,
|
||||
"combo_label": combo_label,
|
||||
"daily": {k: f_d.payload.get(k) for k in (
|
||||
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
|
||||
"range_high", "range_low", "swing_high", "swing_low", "close",
|
||||
)},
|
||||
"weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||
"monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||
}
|
||||
markers = []
|
||||
for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")):
|
||||
if p.payload.get(key) is not None:
|
||||
markers.append({"type": typ, "price": p.payload[key]})
|
||||
|
||||
return WyckoffScanRow(
|
||||
trade_date=trade_date,
|
||||
ts_code=symbol,
|
||||
name=display_name_cn(symbol),
|
||||
industry="crypto",
|
||||
engine_version=WYCKOFF_ENGINE_VERSION,
|
||||
m_cycle=c_m.payload.get("cycle", "Unknown"),
|
||||
cycle_confidence=c_m.confidence,
|
||||
trend_score=float(d.payload.get("trend_score", c_m.score)),
|
||||
w_cycle=c_w.payload.get("cycle", "Unknown"),
|
||||
w_phase=p_w.payload.get("phase", "None"),
|
||||
w_current_event=e_w.payload.get("current_event", "None"),
|
||||
w_recent_events_json=json.dumps(
|
||||
e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
phase_confidence=p_w.confidence,
|
||||
structure_score=float(d.payload.get("structure_score", p_w.score)),
|
||||
d_current_event=e_d.payload.get("current_event", "None"),
|
||||
d_recent_events_json=json.dumps(
|
||||
e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
event_confidence=e_d.confidence,
|
||||
entry_score=float(d.payload.get("entry_score", e_d.score)),
|
||||
entry=p.payload.get("entry"),
|
||||
stop=p.payload.get("stop"),
|
||||
target1=p.payload.get("target1"),
|
||||
target2=p.payload.get("target2"),
|
||||
rr=p.payload.get("rr"),
|
||||
alignment=float(d.payload.get("alignment", 0)),
|
||||
stars=int(d.payload.get("stars", 1)),
|
||||
decision_signal=d.payload.get("decision_signal", "Watch"),
|
||||
signal_confidence=s_d.confidence,
|
||||
overall_confidence=float(d.payload.get("overall_confidence", d.confidence)),
|
||||
overall_score=float(d.payload.get("overall_score", d.score)),
|
||||
risk=d.payload.get("risk", "Medium"),
|
||||
reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False),
|
||||
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||
markers_json=json.dumps(markers, ensure_ascii=False),
|
||||
scanned_at=datetime.now(timezone.utc),
|
||||
combo_id=combo_id,
|
||||
)
|
||||
|
||||
|
||||
_ENGINES = None
|
||||
|
||||
|
||||
def _engines():
|
||||
global _ENGINES
|
||||
if _ENGINES is None:
|
||||
_ENGINES = {
|
||||
"feature_eng": FeatureEngine(),
|
||||
"cycle_eng": CycleEngine(),
|
||||
"phase_eng": PhaseEngine(),
|
||||
"event_eng": EventEngine(),
|
||||
"signal_eng": SignalEngine(),
|
||||
"decision_eng": DecisionEngine(),
|
||||
"plan_eng": PlanEngine(),
|
||||
}
|
||||
return _ENGINES
|
||||
|
||||
|
||||
def analyze_and_store(
|
||||
symbol: str,
|
||||
trade_date: date | None = None,
|
||||
*,
|
||||
combo_id: str | None = None,
|
||||
) -> WyckoffScanRow | None:
|
||||
eng = _engines()
|
||||
combo = get_combo(combo_id)
|
||||
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"]
|
||||
|
||||
low = load_frame(symbol, low_tf, lookback_for(low_tf))
|
||||
mid = load_frame(symbol, mid_tf, lookback_for(mid_tf))
|
||||
high = load_frame(symbol, high_tf, lookback_for(high_tf))
|
||||
if low is None or len(low) < 40:
|
||||
return None
|
||||
result = analyze_symbol(low, mid, high, **eng)
|
||||
td = trade_date or (
|
||||
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date()
|
||||
)
|
||||
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"])
|
||||
upsert_row(row)
|
||||
return row
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import DecisionSignal, EngineResult
|
||||
|
||||
|
||||
_TRADABLE = {
|
||||
DecisionSignal.STRONG_BUY.value,
|
||||
DecisionSignal.BUY.value,
|
||||
DecisionSignal.SELL.value,
|
||||
}
|
||||
|
||||
|
||||
class PlanEngine:
|
||||
name = "Plan"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult:
|
||||
f = daily_feature.payload
|
||||
close = float(f.get("close") or 0)
|
||||
atr = float(f.get("atr") or 0) or close * 0.02
|
||||
swing_low = float(f.get("swing_low") or close - 2 * atr)
|
||||
swing_high = float(f.get("swing_high") or close + 2 * atr)
|
||||
range_high = float(f.get("range_high") or swing_high)
|
||||
signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value)
|
||||
|
||||
entry = stop = t1 = t2 = rr = None
|
||||
reasons: list[str] = []
|
||||
|
||||
if signal not in _TRADABLE or close <= 0:
|
||||
reasons.append(f"无交易计划(信号={signal})")
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=decision.confidence,
|
||||
score=decision.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"entry": None,
|
||||
"stop": None,
|
||||
"target1": None,
|
||||
"target2": None,
|
||||
"rr": None,
|
||||
},
|
||||
)
|
||||
|
||||
if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value):
|
||||
entry = round(close, 4)
|
||||
stop = round(min(swing_low, close - 1.5 * atr), 4)
|
||||
risk = max(entry - stop, 1e-6)
|
||||
t1 = round(entry + 2.0 * risk, 4)
|
||||
t2 = round(max(range_high, entry + 3.0 * risk), 4)
|
||||
rr = round((t1 - entry) / risk, 2)
|
||||
reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}")
|
||||
else: # Sell
|
||||
entry = round(close, 4)
|
||||
stop = round(max(swing_high, close + 1.5 * atr), 4)
|
||||
risk = max(stop - entry, 1e-6)
|
||||
t1 = round(entry - 2.0 * risk, 4)
|
||||
t2 = round(entry - 3.0 * risk, 4)
|
||||
rr = round((entry - t1) / risk, 2)
|
||||
reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}")
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=decision.confidence,
|
||||
score=decision.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"entry": entry,
|
||||
"stop": stop,
|
||||
"target1": t1,
|
||||
"target2": t2,
|
||||
"rr": rr,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
__all__ = ["rule_registry"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Rule protocol for Wyckoff Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleHit:
|
||||
"""A single rule match."""
|
||||
|
||||
rule_id: str
|
||||
event: str | None = None
|
||||
phase: str | None = None
|
||||
cycle: str | None = None
|
||||
confidence: float = 0.0
|
||||
score: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
metrics: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class WyckoffRule(ABC):
|
||||
"""Pluggable rule. Engines iterate registry; never hardcode rule lists."""
|
||||
|
||||
rule_id: str
|
||||
category: str # cycle | phase | event
|
||||
timeframes: tuple[str, ...] = ("1d", "1w", "1M")
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
"""Return RuleHit if matched, else None. Pure — no I/O."""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Cycle classification rules (monthly / weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class MarkupCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_markup"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
ma60 = _f(context, "ma60")
|
||||
ma120 = _f(context, "ma120")
|
||||
adx = _f(context, "adx")
|
||||
slope = _f(context, "ma60_slope")
|
||||
if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18:
|
||||
conf = min(95.0, 55 + adx + (10 if close > ma120 else 0))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.MARKUP.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"],
|
||||
metrics={"adx": adx, "slope": slope},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class MarkdownCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_markdown"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
ma60 = _f(context, "ma60")
|
||||
ma120 = _f(context, "ma120")
|
||||
adx = _f(context, "adx")
|
||||
slope = _f(context, "ma60_slope")
|
||||
if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18:
|
||||
conf = min(95.0, 55 + adx + (10 if close < ma120 else 0))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.MARKDOWN.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"],
|
||||
metrics={"adx": adx},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class AccumulationCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_accumulation"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
close = _f(context, "close")
|
||||
ma120 = _f(context, "ma120")
|
||||
vol_trend = _f(context, "volume_trend")
|
||||
# Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution)
|
||||
if adx < 22 and range_pct < 0.28 and close <= ma120:
|
||||
conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.ACCUMULATION.value,
|
||||
confidence=min(90.0, conf),
|
||||
score=min(90.0, conf),
|
||||
reasons=["低趋势强度区间震荡", "疑似吸筹区间"],
|
||||
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class DistributionCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_distribution"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
close = _f(context, "close")
|
||||
ma120 = _f(context, "ma120")
|
||||
vol_trend = _f(context, "volume_trend")
|
||||
# Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation)
|
||||
if adx < 22 and range_pct < 0.28 and close > ma120:
|
||||
conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.DISTRIBUTION.value,
|
||||
confidence=min(90.0, conf),
|
||||
score=min(90.0, conf),
|
||||
reasons=["高位低趋势震荡", "疑似派发区间"],
|
||||
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
# Order: trend cycles first (more decisive), then range cycles
|
||||
return [
|
||||
MarkupCycleRule(),
|
||||
MarkdownCycleRule(),
|
||||
AccumulationCycleRule(),
|
||||
DistributionCycleRule(),
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/..."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _cycle(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("cycle") or {}).get("cycle") or ""
|
||||
|
||||
|
||||
def _phase(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("phase") or {}).get("phase") or ""
|
||||
|
||||
|
||||
class SpringRule(WyckoffRule):
|
||||
rule_id = "event_spring"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.MARKUP.value):
|
||||
# Allow spring only in accumulative contexts; Decision will filter MTF
|
||||
if cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||
pass # still detect for facts but lower confidence
|
||||
pierce = _f(context, "pierce_below_range")
|
||||
reclaim = _f(context, "reclaim_speed")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
close_in_range = _f(context, "close_back_in_range")
|
||||
if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3:
|
||||
strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SPRING.value,
|
||||
confidence=strength,
|
||||
score=strength,
|
||||
reasons=[
|
||||
f"跌破区间后收回 (pierce={pierce:.3%})",
|
||||
f"回收速度={reclaim:.2f}",
|
||||
f"量比={vol_ratio:.2f}",
|
||||
],
|
||||
metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class TestRule(WyckoffRule):
|
||||
rule_id = "event_test"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pos = _f(context, "range_position")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
near_low = pos < 0.2
|
||||
if near_low and vol_ratio < 0.85:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.TEST.value,
|
||||
confidence=68.0,
|
||||
score=65.0,
|
||||
reasons=["低位缩量回测"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SOSRule(WyckoffRule):
|
||||
rule_id = "event_sos"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
breakout = _f(context, "breakout_above_range")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20:
|
||||
conf = min(95.0, 70 + vol_ratio * 8)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SOS.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["放量突破区间上沿 (SOS)"],
|
||||
metrics={"vol_ratio": vol_ratio},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class LPSRule(WyckoffRule):
|
||||
rule_id = "event_lps"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
# Pullback hold above broken range / MA20 after prior strength
|
||||
pullback = _f(context, "pullback_hold")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
above_ma = _f(context, "close") > _f(context, "ma20")
|
||||
if pullback >= 0.5 and above_ma and vol_ratio <= 1.1:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.LPS.value,
|
||||
confidence=74.0,
|
||||
score=76.0,
|
||||
reasons=["突破后缩量回踩支撑 (LPS)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SCRule(WyckoffRule):
|
||||
rule_id = "event_sc"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
bar_range = _f(context, "bar_range_atr")
|
||||
pos = _f(context, "range_position")
|
||||
if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SC.value,
|
||||
confidence=72.0,
|
||||
score=70.0,
|
||||
reasons=["低位放量宽幅,疑似 Selling Climax"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class ARRule(WyckoffRule):
|
||||
rule_id = "event_ar"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
# Automatic rally: bounce from lows
|
||||
bounce = _f(context, "bounce_from_low")
|
||||
if bounce >= 0.04:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.AR.value,
|
||||
confidence=65.0,
|
||||
score=62.0,
|
||||
reasons=["低点后自动反弹 (AR)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class STRule(WyckoffRule):
|
||||
rule_id = "event_st"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pos = _f(context, "range_position")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if 0.15 < pos < 0.45 and vol_ratio < 1.0:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.ST.value,
|
||||
confidence=60.0,
|
||||
score=58.0,
|
||||
reasons=["次级测试 (ST)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class UTADRule(WyckoffRule):
|
||||
rule_id = "event_utad"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
pierce_up = _f(context, "pierce_above_range")
|
||||
fail = _f(context, "fail_back_into_range")
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
WyckoffCycle.MARKUP.value):
|
||||
if pierce_up >= 0.002 and fail >= 0.5:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.UTAD.value,
|
||||
confidence=76.0,
|
||||
score=74.0,
|
||||
reasons=["冲高失败回到区间 (UTAD)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class JumpRule(WyckoffRule):
|
||||
rule_id = "event_jump"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
gap = _f(context, "gap_up_pct")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if gap >= 0.03 and vol_ratio >= 1.3:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.JUMP.value,
|
||||
confidence=70.0,
|
||||
score=72.0,
|
||||
reasons=["放量向上跳跃 (Jump)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class BackupRule(WyckoffRule):
|
||||
rule_id = "event_backup"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pullback = _f(context, "pullback_hold")
|
||||
after_jump = _f(context, "after_strength")
|
||||
if after_jump >= 0.5 and pullback >= 0.5:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.BACKUP.value,
|
||||
confidence=68.0,
|
||||
score=70.0,
|
||||
reasons=["跳跃后回踩 (Backup)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
return [
|
||||
SpringRule(),
|
||||
UTADRule(),
|
||||
SOSRule(),
|
||||
LPSRule(),
|
||||
SCRule(),
|
||||
JumpRule(),
|
||||
BackupRule(),
|
||||
TestRule(),
|
||||
ARRule(),
|
||||
STRule(),
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Phase A–E rules (primarily weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _cycle(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value
|
||||
|
||||
|
||||
class PhaseARule(WyckoffRule):
|
||||
rule_id = "phase_a"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
return None
|
||||
# Stopping action: high vol + large range recently, still range-bound
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
range_last = _f(context, "bar_range_atr")
|
||||
if vol_ratio >= 1.4 and range_last >= 1.2:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.A.value,
|
||||
confidence=70.0,
|
||||
score=65.0,
|
||||
reasons=["放量宽幅波动,疑似 Phase A 停止行为"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseBRule(WyckoffRule):
|
||||
rule_id = "phase_b"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value):
|
||||
return None
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
pos = _f(context, "range_position") # 0=low 1=high of range
|
||||
if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.B.value,
|
||||
confidence=72.0,
|
||||
score=68.0,
|
||||
reasons=["区间中部震荡,疑似 Phase B 建仓/派发"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseCRule(WyckoffRule):
|
||||
rule_id = "phase_c"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
pos = _f(context, "range_position")
|
||||
spring_like = _f(context, "spring_score_hint")
|
||||
utad_like = _f(context, "utad_score_hint")
|
||||
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||
if pos < 0.25 or spring_like >= 50:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.C.value,
|
||||
confidence=75.0 + min(15.0, spring_like * 0.15),
|
||||
score=78.0,
|
||||
reasons=["区间低位测试,疑似 Phase C (Spring/Test)"],
|
||||
)
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
if pos > 0.75 or utad_like >= 50:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.C.value,
|
||||
confidence=75.0,
|
||||
score=78.0,
|
||||
reasons=["区间高位测试,疑似 Phase C (UTAD)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseDRule(WyckoffRule):
|
||||
rule_id = "phase_d"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
range_high = _f(context, "range_high")
|
||||
range_low = _f(context, "range_low")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||
if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.D.value,
|
||||
confidence=80.0,
|
||||
score=82.0,
|
||||
reasons=["突破区间上沿放量,疑似 Phase D SOS"],
|
||||
)
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
if close < ma20 and range_low > 0 and close <= range_low * 1.02:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.D.value,
|
||||
confidence=80.0,
|
||||
score=82.0,
|
||||
reasons=["跌破区间下沿,疑似 Phase D SOW"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseERule(WyckoffRule):
|
||||
rule_id = "phase_e"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
# Markup/Markdown already imply trend continuation (Phase E of prior structure)
|
||||
if cycle == WyckoffCycle.MARKUP.value:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.E.value,
|
||||
confidence=78.0,
|
||||
score=80.0,
|
||||
reasons=["趋势上行,对应 Phase E Markup"],
|
||||
)
|
||||
if cycle == WyckoffCycle.MARKDOWN.value:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.E.value,
|
||||
confidence=78.0,
|
||||
score=80.0,
|
||||
reasons=["趋势下行,对应 Phase E Markdown"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
# More specific phases first
|
||||
return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Rule Registry — register Wyckoff rules without modifying engines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.rules.base import WyckoffRule
|
||||
|
||||
|
||||
class RuleRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._rules: dict[str, WyckoffRule] = {}
|
||||
|
||||
def register(self, rule: WyckoffRule) -> None:
|
||||
self._rules[rule.rule_id] = rule
|
||||
|
||||
def get(self, rule_id: str) -> WyckoffRule | None:
|
||||
return self._rules.get(rule_id)
|
||||
|
||||
def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]:
|
||||
out = [r for r in self._rules.values() if r.category == category]
|
||||
if timeframe:
|
||||
out = [r for r in out if timeframe in r.timeframes]
|
||||
return out
|
||||
|
||||
def all(self) -> list[WyckoffRule]:
|
||||
return list(self._rules.values())
|
||||
|
||||
|
||||
rule_registry = RuleRegistry()
|
||||
|
||||
|
||||
def _register_defaults() -> None:
|
||||
from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules
|
||||
|
||||
for mod in (cycle_rules, phase_rules, event_rules):
|
||||
for rule in mod.build_rules():
|
||||
rule_registry.register(rule)
|
||||
|
||||
|
||||
_register_defaults()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crypto_wyckoff.combos import all_tfs_for_combos, list_combos
|
||||
from crypto_wyckoff.io import (
|
||||
backfill_symbol,
|
||||
bar_count,
|
||||
fetch_symbols_from_provider,
|
||||
tip_update_symbol,
|
||||
)
|
||||
from crypto_wyckoff.pipeline import analyze_and_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
_status: dict = {
|
||||
"running": False,
|
||||
"last_tick_at": None,
|
||||
"last_error": None,
|
||||
"symbols_total": 0,
|
||||
"symbols_scanned": 0,
|
||||
"tick_interval_sec": 60,
|
||||
"backfill_done": False,
|
||||
}
|
||||
_status_lock = threading.Lock()
|
||||
|
||||
|
||||
def _set(**kwargs):
|
||||
with _status_lock:
|
||||
_status.update(kwargs)
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
with _status_lock:
|
||||
return dict(_status)
|
||||
|
||||
|
||||
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
|
||||
"""One cycle: refresh symbols, tip-update, analyze each combo."""
|
||||
symbols = fetch_symbols_from_provider()
|
||||
if max_symbols:
|
||||
symbols = symbols[:max_symbols]
|
||||
combos = list_combos()
|
||||
tfs = all_tfs_for_combos(combos)
|
||||
_set(symbols_total=len(symbols), running=True, last_error=None)
|
||||
scanned = 0
|
||||
errors = 0
|
||||
changed_n = 0
|
||||
|
||||
for i, sym in enumerate(symbols):
|
||||
try:
|
||||
# Prefer low-TF of first combo for "enough history" gate
|
||||
low0 = combos[0]["low"] if combos else "1d"
|
||||
if bar_count(sym, low0) < 40:
|
||||
backfill_symbol(sym, tfs)
|
||||
tip_changed = tip_update_symbol(sym, tfs)
|
||||
if tip_changed:
|
||||
changed_n += 1
|
||||
if force_rescan or tip_changed:
|
||||
for combo in combos:
|
||||
row = analyze_and_store(sym, combo_id=combo["id"])
|
||||
if row:
|
||||
scanned += 1
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 5:
|
||||
logger.warning("tick %s: %s", sym, e)
|
||||
_set(last_error=str(e))
|
||||
if (i + 1) % 25 == 0:
|
||||
_set(symbols_scanned=scanned)
|
||||
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
|
||||
|
||||
_set(
|
||||
running=False,
|
||||
symbols_scanned=scanned,
|
||||
last_tick_at=datetime.now(timezone.utc).isoformat(),
|
||||
backfill_done=True,
|
||||
)
|
||||
return {
|
||||
"symbols": len(symbols),
|
||||
"scanned": scanned,
|
||||
"changed_tips": changed_n,
|
||||
"errors": errors,
|
||||
"combos": [c["id"] for c in combos],
|
||||
"tfs": tfs,
|
||||
}
|
||||
|
||||
|
||||
def _loop(interval: int, max_symbols: int | None):
|
||||
try:
|
||||
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||
except Exception as e:
|
||||
logger.exception("initial tick failed: %s", e)
|
||||
_set(last_error=str(e), running=False)
|
||||
while not _stop.wait(interval):
|
||||
try:
|
||||
# Tip-driven: only force full rescan when tips change is handled inside
|
||||
run_tick(max_symbols=max_symbols, force_rescan=False)
|
||||
except Exception as e:
|
||||
logger.exception("tick failed: %s", e)
|
||||
_set(last_error=str(e), running=False)
|
||||
|
||||
|
||||
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
|
||||
global _thread
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
_set(tick_interval_sec=interval_sec)
|
||||
_thread = threading.Thread(
|
||||
target=_loop,
|
||||
args=(interval_sec, max_symbols),
|
||||
name="crypto-wyckoff-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
_thread.start()
|
||||
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
_stop.set()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Signal Engine — timeframe-local status labels only (not tradability)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
"""Maps local Event/Phase into a status label. Decision decides tradability."""
|
||||
|
||||
name = "Signal"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult:
|
||||
current = event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
conf = event.confidence
|
||||
label = current # status label mirrors event for V1
|
||||
reasons = [f"本地事件标签: {label}"]
|
||||
if phase and phase.payload.get("phase"):
|
||||
reasons.append(f"本地阶段: {phase.payload.get('phase')}")
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=conf,
|
||||
score=event.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"signal_label": label,
|
||||
"current_event": current,
|
||||
"phase": (phase.payload.get("phase") if phase else None),
|
||||
"active_events": event.payload.get("active_events")
|
||||
or event.payload.get("recent_events", []),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""SQLite persistence for crypto wyckoff scan rows (per combo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||
from crypto_wyckoff.io import SCAN_DB, ensure_dirs
|
||||
|
||||
_COLS = [
|
||||
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version",
|
||||
"m_cycle", "cycle_confidence", "trend_score",
|
||||
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
|
||||
"phase_confidence", "structure_score",
|
||||
"d_current_event", "d_recent_events_json", "event_confidence", "entry_score",
|
||||
"entry", "stop", "target1", "target2", "rr",
|
||||
"alignment", "stars", "decision_signal", "signal_confidence",
|
||||
"overall_confidence", "overall_score", "risk", "reasons_json",
|
||||
"feature_snapshot_json", "markers_json", "scanned_at",
|
||||
]
|
||||
|
||||
_CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS wyckoff_scan (
|
||||
trade_date TEXT NOT NULL,
|
||||
combo_id TEXT NOT NULL DEFAULT 'd_w_m',
|
||||
ts_code TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
industry TEXT DEFAULT '',
|
||||
engine_version TEXT,
|
||||
m_cycle TEXT, cycle_confidence REAL, trend_score REAL,
|
||||
w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT,
|
||||
phase_confidence REAL, structure_score REAL,
|
||||
d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL,
|
||||
entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL,
|
||||
alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL,
|
||||
overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT,
|
||||
feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT,
|
||||
PRIMARY KEY (trade_date, combo_id, ts_code)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _migrate(c: sqlite3.Connection) -> None:
|
||||
cur = c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'"
|
||||
)
|
||||
if not cur.fetchone():
|
||||
c.execute(_CREATE_SQL)
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
return
|
||||
|
||||
cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")}
|
||||
if "combo_id" in cols:
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
return
|
||||
|
||||
# Legacy PK (trade_date, ts_code) → add combo_id via table rebuild
|
||||
c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old")
|
||||
c.execute(_CREATE_SQL)
|
||||
old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")]
|
||||
shared = [col for col in _COLS if col != "combo_id" and col in old_cols]
|
||||
col_sql = ",".join(shared)
|
||||
c.execute(
|
||||
f"""
|
||||
INSERT INTO wyckoff_scan (combo_id, {col_sql})
|
||||
SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old
|
||||
"""
|
||||
)
|
||||
c.execute("DROP TABLE wyckoff_scan_old")
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
c = sqlite3.connect(str(SCAN_DB), timeout=60)
|
||||
c.row_factory = sqlite3.Row
|
||||
_migrate(c)
|
||||
c.commit()
|
||||
return c
|
||||
|
||||
|
||||
def upsert_row(row: WyckoffScanRow) -> None:
|
||||
combo_id = getattr(row, "combo_id", None) or "d_w_m"
|
||||
vals = (
|
||||
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date),
|
||||
combo_id,
|
||||
row.ts_code, row.name, row.industry, row.engine_version,
|
||||
row.m_cycle, row.cycle_confidence, row.trend_score,
|
||||
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
|
||||
row.phase_confidence, row.structure_score,
|
||||
row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score,
|
||||
row.entry, row.stop, row.target1, row.target2, row.rr,
|
||||
row.alignment, row.stars, row.decision_signal, row.signal_confidence,
|
||||
row.overall_confidence, row.overall_score, row.risk, row.reasons_json,
|
||||
row.feature_snapshot_json, row.markers_json,
|
||||
row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at),
|
||||
)
|
||||
c = _conn()
|
||||
try:
|
||||
placeholders = ",".join("?" * len(_COLS))
|
||||
col_sql = ",".join(_COLS)
|
||||
updates = ",".join(
|
||||
f"{col}=excluded.{col}"
|
||||
for col in _COLS
|
||||
if col not in ("trade_date", "combo_id", "ts_code")
|
||||
)
|
||||
c.execute(
|
||||
f"""
|
||||
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
|
||||
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates}
|
||||
""",
|
||||
vals,
|
||||
)
|
||||
c.commit()
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def latest_trade_date(combo_id: str | None = None) -> str | None:
|
||||
c = _conn()
|
||||
try:
|
||||
if combo_id:
|
||||
cur = c.execute(
|
||||
"SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?",
|
||||
(combo_id,),
|
||||
)
|
||||
else:
|
||||
cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else None
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int:
|
||||
td = trade_date or latest_trade_date(combo_id)
|
||||
if not td:
|
||||
return 0
|
||||
c = _conn()
|
||||
try:
|
||||
if combo_id:
|
||||
cur = c.execute(
|
||||
"SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?",
|
||||
(td, combo_id),
|
||||
)
|
||||
else:
|
||||
cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def query_scan(
|
||||
*,
|
||||
trade_date: str | None = None,
|
||||
combo_id: str | None = None,
|
||||
m_cycle: str | None = None,
|
||||
w_phase: str | None = None,
|
||||
d_event: str | None = None,
|
||||
decision_signal: str | None = None,
|
||||
min_overall_score: float | None = None,
|
||||
min_alignment: float | None = None,
|
||||
sort: str = "overall_score",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
cid = combo_id or "d_w_m"
|
||||
td = trade_date or latest_trade_date(cid)
|
||||
if not td:
|
||||
return []
|
||||
sort_col = sort if sort in {
|
||||
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
|
||||
} else "overall_score"
|
||||
clauses = ["trade_date=?", "combo_id=?"]
|
||||
args: list[Any] = [td, cid]
|
||||
if m_cycle:
|
||||
clauses.append("m_cycle=?")
|
||||
args.append(m_cycle)
|
||||
if w_phase:
|
||||
clauses.append("w_phase=?")
|
||||
args.append(w_phase)
|
||||
if d_event:
|
||||
clauses.append("d_current_event=?")
|
||||
args.append(d_event)
|
||||
if decision_signal:
|
||||
clauses.append("decision_signal=?")
|
||||
args.append(decision_signal)
|
||||
if min_overall_score is not None:
|
||||
clauses.append("overall_score>=?")
|
||||
args.append(min_overall_score)
|
||||
if min_alignment is not None:
|
||||
clauses.append("alignment>=?")
|
||||
args.append(min_alignment)
|
||||
where = " AND ".join(clauses)
|
||||
args.extend([limit, offset])
|
||||
c = _conn()
|
||||
try:
|
||||
cur = c.execute(
|
||||
f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?",
|
||||
args,
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def get_symbol(
|
||||
ts_code: str,
|
||||
trade_date: str | None = None,
|
||||
combo_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
cid = combo_id or "d_w_m"
|
||||
td = trade_date or latest_trade_date(cid)
|
||||
if not td:
|
||||
return None
|
||||
c = _conn()
|
||||
try:
|
||||
cur = c.execute(
|
||||
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?",
|
||||
(td, cid, ts_code),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
c.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Crypto symbol → Chinese display name for screener UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base)
|
||||
_BASE_CN: dict[str, str] = {
|
||||
"BTC": "比特币",
|
||||
"ETH": "以太坊",
|
||||
"SOL": "索拉纳",
|
||||
"XAU": "黄金",
|
||||
"XAG": "白银",
|
||||
"SAGA": "Saga",
|
||||
"CL": "原油",
|
||||
"ZEC": "大零币",
|
||||
"XRP": "瑞波币",
|
||||
"DOGE": "狗狗币",
|
||||
"BNB": "币安币",
|
||||
"SUI": "Sui",
|
||||
"BILL": "Bill",
|
||||
"BZ": "BZ",
|
||||
"LAB": "Lab",
|
||||
"TON": "通联币",
|
||||
"CRCL": "Circle",
|
||||
"SNDK": "SNDK",
|
||||
"1000PEPE": "千倍佩佩",
|
||||
"PEPE": "佩佩",
|
||||
"CHIP": "CHIP",
|
||||
"WIF": "狗帽子",
|
||||
}
|
||||
|
||||
|
||||
def base_asset(symbol: str) -> str:
|
||||
"""BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE."""
|
||||
s = (symbol or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
head = s.split(":")[0]
|
||||
return head.split("/")[0].upper() if "/" in head else head.upper()
|
||||
|
||||
|
||||
def display_name_cn(symbol: str) -> str:
|
||||
base = base_asset(symbol)
|
||||
if not base:
|
||||
return symbol or ""
|
||||
return _BASE_CN.get(base, base)
|
||||
|
||||
|
||||
def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]:
|
||||
if not symbols:
|
||||
return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()}
|
||||
return {s: display_name_cn(s) for s in symbols}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Wyckoff Screener engine version — bump when rules change."""
|
||||
|
||||
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
||||
ARCHITECTURE_VERSION = "1.0"
|
||||
@@ -22,16 +22,13 @@
|
||||
- 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
|
||||
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||
|
||||
## 硬约束提醒
|
||||
|
||||
- `/api/analyze` 字段可增不可删
|
||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
|
||||
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||
- 威科夫为独立叠层(ECR-003);勿借机改缠论算法
|
||||
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||
|
||||
## 已知债务
|
||||
|
||||
@@ -40,4 +37,3 @@
|
||||
- 内存泄漏尚无自动化 heap/监听断言
|
||||
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||
- 威科夫启发式参数未做 UI 调参
|
||||
- ECR-007 待 PR 合入 `dev`
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Backend Design: ECR-007 Wyckoff Live Structure
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | BD-2026-007 |
|
||||
| ECR | ECR-007 |
|
||||
| Change Level | L2 |
|
||||
| Status | Approved |
|
||||
| Author | Architect (LOOP-RUN-005 Planner) |
|
||||
| Date | 2026-08-07 |
|
||||
| Risk | High (domain / execution boundary) |
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
|
||||
- 非目标:改 Confirmed 门槛;自动交易;策略。
|
||||
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-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,14 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased — 2026-08-07
|
||||
|
||||
### 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)
|
||||
@@ -16,7 +7,6 @@
|
||||
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
|
||||
|
||||
### ECR-003(L2,Reviewed)
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# ECR-007
|
||||
|
||||
**Title:** Wyckoff Live Structure
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-07
|
||||
**Change Level:** L2
|
||||
**Human:** Approved (LOOP-RUN-005 Start Authorization)
|
||||
|
||||
## Change
|
||||
|
||||
Add **Live / Developing** structure layer beside **Confirmed** Wyckoff engine: lifecycle, FORMING candidates (Spring/SOS/LPS/UTAD), explainable confidence, Summary partition. Keep Confirmed thresholds unchanged; execution may only consume Confirmed.
|
||||
|
||||
## Motivation
|
||||
|
||||
LOOP-RUN-005 — domain-state complexity under Adapter v0.1 STABLE (Confirmed ≠ Live ≠ execution).
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed (IN)
|
||||
|
||||
- `chanlun/analysis/wyckoff/live.py` + engine assembly
|
||||
- lifecycle / confirmed / live payload
|
||||
- Event candidates + confidence
|
||||
- API contract + Summary UI
|
||||
- tests + docs notes (WYCKOFF-LIVE-STRUCTURE-001)
|
||||
|
||||
### Forbidden (OUT)
|
||||
|
||||
- execution signal automation / auto trading
|
||||
- strategy / maker / decide_quotes / `strategies/**`
|
||||
- lowering Confirmed thresholds
|
||||
- Live candidate replacing Confirmed
|
||||
- ESS / Loop / Adapter changes
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Live → execution | `execution_signal_from_wyckoff` source=confirmed only; live-only → None |
|
||||
| Confirmed pollution | candidates never written to confirmed.events |
|
||||
| Domain confusion in UI | Summary Confirmed vs Live partitions |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Approved BD-2026-007
|
||||
- [ ] Confirmed logic not relaxed
|
||||
- [ ] Live ≠ execution signal (tests)
|
||||
- [ ] Lifecycle verifiable
|
||||
- [ ] Artifact chain + Gate PASS
|
||||
|
||||
## Rollback
|
||||
|
||||
- Disable live assembly; remove live.py; revert Summary partition
|
||||
|
||||
## Linked
|
||||
|
||||
- Note: `docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md` (FROZEN)
|
||||
- BACKEND_DESIGN: `docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md`
|
||||
- Loop: LOOP-RUN-005
|
||||
@@ -1,26 +0,0 @@
|
||||
# ENGINEERING_SPEC — ECR-007 Wyckoff Live Structure
|
||||
|
||||
**ECR:** ECR-007
|
||||
**BD:** BD-2026-007
|
||||
**Status:** Approved
|
||||
|
||||
## Intent
|
||||
|
||||
Operators observe FORMING Wyckoff structure without feeding Live into execution.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `events.py` / `range.py` | Confirmed facts |
|
||||
| `live.py` | Live candidates + confidence + lifecycle hint |
|
||||
| `engine.py` | Assemble cycles[].confirmed / .live |
|
||||
| `execution_signal_from_wyckoff` | Confirmed-only gate |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`UNKNOWN → FORMING → CONFIRMED → COMPLETED`
|
||||
|
||||
## Non-goals
|
||||
|
||||
strategies, maker, Live-as-signal, Confirmed threshold cuts.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Handoff
|
||||
|
||||
**From:** Architect
|
||||
**To:** Engineer
|
||||
**ECR:** ECR-007
|
||||
**State:** build
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Artifacts
|
||||
- [x] ECR-007 Approved
|
||||
- [x] BACKEND_DESIGN BD-2026-007
|
||||
- [x] Note WYCKOFF-LIVE-STRUCTURE-001 FROZEN
|
||||
- [ ] TEST_REPORT / CODE_REVIEW
|
||||
|
||||
## Restrictions
|
||||
- Do not lower Confirmed thresholds
|
||||
- Do not let Live feed execution
|
||||
- Do not touch strategies/**
|
||||
|
||||
## Goal
|
||||
Ship Confirmed/Live separation + tests + Summary; Gate PASS.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Code Review — ECR-007
|
||||
|
||||
**From:** Reviewer
|
||||
**To:** Guardian / Human
|
||||
**ECR:** ECR-007
|
||||
**BD:** BD-2026-007
|
||||
**Date:** 2026-08-07
|
||||
**Decision:** PASS
|
||||
|
||||
## Checklist
|
||||
|
||||
| Item | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| State machine boundary | PASS | lifecycle UNKNOWN/FORMING/CONFIRMED/COMPLETED; cycles[0]=ACTIVE |
|
||||
| confidence explainability | PASS | cycle/phase/event/structure/volume/overall — not black-box |
|
||||
| backward compatibility | PASS | top-level phases/events still Confirmed mirror |
|
||||
| Live ≠ execution | PASS | execution_signal_from_wyckoff source=confirmed; live-only None |
|
||||
| Confirmed thresholds | PASS | no intentional cut for Live; structural support fix is robustness (eaten spring) |
|
||||
|
||||
## Findings
|
||||
|
||||
1. Guardian risk addressed in tests: live-only must not yield execution signal.
|
||||
2. Summary UI partitions Confirmed vs Live (observation).
|
||||
|
||||
## Decision
|
||||
|
||||
**PASS**
|
||||
@@ -1,14 +0,0 @@
|
||||
# Handoff — Engineer → Reviewer
|
||||
|
||||
**ECR:** ECR-007
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Delivered
|
||||
|
||||
- `chanlun/analysis/wyckoff/live.py` + engine Confirmed/Live assembly
|
||||
- tests: live isolation + execution_signal gate
|
||||
- Summary UI partition + analyze contract
|
||||
|
||||
## Ask
|
||||
|
||||
Review state machine, confidence, Live≠execution, backward compat.
|
||||
@@ -34,11 +34,10 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(Live Structure,待合入 `dev`)
|
||||
- ECR: ECR-002/003/004 Reviewed(威科夫 + 硬化)
|
||||
- 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-007 Final Approval;待合入 `dev`)
|
||||
**phase:** post-approval
|
||||
**active_ecr:** none(ECR-004 Reviewed;待本批提交合入)
|
||||
**phase:** post-review
|
||||
**system_version:** v1.0.0
|
||||
**strategy_version:** unchanged
|
||||
**updated:** 2026-08-07
|
||||
**updated:** 2026-08-06
|
||||
|
||||
## Recent
|
||||
|
||||
@@ -16,11 +16,8 @@
|
||||
| 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 · `276481e` · LOOP-RUN-005 |
|
||||
|
||||
## Notes
|
||||
|
||||
- ECR-007:**FINAL_APPROVAL** · gate PASS · Confirmed ≠ Live ≠ execution
|
||||
- 归档:`docs/runs/LOOP-RUN-005/`
|
||||
- ECR-004:**Approve**(14 passed);无币种独立参数
|
||||
- 未请求新 system tag
|
||||
- 分支 `feature/ECR-007-wyckoff-live-structure` 待 PR → `dev`
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
ecr: ECR-007
|
||||
owner: human
|
||||
phase: done
|
||||
updated: 2026-08-07
|
||||
backend_design: BD-2026-007
|
||||
loop: LOOP-RUN-005
|
||||
gate: PASS
|
||||
decision: FINAL_APPROVAL
|
||||
implementation_commit: 276481e
|
||||
notes: LOOP-RUN-005 DONE · Human Gate #2 Final Approval · archived to docs/runs/LOOP-RUN-005/
|
||||
@@ -1,33 +0,0 @@
|
||||
# TEST_REPORT — ECR-007
|
||||
|
||||
**Date:** 2026-08-07
|
||||
**BD:** BD-2026-007
|
||||
**Loop:** LOOP-RUN-005
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q
|
||||
PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
```text
|
||||
tests/test_wyckoff.py ………… 9 passed
|
||||
web/tests/test_analyze_contract.py ……… 8 passed
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| Live candidates not pollute confirmed.events | PASS |
|
||||
| CONFIRMED + execution source=confirmed | PASS |
|
||||
| live-only → execution None | PASS |
|
||||
| analyze contract keys | PASS |
|
||||
|
||||
## Design Compliance
|
||||
|
||||
PASS — BD-2026-007; Live ≠ execution; Confirmed thresholds not cut for Live convenience
|
||||
**Commit:** 276481e
|
||||
@@ -44,12 +44,3 @@
|
||||
| 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 |
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# WYCKOFF-LIVE-STRUCTURE-001
|
||||
|
||||
**Status:** FROZEN
|
||||
**Depends on:** WYCKOFF-MULTI-CYCLE-001
|
||||
**Scope:** Live / Developing 结构层(独立于 Confirmed Engine)
|
||||
|
||||
## 核心原则
|
||||
|
||||
| Layer | 定位 |
|
||||
|-------|------|
|
||||
| Confirmed Engine | 历史结构事实 |
|
||||
| Live Engine | 当前结构推演 |
|
||||
|
||||
禁止:
|
||||
|
||||
- 降低 Spring/SOS Confirmed 条件
|
||||
- 用 Live candidate 替代 Confirmed event
|
||||
- Execution 消费 Live / FORMING / Candidate / Prediction
|
||||
|
||||
## 状态机
|
||||
|
||||
```
|
||||
UNKNOWN → FORMING → CONFIRMED → COMPLETED
|
||||
```
|
||||
|
||||
## 数据契约(Live 不进 events[])
|
||||
|
||||
```json
|
||||
{
|
||||
"cycles": [{
|
||||
"id": 0,
|
||||
"lifecycle": "FORMING",
|
||||
"confirmed": { "phases": [], "events": [] },
|
||||
"live": {
|
||||
"phase_candidate": "D",
|
||||
"event_candidates": [{ "type": "SOS", "confidence": 0.62, "confirmed": false }],
|
||||
"next_expected": "LPS",
|
||||
"confidence": { "cycle": 0.72, "phase": 0.68, "event": 0.55, "overall": 0.65 }
|
||||
}
|
||||
}],
|
||||
"live": { "...": "顶层镜像 cycles[0].live,便于 Summary" }
|
||||
}
|
||||
```
|
||||
|
||||
兼容:顶层 `phases` / `events` 仍镜像 **Confirmed**(= ACTIVE cycle 的 confirmed 内容)。
|
||||
|
||||
## Candidate v1(仅启发式)
|
||||
|
||||
- Range Formation:横盘时长、波动收敛 → Potential Trading Range
|
||||
- Phase C candidate:测低 / 下影 / 缩量
|
||||
- Event 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 面板分区
|
||||
- 测例
|
||||
@@ -1,76 +0,0 @@
|
||||
# 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 文案升级(独立小项)
|
||||
@@ -1,62 +0,0 @@
|
||||
# WYCKOFF-MULTI-CYCLE-001
|
||||
|
||||
**Status:** FROZEN
|
||||
**Scope:** Wyckoff Cycle Detection Layer
|
||||
|
||||
## No Change
|
||||
|
||||
- `chan.py` / 笔 / 段 / 中枢
|
||||
- `strategies/`
|
||||
- `/chan_tv`
|
||||
|
||||
## Only Change
|
||||
|
||||
- wyckoff range detection
|
||||
- wyckoff engine payload
|
||||
- API localization
|
||||
- chart rendering
|
||||
- tests
|
||||
|
||||
## Frozen Rules
|
||||
|
||||
1. 每个 TF 最大 8 个周期
|
||||
2. `cycles[0]` 永远为 ACTIVE;`cycles[1:]` 为 HISTORICAL
|
||||
3. **禁止**用 `cycles[-1]` 判断 active;唯一来源:`active_cycle = cycles[0]`
|
||||
4. 周期不可重叠;按时间倒序(近 → 远)
|
||||
5. 顶层字段只镜像 `cycles[0]`
|
||||
6. 历史 cycle 只用于展示/分析,不参与当前交易决策
|
||||
7. 多 TF 只同步 active cycle(`prefer_start_time` ← 主 TF `cycles[0]`)
|
||||
8. 每个 cycle 必须可追溯:`period` / `status` / `role` / `confidence`
|
||||
9. 嵌套箱:`overlap_ratio < 0.2` 才可并存;否则丢弃
|
||||
10. 验收重点:历史周期稳定复现 + active 不漂移
|
||||
|
||||
## Layer Duties
|
||||
|
||||
```
|
||||
range.py
|
||||
_detect_in_window() → TradingRange # 仅起止、高低、结构分
|
||||
detect_trading_ranges() → list[TR] # 倒序扫 + 过滤 + mask
|
||||
|
||||
engine.py
|
||||
phases / events / VP / confidence aggregation → cycles[]
|
||||
```
|
||||
|
||||
## Filter Order(不可改)
|
||||
|
||||
```
|
||||
candidate window
|
||||
→ detect range
|
||||
→ quality filter
|
||||
→ trend contamination filter
|
||||
→ overlap filter (<0.2)
|
||||
→ accept cycle
|
||||
→ mask
|
||||
```
|
||||
|
||||
禁止先 mask 再判断质量。
|
||||
|
||||
## Display / Summary (2026-08-06)
|
||||
|
||||
- 图面阶段标记:`{TF} C{id} Phase {X}`;事件:`{TF} C{id} {Event}`
|
||||
- Cycle Summary 面板:消费 `cycles[0]`,写入 `window.wyckoffCycleSummary`
|
||||
- 检测算法本轮不改;质量阈值 / 历史层折叠为后续项
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"ecr": "ECR-007",
|
||||
"result": "PASS",
|
||||
"ess_version": "v1.0",
|
||||
"gate_version": "0.1.2",
|
||||
"project_profile": "unknown",
|
||||
"checks": {
|
||||
"artifact": true,
|
||||
"role_boundary": true,
|
||||
"backend_boundary": true,
|
||||
"traceability": true,
|
||||
"tests": true
|
||||
},
|
||||
"violations": [],
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"timestamp": "2026-08-06T19:14:19Z"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
# LOOP-RUN-005 — ECR-007 archive
|
||||
|
||||
**Feature:** WYCKOFF-LIVE-STRUCTURE
|
||||
**ECR:** ECR-007 · **BD:** BD-2026-007
|
||||
**Decision:** FINAL_APPROVAL · gate PASS
|
||||
**Implementation:** `276481e`
|
||||
|
||||
## Contents
|
||||
|
||||
| Path | Note |
|
||||
|------|------|
|
||||
| `task.yaml` / `result.yaml` / `human_interventions.yaml` | Loop runner state |
|
||||
| `ECR-007-gate-report.json` | ess-gate-check PASS |
|
||||
| `artifacts/` | plan · gate · code_review · test_report |
|
||||
|
||||
Code diff 以 git commit `276481e` 为准(未归档 192KB `diff.patch`)。
|
||||
|
||||
Working dirs `.gates/` / `loop/` 已忽略,勿再提交。
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"stage": "code_reviewer",
|
||||
"decision": "PASS",
|
||||
"checks": {
|
||||
"state_machine_boundary": "PASS",
|
||||
"confidence_explainability": "PASS",
|
||||
"backward_compatibility": "PASS",
|
||||
"live_ne_execution": "PASS",
|
||||
"confirmed_thresholds": "PASS"
|
||||
},
|
||||
"artifact": "docs/HANDOFF/ECR-007-code-review.md"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"ecr": "ECR-007",
|
||||
"result": "PASS",
|
||||
"ess_version": "v1.0",
|
||||
"gate_version": "0.1.2",
|
||||
"project_profile": "unknown",
|
||||
"checks": {
|
||||
"artifact": true,
|
||||
"role_boundary": true,
|
||||
"backend_boundary": true,
|
||||
"traceability": true,
|
||||
"tests": true
|
||||
},
|
||||
"violations": [],
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"timestamp": "2026-08-06T19:14:19Z"
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
artifact_schema:
|
||||
version: 1
|
||||
|
||||
# LOOP-RUN-005 Planner — domain-state complexity (observe Confirmed vs Live)
|
||||
|
||||
layers:
|
||||
- id: confirmed_engine
|
||||
role: historical structure facts (range/phases/events) — thresholds UNCHANGED
|
||||
- id: live_engine
|
||||
role: FORMING candidates + confidence — independent of Confirmed writes
|
||||
- id: lifecycle
|
||||
role: UNKNOWN → FORMING → CONFIRMED → COMPLETED per cycle
|
||||
- id: api_contract
|
||||
role: analyze payload cycles[].confirmed / cycles[].live / top-level live mirror
|
||||
- id: summary_ui
|
||||
role: Confirmed vs Live partitioned Summary (observation only)
|
||||
|
||||
delivery_constraints:
|
||||
required:
|
||||
- commit_exists_in_traceability_or_test_report
|
||||
- bd_status_format_approved
|
||||
- test_report_with_commands_result_date
|
||||
- code_review_handoff
|
||||
- out_of_scope_declared
|
||||
- execution_source_confirmed_only
|
||||
gate:
|
||||
ecr: ECR-007
|
||||
command: ess-gate-check --ecr ECR-007
|
||||
|
||||
out_of_scope:
|
||||
- execution signal automation / auto trading
|
||||
- strategy / maker / decide_quotes / strategies/**
|
||||
- lowering Confirmed Spring/SOS thresholds
|
||||
- using Live candidate as Confirmed event or execution input
|
||||
- Subagents / Adapter v0.2 / auto-retry
|
||||
- chan algorithm (笔/线段/中枢) changes
|
||||
|
||||
scope:
|
||||
files:
|
||||
- chanlun/analysis/wyckoff/live.py
|
||||
- chanlun/analysis/wyckoff/engine.py
|
||||
- chanlun/analysis/wyckoff/__init__.py
|
||||
- chanlun/analysis/wyckoff/events.py
|
||||
- chanlun/analysis/wyckoff/range.py
|
||||
- tests/test_wyckoff.py
|
||||
- web/api/analyze.py
|
||||
- web/static/js/app/ui.js
|
||||
- web/templates/index.html
|
||||
- web/tests/test_analyze_contract.py
|
||||
- tests/fixtures/analyze_contract_keys.json
|
||||
- docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md
|
||||
- docs/ECR/ECR-007-wyckoff-live-structure.md
|
||||
- docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md
|
||||
- docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md
|
||||
- docs/HANDOFF/ECR-007-architect-to-engineer.md
|
||||
- docs/HANDOFF/ECR-007-code-review.md
|
||||
- docs/HANDOFF/ECR-007-engineer-to-reviewer.md
|
||||
- docs/TEST_REPORT/ECR-007.md
|
||||
- docs/STATE/ECR-007.md
|
||||
- docs/TRACEABILITY.md
|
||||
- docs/CHANGELOG/CHANGELOG.md
|
||||
|
||||
boundary:
|
||||
forbidden:
|
||||
- strategies/
|
||||
- decide_quotes / maker
|
||||
- Live → execution_signal
|
||||
- ESS / Loop v1.1 / Adapter v0.1
|
||||
|
||||
acceptance:
|
||||
- lifecycle + confirmed/live separation in analyze_wyckoff output
|
||||
- event_candidates confirmed=false; not in top-level events unless Confirmed
|
||||
- execution_signal_from_wyckoff source==confirmed; live-only → None
|
||||
- Summary shows Confirmed vs Live partition
|
||||
- pytest test_wyckoff + analyze_contract green
|
||||
- ess-gate-check ECR-007
|
||||
|
||||
risks: |
|
||||
Primary Guardian risk: Live candidate mistaken for execution signal.
|
||||
Code Review: state machine boundary, confidence explainability, backward compat of phases/events.
|
||||
|
||||
notes: |
|
||||
Planner must name Confirmed / Live / Lifecycle / Event Candidate explicitly.
|
||||
delivery_constraints include execution_source_confirmed_only.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"stage": "validator",
|
||||
"result": "PASS",
|
||||
"commands": [
|
||||
"PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q",
|
||||
"PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||
],
|
||||
"summary": "17 passed (9 wyckoff + 8 contract)",
|
||||
"notes": "Live isolation + execution_signal confirmed-only"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
interventions:
|
||||
- stage: START_AUTHORIZATION
|
||||
reason: "authorize LOOP-RUN-005 ECR-007 Wyckoff Live Structure (supervised; Adapter v0.1 STABLE)"
|
||||
note: "Human Gate #1 — Goal + Authorization merged"
|
||||
- stage: FINAL_APPROVAL
|
||||
reason: "LOOP-RUN-005 approved — proceed to --approve and archive"
|
||||
note: "Human Gate #2"
|
||||
notes: |
|
||||
No Plan Mode; no mid-build confirm; no Subagents / Adapter v0.2 / auto-retry.
|
||||
Live ≠ execution signal held; TR-COMMIT BLOCK→PASS retained as training signal.
|
||||
Final Approval distinct from Start Authorization.
|
||||
@@ -1,49 +0,0 @@
|
||||
loop:
|
||||
id: LOOP-RUN-005
|
||||
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||
ecr: ECR-007
|
||||
current_state: DONE
|
||||
retry_count: 0
|
||||
history:
|
||||
- state: CREATED
|
||||
timestamp: '2026-08-06T19:12:00Z'
|
||||
actor: runner
|
||||
result: INIT
|
||||
- state: CREATED
|
||||
timestamp: '2026-08-06T19:13:48Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →PLANNING
|
||||
- state: PLANNING
|
||||
timestamp: '2026-08-06T19:13:48Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →BUILDING
|
||||
- state: BUILDING
|
||||
timestamp: '2026-08-06T19:14:34Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →VALIDATING
|
||||
- state: VALIDATING
|
||||
timestamp: '2026-08-06T19:14:34Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →CODE_REVIEW
|
||||
- state: CODE_REVIEW
|
||||
timestamp: '2026-08-06T19:14:34Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →GUARDING
|
||||
- state: GUARDING
|
||||
timestamp: '2026-08-06T19:14:34Z'
|
||||
actor: runner
|
||||
result: PASS
|
||||
detail: →READY_FOR_APPROVAL
|
||||
- state: READY_FOR_APPROVAL
|
||||
timestamp: '2026-08-06T19:19:41Z'
|
||||
actor: runner
|
||||
result: APPROVED
|
||||
- state: DONE
|
||||
timestamp: '2026-08-06T19:19:41Z'
|
||||
actor: runner
|
||||
result: DONE
|
||||
@@ -1,77 +0,0 @@
|
||||
# LOOP-RUN-005 — ECR-007 Wyckoff Live Structure
|
||||
# Adapter v0.1 STABLE · single agent · supervised
|
||||
# Human Gate #1: Start Authorization granted
|
||||
|
||||
id: LOOP-RUN-005
|
||||
feature: ECR-007-WYCKOFF-LIVE-STRUCTURE
|
||||
ecr: ECR-007
|
||||
project_profile: "2026.08"
|
||||
|
||||
goal: |
|
||||
验证 Engineering Loop v1.1 + Adapter v0.1 在高领域状态复杂度 Feature 下的执行稳定性。
|
||||
实现 Wyckoff Confirmed + Live Structure 分层,观察层与执行层严格隔离。
|
||||
|
||||
authorization:
|
||||
approved_by: human
|
||||
feature: ECR-007
|
||||
run: LOOP-RUN-005
|
||||
constraints:
|
||||
- no_ess_change
|
||||
- no_loop_v1_1_change
|
||||
- no_adapter_v0_1_change
|
||||
- single_agent
|
||||
- supervised
|
||||
- no_subagents
|
||||
- no_auto_retry
|
||||
- no_live_as_execution_signal
|
||||
- no_confirmed_threshold_lowering
|
||||
|
||||
constraints:
|
||||
allowed:
|
||||
- "chanlun/analysis/wyckoff/**"
|
||||
- "tests/test_wyckoff.py"
|
||||
- "tests/fixtures/**"
|
||||
- "tests/generate_golden.py"
|
||||
- "tests/test_golden_pipeline.py"
|
||||
- "web/api/analyze.py"
|
||||
- "web/api/pages.py"
|
||||
- "web/static/js/app/**"
|
||||
- "web/templates/index.html"
|
||||
- "web/tests/**"
|
||||
- "web/services/runtime/timeframes.py"
|
||||
- "docs/**"
|
||||
- "loop/**"
|
||||
forbidden:
|
||||
- "strategies/**"
|
||||
- "**/decide_quotes*"
|
||||
- "maker/**"
|
||||
- "skills/engineering-spec-system/**"
|
||||
- "docs/architecture/ENGINEERING-LOOP-V1.1.md"
|
||||
notes:
|
||||
- Confirmed detection thresholds UNCHANGED
|
||||
- Live candidates must never replace Confirmed events
|
||||
- execution_signal_from_wyckoff source must be confirmed only
|
||||
|
||||
acceptance:
|
||||
criteria:
|
||||
- Confirmed logic unchanged (events.py confirm rules not relaxed)
|
||||
- execution only consumes confirmed
|
||||
- Live ≠ execution signal
|
||||
- lifecycle transitions verifiable (UNKNOWN/FORMING/CONFIRMED/COMPLETED)
|
||||
- API contract + Summary display Confirmed/Live separation
|
||||
- Artifact chain complete
|
||||
commands:
|
||||
- "PYTHONPATH=. python -m pytest tests/test_wyckoff.py -q"
|
||||
- "PYTHONPATH=. python -m pytest web/tests/test_analyze_contract.py -q"
|
||||
|
||||
execution:
|
||||
autonomy: supervised
|
||||
adapter: none
|
||||
|
||||
ess:
|
||||
gate_command: "python ${ESS_ROOT}/scripts/ess-gate-check.py --project . --ecr ECR-007"
|
||||
|
||||
observe:
|
||||
planner_domain: Confirmed + Live + Lifecycle + Event Candidate
|
||||
guardian_risk: live_candidate_must_not_become_execution_signal
|
||||
human_gates: start_authorization + final_approval
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。"""
|
||||
|
||||
from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask
|
||||
|
||||
__all__ = [
|
||||
"compute_market_state_8h",
|
||||
"spring_gate_mask",
|
||||
"utad_gate_mask",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,155 @@
|
||||
# Dry-Run Decision Checklist — GATED_V1_1_LOCKED
|
||||
|
||||
```text
|
||||
Purpose: 上线前不改规则,只验执行链
|
||||
Stack: Market State → Decision → Frozen Signal
|
||||
Version: GATED_V1_1_LOCKED
|
||||
Mode: dry-run / monitoring only
|
||||
```
|
||||
|
||||
研究线已收手。本清单是 **operational acceptance**,不是新实验。
|
||||
|
||||
---
|
||||
|
||||
## Locked defaults(不可在 dry-run 中改动)
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Strategy | `Wyckoff_BTC_GATED` |
|
||||
| Spring | `V1_BASELINE` FROZEN |
|
||||
| Gate | `market_state in {accumulation, markup}` → allow Spring |
|
||||
| Soft-score | rejected |
|
||||
| Range | observe only(非交易规则) |
|
||||
| gate_version | `GATED_V1_1_LOCKED` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 信号一致性
|
||||
|
||||
上线前逐项勾选:
|
||||
|
||||
- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 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`
|
||||
@@ -0,0 +1,63 @@
|
||||
# Wyckoff BTC System v1 — Decision Rule Locked
|
||||
|
||||
```
|
||||
Architecture: Market State → Decision → Signal
|
||||
|
||||
Spring: FROZEN
|
||||
Gate v1.1: LOCKED DEFAULT Decision rule (PASS)
|
||||
Soft-score: REJECTED (no increment)
|
||||
Hard-score: REJECTED
|
||||
|
||||
Minimal rule:
|
||||
market_state in {accumulation, markup} -> allow Spring
|
||||
else -> block Spring
|
||||
|
||||
Primary invalidation domain: distribution
|
||||
range: observation bucket only (NOT a trading rule)
|
||||
|
||||
Validity: DEFINED
|
||||
Confidence: MEDIUM / defined-domain PASS
|
||||
Status: DEFAULT RULES FROZEN
|
||||
Next: dry-run / monitoring only(见 operational checklist)
|
||||
```
|
||||
|
||||
## Operational
|
||||
|
||||
上线前不改规则,只验执行链:
|
||||
|
||||
→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md)
|
||||
|
||||
覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。
|
||||
|
||||
## Locked stack
|
||||
|
||||
| Layer | File | Status |
|
||||
|-------|------|--------|
|
||||
| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN |
|
||||
| State | `engine/market_state.py` | causal v1.1 |
|
||||
| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** |
|
||||
| Boundary | `VALIDITY_BOUNDARY.md` | active |
|
||||
|
||||
## Robustness slices (blocked Spring, by year/era)
|
||||
|
||||
证据:`scripts/wyckoff_gate_robustness_slices_result.json`
|
||||
|
||||
| Slice | blocked n | dist share | top blocked | blocked PF |
|
||||
|-------|-----------|------------|-------------|------------|
|
||||
| 2020 | 3 | **1.00** | distribution | 0.73 |
|
||||
| 2021 | 4 | **0.75** | distribution | 0.31 |
|
||||
| 2022 | 1 | 1.00 | distribution | 0 |
|
||||
| 2023 | 1 | 1.00 | distribution | 0 |
|
||||
| 2024 | 3 | 0.33 | distribution+range | 0 |
|
||||
| 2025 | 1 | 0 | range (obs) | n=1 win |
|
||||
| pre_2023 | 8 | **0.875** | distribution | 0.37 |
|
||||
| 2023plus | 5 | 0.40 | distribution+range | 1.22 |
|
||||
|
||||
Verdict: **distribution 归因在多数有样本切片上稳定**(PASS)。
|
||||
2023+ / 2024–25 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
|
||||
|
||||
## Do not
|
||||
|
||||
- 调 Spring / soft-score / Gate 阈值
|
||||
- 因 range 小样本正 PF 开放 range 交易
|
||||
- 复活 LPS / 默认跨资产
|
||||
@@ -0,0 +1,85 @@
|
||||
# Validity Boundary — Market-State Gated Spring
|
||||
|
||||
## Definition (hard)
|
||||
|
||||
```text
|
||||
market_state in {accumulation, markup} -> allow Spring
|
||||
else -> block Spring
|
||||
```
|
||||
|
||||
Spring 信号本体 = `V1_BASELINE`(FROZEN)。
|
||||
Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。
|
||||
|
||||
Soft-score / hard-score 阈值 **不进入默认规则**。
|
||||
|
||||
## Validity statement
|
||||
|
||||
Spring has positive expectancy under:
|
||||
|
||||
1. BTC market
|
||||
2. Causal `market_state ∈ {accumulation, markup}`
|
||||
3. 8h / 4h / 1h alignment
|
||||
4. Trend-compatible (range already blocked in Baseline)
|
||||
|
||||
Invalid under:
|
||||
|
||||
1. `distribution`
|
||||
2. `range`
|
||||
3. `markdown`(对 SPRING_LONG)
|
||||
4. Ungated global trading
|
||||
|
||||
## Causal state (entry-time only)
|
||||
|
||||
```
|
||||
bear & ema_slope >= -1% → accumulation
|
||||
bull & ema_slope > +0.5% → markup
|
||||
bull & ema_slope <= +0.5% → distribution
|
||||
bear & ema_slope < -1% → markdown
|
||||
else → range
|
||||
```
|
||||
|
||||
## Gate performance (net fee+slip)
|
||||
|
||||
| Window | Baseline | Gated state_set |
|
||||
|--------|----------|-----------------|
|
||||
| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** |
|
||||
| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** |
|
||||
|
||||
Confidence: **MEDIUM / defined-domain PASS**(full PF 仍 < 1)。
|
||||
|
||||
## Negative-domain audit
|
||||
|
||||
`scripts/wyckoff_negative_domain_audit_result.json`
|
||||
|
||||
对 Baseline 全部 `SPRING_LONG`(n=28)按因果状态拆 kept/blocked:
|
||||
|
||||
| | n | PF | 含义 |
|
||||
|--|---|-----|------|
|
||||
| Kept | 15 | 1.09 | 全部在 markup |
|
||||
| Blocked | 13 | 0.58 | **100% bad domain** |
|
||||
| Blocked × distribution | 9 | **0.38** | 主杀伤区 |
|
||||
| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 |
|
||||
|
||||
→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。
|
||||
|
||||
## Default 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**
|
||||
@@ -0,0 +1,19 @@
|
||||
# Spring Baseline V1 — FROZEN SNAPSHOT
|
||||
|
||||
勿改本目录文件。可运行副本在:
|
||||
|
||||
- `strategies/Wyckoff_BTC_V1_BASELINE.py`
|
||||
- `config/Wyckoff_BTC_V1_BASELINE.json`
|
||||
|
||||
## Evidence (cost-adjusted)
|
||||
|
||||
| Window | Profit | n | DD | Net PF |
|
||||
|--------|--------|---|-----|--------|
|
||||
| Train | +1.66% | 12 | 3.6% | 1.17 |
|
||||
| Validate | +9.99% | 6 | 1.8% | 6.20 |
|
||||
| Test | +0.85% | 2 | 0.7% | 2.18 |
|
||||
| Full | +12.74% | 20 | 3.6% | 2.02 |
|
||||
| fee+slip 5bps | +6.78% | 20 | — | **1.45** |
|
||||
|
||||
Status: **PASS + Limited Evidence** (N=20)
|
||||
Next: Phase3 → N≥50(延历史 / 多品种),不改规则。
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
|
||||
"dry_run_wallet": 10000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short": true,
|
||||
"timeframe": "1h",
|
||||
"process_only_new_candles": true,
|
||||
"unfilledtimeout": {
|
||||
"entry": 60,
|
||||
"exit": 60,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1,
|
||||
"price_last_balance": 0.0,
|
||||
"check_depth_of_market": {
|
||||
"enabled": false,
|
||||
"bids_to_ask_delta": 1
|
||||
}
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true,
|
||||
"order_book_top": 1
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList"
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "",
|
||||
"chat_id": ""
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"listen_port": 8823,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
|
||||
"ws_token": "wyckoff-v1-baseline-ws-change-me",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "wyckoff_btc_v1_baseline",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range 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)
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"branches": {
|
||||
"Spring_V1": {
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": 1.6587295176,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 1.1700179329477578,
|
||||
"winrate": 25.0,
|
||||
"final": 10165.87295176,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": 9.990534148400002,
|
||||
"trades": 6,
|
||||
"dd_pct": 1.797834787912851,
|
||||
"pf": 6.201791679101682,
|
||||
"winrate": 66.66666666666666,
|
||||
"final": 10999.05341484,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.8458820224000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 0.7197049309999966,
|
||||
"pf": 2.175317808681236,
|
||||
"winrate": 50.0,
|
||||
"final": 10084.58820224,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": 8.166882314399999,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.4837023928902555,
|
||||
"pf": 1.9398544482027922,
|
||||
"winrate": 33.33333333333333,
|
||||
"final": 10816.688231439999,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 4.2503064875000005,
|
||||
"trades": 8,
|
||||
"dd_pct": 3.173714645599994,
|
||||
"pf": 2.084295240772406,
|
||||
"winrate": 50.0,
|
||||
"final": 10425.03064875,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": -4.3352539371,
|
||||
"trades": 6,
|
||||
"dd_pct": 4.404162180500007,
|
||||
"pf": 0.15746188404490422,
|
||||
"winrate": 16.666666666666664,
|
||||
"final": 9566.47460629,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": 7.831216539699999,
|
||||
"trades": 26,
|
||||
"dd_pct": 7.883451762900004,
|
||||
"pf": 1.454582067425369,
|
||||
"winrate": 34.61538461538461,
|
||||
"final": 10783.12165397,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": 6.782772099999998,
|
||||
"trades": 20,
|
||||
"dd_pct": 7.851805397900007,
|
||||
"pf": 1.4511324473780693,
|
||||
"winrate": 35.0,
|
||||
"final": 10678.27721,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": 3.182909279400001,
|
||||
"trades": 20,
|
||||
"dd_pct": 9.126146157700004,
|
||||
"pf": 1.1834520309921508,
|
||||
"winrate": 35.0,
|
||||
"final": 10318.29092794,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.3,
|
||||
"dd": 10.0,
|
||||
"note": "Spring: PF>1.3 DD<10%"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 2.0183507402435503,
|
||||
"full_dd": 3.644907735100005,
|
||||
"trades_per_year": 5.555555555555555,
|
||||
"net_mid_pf": 1.4511324473780693,
|
||||
"target_pf_ok": true,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
},
|
||||
"LPS_V1": {
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -1.2518571096,
|
||||
"trades": 1,
|
||||
"dd_pct": 1.251857109600005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9874.81428904,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -0.24613064569999998,
|
||||
"trades": 1,
|
||||
"dd_pct": 0.24613064569999552,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9975.38693543,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -1.4952529704,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4952529703999973,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9850.47470296,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -1.6902037039,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.6902037039000062,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9830.97962961,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -2.0801051709,
|
||||
"trades": 2,
|
||||
"dd_pct": 2.080105170900006,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9791.98948291,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS: PF>1.2, 次数增加"
|
||||
},
|
||||
"version": "LPS_V1.1",
|
||||
"verdict": {
|
||||
"full_pf": 0.0,
|
||||
"full_dd": 1.4952529703999973,
|
||||
"trades_per_year": 0.5555555555555556,
|
||||
"net_mid_pf": 0.0,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
},
|
||||
"LPS_V2": {
|
||||
"version": "LPS_V2",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -3.5049591933000004,
|
||||
"trades": 3,
|
||||
"dd_pct": 3.504959193300001,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9649.50408067,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -1.6143743830000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.614374382999995,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9838.5625617,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -5.0599199851,
|
||||
"trades": 5,
|
||||
"dd_pct": 5.059919985100005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9494.00800149,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -4.5549168852,
|
||||
"trades": 7,
|
||||
"dd_pct": 7.020877735199993,
|
||||
"pf": 0.3512325585213674,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9544.50831148,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -5.371762636800001,
|
||||
"trades": 7,
|
||||
"dd_pct": 8.1297357765,
|
||||
"pf": 0.33924511392759654,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9462.82373632,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 0.39720654015221873,
|
||||
"full_dd": 6.479119889400008,
|
||||
"trades_per_year": 1.9444444444444444,
|
||||
"net_mid_pf": 0.3512325585213674,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true,
|
||||
"freq_ok": false,
|
||||
"regime_logic_ok": true,
|
||||
"status": "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"portfolio_note": {
|
||||
"spring_tpy": 5.555555555555555,
|
||||
"lps_tpy": 0.5555555555555556,
|
||||
"sum_tpy_approx": 6.111111111111111,
|
||||
"combined_target_tpy": "15-25",
|
||||
"lps_status": "FAIL",
|
||||
"spring_status": "PASS"
|
||||
},
|
||||
"system_status": {
|
||||
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||
"lps": "FAIL",
|
||||
"spring_tpy": 5.555555555555555,
|
||||
"lps_tpy": 1.9444444444444444,
|
||||
"next": "若 LPS PASS → 组合层;否则 Spring-only"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": 1.6587295176,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 1.1700179329477578,
|
||||
"winrate": 25.0,
|
||||
"final": 10165.87295176,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": 9.990534148400002,
|
||||
"trades": 6,
|
||||
"dd_pct": 1.797834787912851,
|
||||
"pf": 6.201791679101682,
|
||||
"winrate": 66.66666666666666,
|
||||
"final": 10999.05341484,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 0.8458820224000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 0.7197049309999966,
|
||||
"pf": 2.175317808681236,
|
||||
"winrate": 50.0,
|
||||
"final": 10084.58820224,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": 8.166882314399999,
|
||||
"trades": 12,
|
||||
"dd_pct": 3.4837023928902555,
|
||||
"pf": 1.9398544482027922,
|
||||
"winrate": 33.33333333333333,
|
||||
"final": 10816.688231439999,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 4.2503064875000005,
|
||||
"trades": 8,
|
||||
"dd_pct": 3.173714645599994,
|
||||
"pf": 2.084295240772406,
|
||||
"winrate": 50.0,
|
||||
"final": 10425.03064875,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": -4.3352539371,
|
||||
"trades": 6,
|
||||
"dd_pct": 4.404162180500007,
|
||||
"pf": 0.15746188404490422,
|
||||
"winrate": 16.666666666666664,
|
||||
"final": 9566.47460629,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": 7.831216539699999,
|
||||
"trades": 26,
|
||||
"dd_pct": 7.883451762900004,
|
||||
"pf": 1.454582067425369,
|
||||
"winrate": 34.61538461538461,
|
||||
"final": 10783.12165397,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": 12.7374753063,
|
||||
"trades": 20,
|
||||
"dd_pct": 3.644907735100005,
|
||||
"pf": 2.0183507402435503,
|
||||
"winrate": 40.0,
|
||||
"final": 11273.74753063,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": 6.782772099999998,
|
||||
"trades": 20,
|
||||
"dd_pct": 7.851805397900007,
|
||||
"pf": 1.4511324473780693,
|
||||
"winrate": 35.0,
|
||||
"final": 10678.27721,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": 3.182909279400001,
|
||||
"trades": 20,
|
||||
"dd_pct": 9.126146157700004,
|
||||
"pf": 1.1834520309921508,
|
||||
"winrate": 35.0,
|
||||
"final": 10318.29092794,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 2.0183507402435503,
|
||||
"full_dd": 3.644907735100005,
|
||||
"trades_per_year": 5.555555555555555,
|
||||
"net_mid_pf": 1.4511324473780693,
|
||||
"target_pf_ok": true,
|
||||
"target_dd_ok": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# LPS V1.1 — REJECTED
|
||||
|
||||
## Hypothesis
|
||||
|
||||
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
|
||||
|
||||
## Result
|
||||
|
||||
- Full: **-1.50%**, n=**2**, 全亏
|
||||
- 过滤方向正确,但过度收缩 → 无统计意义
|
||||
|
||||
## Reject reason
|
||||
|
||||
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
|
||||
@@ -0,0 +1,15 @@
|
||||
# LPS V1 — REJECTED
|
||||
|
||||
## Hypothesis
|
||||
|
||||
1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随)
|
||||
|
||||
## Result
|
||||
|
||||
- Full: **-18.92%**, n=133, PF **0.73**
|
||||
- Regime anomaly: **trend 亏、range 赚**(反理论)
|
||||
|
||||
## Reject reason
|
||||
|
||||
捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。
|
||||
定义错误,不是参数问题。
|
||||
@@ -0,0 +1,43 @@
|
||||
# LPS V2 — REJECTED(归档,不再救援)
|
||||
|
||||
## Hypothesis
|
||||
|
||||
**4h 原生 SOS Confirm → 1h LPS Entry**
|
||||
大级别事件、小级别执行(非 1h 假突破)
|
||||
|
||||
## Implementation
|
||||
|
||||
见 `Wyckoff_BTC_LPS_V2.py`
|
||||
|
||||
4h SOS: 实体收盘离开区间 + vol>MA*1.5 + close strength>0.7 + 3 根 hold
|
||||
1h LPS: 首次回踩 + 0.5~1.5 ATR + vol<breakout_vol + close>prev high
|
||||
|
||||
## Result
|
||||
|
||||
| Window | Profit | n | PF |
|
||||
|--------|--------|---|-----|
|
||||
| Train | -3.50% | 3 | 0 |
|
||||
| Validate | -1.61% | 2 | 0 |
|
||||
| Test | +1.22% | 2 | 1.82 |
|
||||
| Full | **-3.91%** | 7 | **0.40** |
|
||||
| fee+slip | -4.55% | 7 | **0.35** |
|
||||
|
||||
证据文件: `wyckoff_lps_v2_phase2_result.json`
|
||||
|
||||
## Funnel
|
||||
|
||||
```
|
||||
4h sos_raw 183 → confirmed 123 → 1h LPS 7
|
||||
```
|
||||
|
||||
SOS 识别有产出;**SOS→LPS 映射无稳定边际**。
|
||||
|
||||
## Reject reason
|
||||
|
||||
在 BTC 永续当前结构下,传统股票式 SOS→LPS→Markup 假设不成立:
|
||||
突破后常不给标准 LPS,或首次回踩已破坏结构。
|
||||
样本少/成本/Regime 均非主因。**停止优化本假设。**
|
||||
|
||||
## Reopen only if
|
||||
|
||||
成交量分布 / 订单流 / 资金费率等新信息源进入假设。
|
||||
@@ -0,0 +1,492 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC — Branch B: LPS Trend Continuation(独立 Setup 研究)
|
||||
|
||||
Status: RESEARCH
|
||||
Spring V1: BASELINE FROZEN(禁止改动 / 禁止与本分支合并调参)
|
||||
|
||||
LPS V2 假设(验证中):
|
||||
4h 原生 SOS Confirm → 1h LPS Entry
|
||||
不是 1h 假突破回踩
|
||||
|
||||
4h SOS:
|
||||
① close > range_high(实体收盘离开区间,非 wick)
|
||||
② volume > MA20 * 1.5
|
||||
③ close strength (close-low)/(high-low) > 0.7
|
||||
④ 随后 3 根 4h close 仍 > breakout_level
|
||||
|
||||
1h LPS:
|
||||
第一次回踩 breakout_level
|
||||
回踩深度 0.5~1.5 ATR(1h)
|
||||
volume_4h < sos_break_volume
|
||||
转强: close > previous high
|
||||
|
||||
setup_type / enter_tag: LPS / LPSY
|
||||
regime_mode=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)
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"version": "LPS_V2",
|
||||
"wfo": {
|
||||
"train": {
|
||||
"timerange": "20230101-20250101",
|
||||
"profit_pct": -3.5049591933000004,
|
||||
"trades": 3,
|
||||
"dd_pct": 3.504959193300001,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9649.50408067,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"validate": {
|
||||
"timerange": "20250101-20260101",
|
||||
"profit_pct": -1.6143743830000001,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.614374382999995,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9838.5625617,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"test": {
|
||||
"timerange": "20260101-",
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"full": {
|
||||
"timerange": "20230101-",
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"regimes": {
|
||||
"trend": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"bull": {
|
||||
"profit_pct": -5.0599199851,
|
||||
"trades": 5,
|
||||
"dd_pct": 5.059919985100005,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 9494.00800149,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bull"
|
||||
},
|
||||
"bear": {
|
||||
"profit_pct": 1.2174468187999996,
|
||||
"trades": 2,
|
||||
"dd_pct": 1.4924489317000007,
|
||||
"pf": 1.81573767312309,
|
||||
"winrate": 50.0,
|
||||
"final": 10121.74468188,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "bear"
|
||||
},
|
||||
"range": {
|
||||
"profit_pct": 0.0,
|
||||
"trades": 0,
|
||||
"dd_pct": 0.0,
|
||||
"pf": 0.0,
|
||||
"winrate": 0.0,
|
||||
"final": 10000.0,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "range"
|
||||
},
|
||||
"all": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "all"
|
||||
}
|
||||
},
|
||||
"cost_stress": {
|
||||
"fee_5bps": {
|
||||
"profit_pct": -3.9055710949000004,
|
||||
"trades": 7,
|
||||
"dd_pct": 6.479119889400008,
|
||||
"pf": 0.39720654015221873,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9609.44289051,
|
||||
"fee_used": 0.0005,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_5bps+slip_5bps": {
|
||||
"profit_pct": -4.5549168852,
|
||||
"trades": 7,
|
||||
"dd_pct": 7.020877735199993,
|
||||
"pf": 0.3512325585213674,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9544.50831148,
|
||||
"fee_used": 0.001,
|
||||
"regime_loaded": "trend"
|
||||
},
|
||||
"fee_10bps+slip_10bps": {
|
||||
"profit_pct": -5.371762636800001,
|
||||
"trades": 7,
|
||||
"dd_pct": 8.1297357765,
|
||||
"pf": 0.33924511392759654,
|
||||
"winrate": 14.285714285714285,
|
||||
"final": 9462.82373632,
|
||||
"fee_used": 0.002,
|
||||
"regime_loaded": "trend"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pf": 1.2,
|
||||
"dd": 15.0,
|
||||
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
|
||||
},
|
||||
"verdict": {
|
||||
"full_pf": 0.39720654015221873,
|
||||
"full_dd": 6.479119889400008,
|
||||
"trades_per_year": 1.9444444444444444,
|
||||
"net_mid_pf": 0.3512325585213674,
|
||||
"target_pf_ok": false,
|
||||
"target_dd_ok": true,
|
||||
"freq_ok": false,
|
||||
"regime_logic_ok": true,
|
||||
"status": "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gate v1.1 冻结前小范围稳健性确认(不改 Spring / 不调 soft-score)
|
||||
|
||||
在 Baseline SPRING_LONG 全集上:
|
||||
- 按年份、era 切片
|
||||
- 看 blocked 是否仍主要来自 distribution
|
||||
- kept vs blocked 的 PF 关系是否稳定
|
||||
|
||||
range 只作观察桶,不改交易规则。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
AUDIT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_gate_robustness_slices_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "sum_pct": 0.0, "winrate": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
}
|
||||
|
||||
|
||||
def load_annotated_springs() -> list[dict[str, Any]]:
|
||||
"""复用 audit 逻辑,产出逐笔 annotated SPRING。"""
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
if "SPRING" not in (t.enter_tag or ""):
|
||||
continue
|
||||
ed = pd.Timestamp(t.open_date_utc)
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
st = h8.iloc[idx]
|
||||
state = str(st["market_state"])
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": ed.isoformat(),
|
||||
"year": str(ed.year),
|
||||
"era": "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023",
|
||||
"market_state": state,
|
||||
"allow_spring": bool(st["allow_spring"]),
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def slice_report(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r[key])].append(r)
|
||||
for k, rs in sorted(groups.items()):
|
||||
kept = [x for x in rs if x["allow_spring"]]
|
||||
blocked = [x for x in rs if not x["allow_spring"]]
|
||||
b_by_state: dict[str, list[float]] = defaultdict(list)
|
||||
for x in blocked:
|
||||
b_by_state[x["market_state"]].append(x["profit_ratio"])
|
||||
blocked_states = {s: _stats(ps) for s, ps in b_by_state.items()}
|
||||
dist_n = blocked_states.get("distribution", {}).get("n", 0)
|
||||
blocked_n = len(blocked)
|
||||
out[k] = {
|
||||
"n_total": len(rs),
|
||||
"kept": _stats([x["profit_ratio"] for x in kept]),
|
||||
"blocked": _stats([x["profit_ratio"] for x in blocked]),
|
||||
"blocked_by_state": blocked_states,
|
||||
"blocked_distribution_share": round(dist_n / blocked_n, 3) if blocked_n else None,
|
||||
"blocked_all_bad": (
|
||||
all(s in ("distribution", "markdown", "range") for s in blocked_states)
|
||||
if blocked_n
|
||||
else True
|
||||
),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Annotate SPRING_LONG =====", flush=True)
|
||||
rows = load_annotated_springs()
|
||||
print(f" n={len(rows)}", flush=True)
|
||||
|
||||
by_year = slice_report(rows, "year")
|
||||
by_era = slice_report(rows, "era")
|
||||
|
||||
# 稳定性:有 blocked 的切片里,distribution 是否为第一大来源
|
||||
dist_primary = []
|
||||
for label, block in {**{f"year:{k}": v for k, v in by_year.items()}, **{f"era:{k}": v for k, v in by_era.items()}}.items():
|
||||
bn = block["blocked"]["n"]
|
||||
if bn < 2:
|
||||
continue
|
||||
states = block["blocked_by_state"]
|
||||
top = max(states.items(), key=lambda x: x[1]["n"])[0] if states else None
|
||||
dist_primary.append(
|
||||
{
|
||||
"slice": label,
|
||||
"blocked_n": bn,
|
||||
"top_blocked_state": top,
|
||||
"distribution_share": block["blocked_distribution_share"],
|
||||
"blocked_pf": block["blocked"]["pf"],
|
||||
"kept_pf": block["kept"]["pf"],
|
||||
}
|
||||
)
|
||||
|
||||
n_slices = len(dist_primary)
|
||||
n_dist_top = sum(1 for x in dist_primary if x["top_blocked_state"] == "distribution")
|
||||
n_dist_ge_50 = sum(
|
||||
1 for x in dist_primary if (x["distribution_share"] or 0) >= 0.5
|
||||
)
|
||||
|
||||
result = {
|
||||
"n_spring": len(rows),
|
||||
"by_year": by_year,
|
||||
"by_era": by_era,
|
||||
"slice_summaries": dist_primary,
|
||||
"range_observation_only": {
|
||||
"note": "range 不作交易规则;仅观察 blocked 中的占比与 PF",
|
||||
"blocked_range_global": _stats(
|
||||
[r["profit_ratio"] for r in rows if (not r["allow_spring"] and r["market_state"] == "range")]
|
||||
),
|
||||
},
|
||||
"verdict": {
|
||||
"slices_with_blocked_ge_2": n_slices,
|
||||
"distribution_is_top_blocked_state": n_dist_top,
|
||||
"distribution_share_ge_50pct_slices": n_dist_ge_50,
|
||||
"distribution_attribution_stable": (
|
||||
n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
),
|
||||
"status": (
|
||||
"PASS"
|
||||
if n_slices > 0 and (n_dist_top / n_slices) >= 0.6
|
||||
else "PARTIAL"
|
||||
if n_dist_ge_50 >= max(1, n_slices // 2)
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = across year/era slices, blocked mass still led by distribution.",
|
||||
},
|
||||
}
|
||||
|
||||
print("\n===== By year (blocked focus) =====", flush=True)
|
||||
for y, b in by_year.items():
|
||||
print(
|
||||
f" {y}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== By era =====", flush=True)
|
||||
for e, b in by_era.items():
|
||||
print(
|
||||
f" {e}: total={b['n_total']} kept_pf={b['kept']['pf']} "
|
||||
f"blocked_n={b['blocked']['n']} blocked_pf={b['blocked']['pf']} "
|
||||
f"dist_share={b['blocked_distribution_share']} states={list(b['blocked_by_state'])}",
|
||||
flush=True,
|
||||
)
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Negative-domain audit
|
||||
|
||||
问题:Gate 拦掉的 Spring,是否集中死在 distribution | markdown | range(结构性错误),
|
||||
而不是偶然删掉赚钱样本?
|
||||
|
||||
方法(Spring 冻结,Gate=state_set):
|
||||
1) 跑 Baseline,取出全部 SPRING_LONG 成交
|
||||
2) 用因果 8h market_state 标注入场时状态
|
||||
3) 按 allow_spring 分成 kept vs blocked
|
||||
4) 比较各域 n / PF / winrate / sum%
|
||||
|
||||
判定:
|
||||
- blocked 主要落在 bad domains
|
||||
- blocked 整体 PF << kept(或明显更差)
|
||||
- kept 域仍以 accumulation|markup 为主
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "user_data/Chan"))
|
||||
|
||||
from engine.market_state import compute_market_state_8h # noqa: E402
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_negative_domain_audit_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
GOOD = {"accumulation", "markup"}
|
||||
BAD = {"distribution", "markdown", "range"}
|
||||
|
||||
|
||||
def _pf(ps: list[float]) -> float:
|
||||
wins = [p for p in ps if p > 0]
|
||||
losses = [-p for p in ps if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _stats(ps: list[float]) -> dict[str, Any]:
|
||||
if not ps:
|
||||
return {"n": 0, "pf": 0.0, "winrate": 0.0, "sum_pct": 0.0, "avg_pct": 0.0}
|
||||
return {
|
||||
"n": len(ps),
|
||||
"pf": round(_pf(ps), 3),
|
||||
"winrate": round(100.0 * sum(1 for p in ps if p > 0) / len(ps), 1),
|
||||
"sum_pct": round(100.0 * float(np.sum(ps)), 2),
|
||||
"avg_pct": round(100.0 * float(np.mean(ps)), 2),
|
||||
}
|
||||
|
||||
|
||||
def run_baseline_spring_trades() -> list[dict[str, Any]]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(CFG)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": "Wyckoff_BTC_V1_BASELINE",
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": "20190901-",
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
tag = t.enter_tag or ""
|
||||
if "SPRING" not in tag:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"entry_date": t.open_date_utc.isoformat() if t.open_date_utc else "",
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else "",
|
||||
"enter_tag": tag,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"era": (
|
||||
"2023plus"
|
||||
if t.open_date_utc and t.open_date_utc >= pd.Timestamp("2023-01-01", tz="UTC")
|
||||
else "pre_2023"
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def annotate(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
row = h8.iloc[idx]
|
||||
state = str(row["market_state"])
|
||||
allowed = bool(row["allow_spring"])
|
||||
rec = {
|
||||
**t,
|
||||
"market_state": state,
|
||||
"allow_spring": allowed,
|
||||
"domain": "good" if state in GOOD else ("bad" if state in BAD else "other"),
|
||||
"accumulation_score": float(row["accumulation_score"]),
|
||||
"markup_score": float(row["markup_score"]),
|
||||
"distribution_score": float(row["distribution_score"]),
|
||||
"markdown_score": float(row["markdown_score"]),
|
||||
"range_score": float(row["range_score"]),
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def bucket(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
g: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
g[str(r[key])].append(float(r["profit_ratio"]))
|
||||
return {k: _stats(v) for k, v in sorted(g.items(), key=lambda x: -len(x[1]))}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== Baseline SPRING_LONG trades =====", flush=True)
|
||||
raw = run_baseline_spring_trades()
|
||||
print(f" spring trades={len(raw)}", flush=True)
|
||||
rows = annotate(raw)
|
||||
kept = [r for r in rows if r["allow_spring"]]
|
||||
blocked = [r for r in rows if not r["allow_spring"]]
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"fee_model": "fee5bps+slip5bps",
|
||||
"n_spring_total": len(rows),
|
||||
"n_kept": len(kept),
|
||||
"n_blocked": len(blocked),
|
||||
"kept": {
|
||||
"overall": _stats([r["profit_ratio"] for r in kept]),
|
||||
"by_state": bucket(kept, "market_state"),
|
||||
"by_era": bucket(kept, "era"),
|
||||
},
|
||||
"blocked": {
|
||||
"overall": _stats([r["profit_ratio"] for r in blocked]),
|
||||
"by_state": bucket(blocked, "market_state"),
|
||||
"by_era": bucket(blocked, "era"),
|
||||
"by_domain": bucket(blocked, "domain"),
|
||||
},
|
||||
"blocked_share_by_state": {},
|
||||
"verdict": {},
|
||||
}
|
||||
|
||||
# blocked 状态占比
|
||||
if blocked:
|
||||
for st, stt in result["blocked"]["by_state"].items():
|
||||
result["blocked_share_by_state"][st] = round(stt["n"] / len(blocked), 3)
|
||||
|
||||
bad_n = sum(result["blocked"]["by_state"].get(s, {}).get("n", 0) for s in BAD)
|
||||
blocked_bad_share = (bad_n / len(blocked)) if blocked else 0.0
|
||||
kept_good_share = 0.0
|
||||
if kept:
|
||||
kg = sum(1 for r in kept if r["market_state"] in GOOD)
|
||||
kept_good_share = kg / len(kept)
|
||||
|
||||
bk = result["blocked"]["overall"]
|
||||
kp = result["kept"]["overall"]
|
||||
result["verdict"] = {
|
||||
"blocked_mostly_bad_domain": blocked_bad_share >= 0.8,
|
||||
"blocked_bad_share": round(blocked_bad_share, 3),
|
||||
"kept_mostly_good_domain": kept_good_share >= 0.95,
|
||||
"kept_good_share": round(kept_good_share, 3),
|
||||
"blocked_pf_worse_than_kept": bk["pf"] < kp["pf"],
|
||||
"blocked_pf": bk["pf"],
|
||||
"kept_pf": kp["pf"],
|
||||
"status": (
|
||||
"PASS"
|
||||
if (
|
||||
blocked_bad_share >= 0.8
|
||||
and kept_good_share >= 0.95
|
||||
and bk["pf"] < kp["pf"]
|
||||
)
|
||||
else "PARTIAL"
|
||||
if (blocked_bad_share >= 0.7 and bk["pf"] <= kp["pf"])
|
||||
else "FAIL"
|
||||
),
|
||||
"note": "PASS = Gate filters structural bad domains, not random sample deletion.",
|
||||
}
|
||||
|
||||
print("\n===== KEPT (allow_spring) =====", flush=True)
|
||||
print(json.dumps(result["kept"], indent=2, ensure_ascii=False))
|
||||
print("\n===== BLOCKED =====", flush=True)
|
||||
print(json.dumps(result["blocked"], indent=2, ensure_ascii=False))
|
||||
print("\n===== Verdict =====", flush=True)
|
||||
print(json.dumps(result["verdict"], indent=2, ensure_ascii=False))
|
||||
|
||||
OUT.write_text(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""在最优周期 1h/4h/8h 上扫 ATR 与关键参数。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import ( # noqa: E402
|
||||
STRAT_PATH,
|
||||
install_offline_markets,
|
||||
patch_strategy,
|
||||
run_one,
|
||||
)
|
||||
|
||||
# 参数名 -> (正则匹配赋值行前缀, 候选值列表)
|
||||
PARAM_GRID = {
|
||||
"atr_sl_mult": (
|
||||
r'^(\tatr_sl_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.5, 2.0, 2.5, 3.0],
|
||||
),
|
||||
"vol_spike_mult": (
|
||||
r'^(\tvol_spike_mult = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[1.2, 1.4, 1.8],
|
||||
),
|
||||
"spring_pierce_pct": (
|
||||
r'^(\tspring_pierce_pct = DecimalParameter\([^\n]*default=)([0-9.]+)',
|
||||
[0.002, 0.004, 0.008],
|
||||
),
|
||||
"range_lookback": (
|
||||
r'^(\trange_lookback = IntParameter\([^\n]*default=)([0-9]+)',
|
||||
[18, 24, 36],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def set_defaults(text: str, values: dict[str, Any]) -> str:
|
||||
for key, (pat, _) in PARAM_GRID.items():
|
||||
val = values[key]
|
||||
text = re.sub(pat, rf"\g<1>{val}", text, count=1, flags=re.M)
|
||||
return text
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
|
||||
keys = list(PARAM_GRID.keys())
|
||||
combos = list(itertools.product(*[PARAM_GRID[k][1] for k in keys]))
|
||||
# 全组合太多:改为坐标下降式 — 先基线,再逐参扫描
|
||||
base = {k: PARAM_GRID[k][1][len(PARAM_GRID[k][1]) // 2] for k in keys}
|
||||
# 确保与当前文件接近的中心点
|
||||
base.update(
|
||||
{
|
||||
"atr_sl_mult": 2.0,
|
||||
"vol_spike_mult": 1.4,
|
||||
"spring_pierce_pct": 0.004,
|
||||
"range_lookback": 24,
|
||||
}
|
||||
)
|
||||
|
||||
trials = [dict(base)]
|
||||
for k in keys:
|
||||
for v in PARAM_GRID[k][1]:
|
||||
if v == base[k]:
|
||||
continue
|
||||
t = dict(base)
|
||||
t[k] = v
|
||||
trials.append(t)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
patch_strategy("1h", "4h", "8h")
|
||||
for i, vals in enumerate(trials):
|
||||
text = set_defaults(STRAT_PATH.read_text(), vals)
|
||||
STRAT_PATH.write_text(text)
|
||||
label = ",".join(f"{k}={vals[k]}" for k in keys)
|
||||
print(f"[{i+1}/{len(trials)}] {label}", flush=True)
|
||||
try:
|
||||
res = run_one("1h", timerange)
|
||||
res.update(vals)
|
||||
res["label"] = label
|
||||
res["ok"] = True
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": str(e), "label": label, **vals}
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" -> profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== PARAM RANKING ==========")
|
||||
for r in ok[:10]:
|
||||
print(
|
||||
f"{r['profit_pct']:>7.2f}% pf={r['pf']:.2f} dd={r['dd_pct']:.1f}% "
|
||||
f"n={r['trades']:<3} {r['label']}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_param_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print("\nBEST params:", {k: best[k] for k in keys})
|
||||
# 写回最优 default
|
||||
text = set_defaults(orig, {k: best[k] for k in keys})
|
||||
# 保持最优周期
|
||||
text2 = text
|
||||
text2 = re.sub(r'^(\ttimeframe = ).*$', r'\g<1>"1h"', text2, count=1, flags=re.M)
|
||||
text2 = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', r'\g<1>"4h"', text2, count=1, flags=re.M
|
||||
)
|
||||
text2 = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
r'\g<1>"8h"',
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
STRAT_PATH.write_text(text2)
|
||||
print("Wrote best defaults into Wyckoff_BTC.py")
|
||||
# 长周期验证
|
||||
print("\nValidate 20230101- ...", flush=True)
|
||||
res = run_one("1h", "20230101-")
|
||||
print(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wyckoff Phase2 对比:同一数据 / 同一成本 / 同一 WFO / 同一 Regime
|
||||
|
||||
对比:
|
||||
- Wyckoff_BTC_V1_BASELINE (Spring, range off)
|
||||
- Wyckoff_BTC_LPS (LPS continuation, range off)
|
||||
|
||||
统一看 net 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()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LPS V2 单独 Phase2(不改 Spring、不合并组合)
|
||||
|
||||
同一 WFO / Regime / 成本模型。
|
||||
目标: net PF > 1.2;频率约 5-15/year。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_phase2_compare import ( # noqa: E402
|
||||
BRANCHES,
|
||||
WFO,
|
||||
install_offline_markets,
|
||||
run_bt,
|
||||
)
|
||||
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_lps_v2_phase2_result.json"
|
||||
COMPARE = ROOT / "user_data/Chan/scripts/wyckoff_phase2_compare_result.json"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets()
|
||||
br = next(b for b in BRANCHES if b["name"] == "LPS_V2")
|
||||
print(f"===== {br['name']} ({br['strategy']}) — LPS-only Phase2 =====", flush=True)
|
||||
block = {"version": "LPS_V2", "wfo": {}, "regimes": {}, "cost_stress": {}, "target": br["target"]}
|
||||
|
||||
print("--- WFO ---", flush=True)
|
||||
for wname, tr in WFO:
|
||||
r = run_bt(br["strategy"], br["config"], tr)
|
||||
block["wfo"][wname] = {"timerange": tr, **r}
|
||||
print(
|
||||
f" {wname:<8} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"dd={r['dd_pct']:.1f}% pf={r['pf']:.2f} wr={r['winrate']:.1f}%",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Regime ---", flush=True)
|
||||
for mode in ["trend", "bull", "bear", "range", "all"]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", regime=mode)
|
||||
block["regimes"][mode] = r
|
||||
print(
|
||||
f" {mode:<6} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} (loaded={r['regime_loaded']})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print("--- Cost (net PF) ---", flush=True)
|
||||
for label, fee, extra in [
|
||||
("fee_5bps", 0.0005, 0.0),
|
||||
("fee_5bps+slip_5bps", 0.0005, 0.0005),
|
||||
("fee_10bps+slip_10bps", 0.0010, 0.0010),
|
||||
]:
|
||||
r = run_bt(br["strategy"], br["config"], "20230101-", fee=fee, extra_cost=extra)
|
||||
block["cost_stress"][label] = r
|
||||
flag = "OK" if r["pf"] >= br["target"]["pf"] else ("WEAK" if r["pf"] >= 1.0 else "FAIL")
|
||||
print(
|
||||
f" {label:<22} profit={r['profit_pct']:>7.2f}% n={r['trades']:<3} "
|
||||
f"pf={r['pf']:.2f} [{flag}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
full = block["wfo"]["full"]
|
||||
mid = block["cost_stress"]["fee_5bps+slip_5bps"]
|
||||
tpy = full["trades"] / 3.6
|
||||
trend_pf = block["regimes"]["trend"]["pf"]
|
||||
range_pf = block["regimes"]["range"]["pf"]
|
||||
block["verdict"] = {
|
||||
"full_pf": full["pf"],
|
||||
"full_dd": full["dd_pct"],
|
||||
"trades_per_year": tpy,
|
||||
"net_mid_pf": mid["pf"],
|
||||
"target_pf_ok": mid["pf"] >= br["target"]["pf"],
|
||||
"target_dd_ok": full["dd_pct"] <= br["target"]["dd"],
|
||||
"freq_ok": 5.0 <= tpy <= 15.0,
|
||||
"regime_logic_ok": trend_pf >= range_pf, # 趋势应不差于横盘
|
||||
"status": "PASS" if (mid["pf"] >= br["target"]["pf"] and full["dd_pct"] <= br["target"]["dd"]) else "FAIL",
|
||||
"hypothesis": "4h native SOS → 1h LPS",
|
||||
}
|
||||
print("\n===== Verdict =====")
|
||||
print(json.dumps(block["verdict"], ensure_ascii=False, indent=2))
|
||||
|
||||
OUT.write_text(json.dumps(block, indent=2, ensure_ascii=False))
|
||||
# 合并进 compare 结果(保留 Spring,覆盖 LPS)
|
||||
if COMPARE.exists():
|
||||
prev = json.loads(COMPARE.read_text())
|
||||
else:
|
||||
prev = {"branches": {}}
|
||||
prev.setdefault("branches", {})["LPS_V2"] = block
|
||||
# 清理旧 LPS_V1 key 的活跃地位,保留作历史可手动看
|
||||
spring = prev["branches"].get("Spring_V1", {}).get("verdict", {})
|
||||
prev["system_status"] = {
|
||||
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
|
||||
"lps": block["verdict"]["status"],
|
||||
"spring_tpy": spring.get("trades_per_year"),
|
||||
"lps_tpy": tpy,
|
||||
"next": "若 LPS PASS → 组合层;否则 Spring-only",
|
||||
}
|
||||
COMPARE.write_text(json.dumps(prev, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
print("system_status:", json.dumps(prev["system_status"], ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase3 — Evidence Expansion(不改 Spring 规则)
|
||||
|
||||
目标: 将样本从 N=20 推向 N>=50
|
||||
手段:
|
||||
- 多品种外部验证(本地有数据的 pair)
|
||||
- 分开统计 SPRING_LONG / UTAD_SHORT
|
||||
- 同一净成本模型(fee+slip)
|
||||
- 不引入 LPS、不扫参
|
||||
|
||||
用法:
|
||||
.venv/bin/python user_data/Chan/scripts/wyckoff_phase3_evidence.py
|
||||
|
||||
缺 4h/8h 时从 1h resample(离线,不依赖 API)。
|
||||
BTC 若无 2019 更早数据,脚本会标明 gap,不伪造历史。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
DATADIR = ROOT / "user_data/data/binance/futures"
|
||||
STRAT = "Wyckoff_BTC_V1_BASELINE"
|
||||
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_phase3_evidence_result.json"
|
||||
|
||||
# 候选外部验证(规则冻结;用本地最长可用历史)
|
||||
CANDIDATES = [
|
||||
{"pair": "BTC/USDT:USDT", "file": "BTC_USDT_USDT", "timerange": "20190901-"},
|
||||
{"pair": "ETH/USDT:USDT", "file": "ETH_USDT_USDT", "timerange": "20191101-"},
|
||||
{"pair": "SOL/USDT:USDT", "file": "SOL_USDT_USDT", "timerange": "20200901-"},
|
||||
]
|
||||
|
||||
MIN_1H_BARS = 4000 # ~ema200@8h 需要足够历史;过短 skip
|
||||
|
||||
|
||||
def ensure_tf(file_stub: str, tf: str, source_tf: str = "1h") -> bool:
|
||||
"""从更细周期 resample 生成 tf feather;已存在则跳过。"""
|
||||
out = DATADIR / f"{file_stub}-{tf}-futures.feather"
|
||||
src = DATADIR / f"{file_stub}-{source_tf}-futures.feather"
|
||||
if out.exists():
|
||||
return True
|
||||
if not src.exists():
|
||||
return False
|
||||
df = pd.read_feather(src)
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True)
|
||||
df = df.set_index("date").sort_index()
|
||||
rule = tf.replace("m", "min") if tf.endswith("m") else tf
|
||||
ohlc = df.resample(rule).agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
|
||||
).dropna(subset=["open", "close"])
|
||||
ohlc = ohlc.reset_index()
|
||||
ohlc.to_feather(out)
|
||||
print(f" resampled {out.name} n={len(ohlc)}", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def pair_ready(file_stub: str) -> tuple[bool, str]:
|
||||
p1 = DATADIR / f"{file_stub}-1h-futures.feather"
|
||||
if not p1.exists():
|
||||
return False, "missing 1h"
|
||||
df = pd.read_feather(p1)
|
||||
n = len(df)
|
||||
if n < MIN_1H_BARS:
|
||||
return False, f"1h bars={n} < {MIN_1H_BARS} (insufficient for 8h ema200)"
|
||||
ok4 = ensure_tf(file_stub, "4h")
|
||||
ok8 = ensure_tf(file_stub, "8h")
|
||||
if not (ok4 and ok8):
|
||||
return False, "cannot build 4h/8h"
|
||||
return True, f"1h={n}"
|
||||
|
||||
|
||||
def run_bt(pair: str, timerange: str, fee: float = 0.0005, extra: float = 0.0) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": STRAT,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": fee + extra,
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"pair_whitelist": [pair],
|
||||
"name": config.get("exchange", {}).get("name", "binance"),
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(STRAT) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
|
||||
# 按 enter_tag 拆分(freqtrade 可能是 dict 或 list[dict])
|
||||
by_tag: dict[str, dict[str, Any]] = {}
|
||||
trades = st.get("trades") or []
|
||||
tag_stats = st.get("results_per_enter_tag") or {}
|
||||
items = []
|
||||
if isinstance(tag_stats, dict):
|
||||
items = list(tag_stats.items())
|
||||
elif isinstance(tag_stats, list):
|
||||
items = [
|
||||
(x.get("key") or x.get("enter_tag") or x.get("tag") or "unknown", x)
|
||||
for x in tag_stats
|
||||
if isinstance(x, dict)
|
||||
]
|
||||
if items:
|
||||
for tag, info in items:
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
by_tag[str(tag)] = {
|
||||
"trades": int(info.get("trades") or info.get("total_trades") or 0),
|
||||
"profit_pct": float(
|
||||
info.get("profit_total_pct")
|
||||
if info.get("profit_total_pct") is not None
|
||||
else (float(info.get("profit_total") or 0) * 100)
|
||||
),
|
||||
"pf": float(info.get("profit_factor") or 0),
|
||||
}
|
||||
elif trades:
|
||||
from collections import defaultdict
|
||||
agg: dict[str, list] = defaultdict(list)
|
||||
for t in trades:
|
||||
tag = t.get("enter_tag") or "unknown"
|
||||
agg[tag].append(float(t.get("profit_ratio") or 0))
|
||||
for tag, profits in agg.items():
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [-p for p in profits if p <= 0]
|
||||
gross_win = sum(wins)
|
||||
gross_loss = sum(losses)
|
||||
pf = (gross_win / gross_loss) if gross_loss > 0 else (999.0 if gross_win > 0 else 0.0)
|
||||
by_tag[tag] = {
|
||||
"trades": len(profits),
|
||||
"profit_pct": sum(profits) * 100,
|
||||
"pf": float(pf),
|
||||
}
|
||||
|
||||
return {
|
||||
"pair": pair,
|
||||
"timerange": timerange,
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"fee_used": config["fee"],
|
||||
"by_setup": by_tag,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([c["pair"] for c in CANDIDATES])
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"phase": "Phase3 Evidence Expansion",
|
||||
"strategy": STRAT,
|
||||
"rule": "frozen Spring-only; no LPS; no param change",
|
||||
"pairs": {},
|
||||
"skipped": {},
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
# BTC 历史缺口说明
|
||||
btc_1h = DATADIR / "BTC_USDT_USDT-1h-futures.feather"
|
||||
if btc_1h.exists():
|
||||
d0 = pd.read_feather(btc_1h)["date"].min()
|
||||
results["notes"].append(
|
||||
f"BTC local 1h starts {d0}; 2019-2022 not in datadir — download separately for deeper N"
|
||||
)
|
||||
|
||||
print("===== Phase3: prepare TF data =====", flush=True)
|
||||
run_list = []
|
||||
for c in CANDIDATES:
|
||||
ok, msg = pair_ready(c["file"])
|
||||
if ok:
|
||||
print(f" READY {c['pair']}: {msg}", flush=True)
|
||||
run_list.append(c)
|
||||
else:
|
||||
print(f" SKIP {c['pair']}: {msg}", flush=True)
|
||||
results["skipped"][c["pair"]] = msg
|
||||
|
||||
print("\n===== Phase3: backtests (fee 5bps, then fee+slip) =====", flush=True)
|
||||
total_n = 0
|
||||
spring_n = 0
|
||||
utad_n = 0
|
||||
|
||||
for c in run_list:
|
||||
print(f"\n--- {c['pair']} ---", flush=True)
|
||||
base = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0)
|
||||
mid = run_bt(c["pair"], c["timerange"], fee=0.0005, extra=0.0005)
|
||||
block = {"base_fee": base, "net_mid": mid}
|
||||
results["pairs"][c["pair"]] = block
|
||||
total_n += base["trades"]
|
||||
for tag, info in base.get("by_setup", {}).items():
|
||||
if "SPRING" in tag:
|
||||
spring_n += info["trades"]
|
||||
if "UTAD" in tag:
|
||||
utad_n += info["trades"]
|
||||
print(
|
||||
f" fee5bps profit={base['profit_pct']:.2f}% n={base['trades']} "
|
||||
f"dd={base['dd_pct']:.1f}% pf={base['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f" net_mid profit={mid['profit_pct']:.2f}% n={mid['trades']} "
|
||||
f"pf={mid['pf']:.2f}",
|
||||
flush=True,
|
||||
)
|
||||
print(f" by_setup {base.get('by_setup')}", flush=True)
|
||||
|
||||
results["aggregate"] = {
|
||||
"pairs_tested": len(run_list),
|
||||
"total_trades": total_n,
|
||||
"spring_long_trades": spring_n,
|
||||
"utad_short_trades": utad_n,
|
||||
"target_n": 50,
|
||||
"target_met": total_n >= 50,
|
||||
"next": (
|
||||
"目标 N>=50 已达成 — 再看跨品种 net PF 是否仍>1.3"
|
||||
if total_n >= 50
|
||||
else "继续补历史数据(BTC 2019+)或更多品种 1h/4h/8h"
|
||||
),
|
||||
}
|
||||
print("\n===== Aggregate =====")
|
||||
print(json.dumps(results["aggregate"], ensure_ascii=False, indent=2))
|
||||
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
print(f"\nSaved {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regime Attribution Study — 策略完全冻结
|
||||
|
||||
问题:为什么 Spring 在 2023+ BTC 有效,全历史 / 多品种不稳健?
|
||||
方法:逐笔交易打市场状态标签,按桶看 net PF(不改任何入场逻辑)
|
||||
|
||||
输出:
|
||||
- scripts/wyckoff_regime_attribution_trades.jsonl 逐笔
|
||||
- scripts/wyckoff_regime_attribution_result.json 汇总
|
||||
- research/VALIDITY_BOUNDARY.md 适用域草案
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
STRAT = "Wyckoff_BTC_V1_BASELINE"
|
||||
CONFIG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
DATADIR = ROOT / "user_data/data/binance/futures"
|
||||
OUT_JSON = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_result.json"
|
||||
OUT_TRADES = ROOT / "user_data/Chan/scripts/wyckoff_regime_attribution_trades.jsonl"
|
||||
OUT_BOUNDARY = ROOT / "user_data/Chan/research/VALIDITY_BOUNDARY.md"
|
||||
STATUS = ROOT / "user_data/Chan/research/SYSTEM_STATUS.md"
|
||||
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
TIMERANGE = "20190901-"
|
||||
FEE = 0.0005
|
||||
SLIP = 0.0005 # 评价用 net
|
||||
|
||||
|
||||
def _pf(profits: list[float]) -> float:
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [-p for p in profits if p <= 0]
|
||||
gw, gl = sum(wins), sum(losses)
|
||||
if gl <= 0:
|
||||
return 999.0 if gw > 0 else 0.0
|
||||
return gw / gl
|
||||
|
||||
|
||||
def _bucket_stats(rows: list[dict], key: str) -> dict[str, Any]:
|
||||
groups: dict[str, list[float]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[str(r.get(key, "na"))].append(float(r["profit_ratio"]))
|
||||
out = {}
|
||||
for k, ps in sorted(groups.items(), key=lambda x: -len(x[1])):
|
||||
out[k] = {
|
||||
"n": len(ps),
|
||||
"winrate": 100.0 * sum(1 for p in ps if p > 0) / len(ps),
|
||||
"avg_pct": 100.0 * float(np.mean(ps)),
|
||||
"sum_pct": 100.0 * float(np.sum(ps)),
|
||||
"pf": round(_pf(ps), 3),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def build_feature_frames(pair_file: str = "BTC_USDT_USDT") -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""1h ATR percentile + 8h structure features(与策略无关的分析层)。"""
|
||||
h1 = pd.read_feather(DATADIR / f"{pair_file}-1h-futures.feather")
|
||||
h1["date"] = pd.to_datetime(h1["date"], utc=True)
|
||||
h1 = h1.sort_values("date").reset_index(drop=True)
|
||||
h1["atr"] = ta.ATR(h1, timeperiod=14)
|
||||
# 滚动 90 天 ≈ 2160 根 1h 的 ATR 分位
|
||||
win = 2160
|
||||
h1["atr_percentile"] = h1["atr"].rolling(win, min_periods=200).apply(
|
||||
lambda x: pd.Series(x).rank(pct=True).iloc[-1], raw=False
|
||||
)
|
||||
|
||||
h8 = pd.read_feather(DATADIR / f"{pair_file}-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = h8.sort_values("date").reset_index(drop=True)
|
||||
h8["ema50"] = ta.EMA(h8, timeperiod=50)
|
||||
h8["ema200"] = ta.EMA(h8, timeperiod=200)
|
||||
h8["adx"] = ta.ADX(h8, timeperiod=14)
|
||||
h8["ema_slope"] = (h8["ema50"] - h8["ema50"].shift(6)) / h8["ema50"].shift(6)
|
||||
h8["dist_ema200"] = (h8["close"] - h8["ema200"]) / h8["ema200"]
|
||||
h8["bull"] = (h8["close"] > h8["ema200"]) & (h8["ema50"] > h8["ema200"])
|
||||
h8["bear"] = (h8["close"] < h8["ema200"]) & (h8["ema50"] < h8["ema200"])
|
||||
|
||||
# Cycle(粗粒度威科夫语境,非策略信号)
|
||||
slope = h8["ema_slope"]
|
||||
cycle = np.full(len(h8), "transition", dtype=object)
|
||||
cycle[(h8["bear"]) & (slope < -0.01)] = "markdown"
|
||||
cycle[(h8["bear"]) & (slope >= -0.01)] = "accumulation_like"
|
||||
cycle[(h8["bull"]) & (slope > 0.005)] = "markup"
|
||||
cycle[(h8["bull"]) & (slope <= 0.005)] = "distribution_like"
|
||||
h8["btc_cycle"] = cycle
|
||||
|
||||
# trend strength
|
||||
ts = np.full(len(h8), "weak", dtype=object)
|
||||
ts[(h8["adx"] >= 25) & (h8["adx"] < 35)] = "moderate"
|
||||
ts[h8["adx"] >= 35] = "strong"
|
||||
h8["trend_strength"] = ts
|
||||
|
||||
regime = np.full(len(h8), "range", dtype=object)
|
||||
regime[h8["bull"].fillna(False)] = "bull"
|
||||
regime[h8["bear"].fillna(False)] = "bear"
|
||||
h8["market_regime"] = regime
|
||||
return h1, h8
|
||||
|
||||
|
||||
def atr_bucket(p: float) -> str:
|
||||
if pd.isna(p):
|
||||
return "atr_unknown"
|
||||
if p < 0.33:
|
||||
return "atr_low"
|
||||
if p < 0.66:
|
||||
return "atr_mid"
|
||||
return "atr_high"
|
||||
|
||||
|
||||
def slope_bucket(s: float) -> str:
|
||||
if pd.isna(s):
|
||||
return "slope_unknown"
|
||||
if s > 0.01:
|
||||
return "slope_up_strong"
|
||||
if s > 0:
|
||||
return "slope_up_mild"
|
||||
if s > -0.01:
|
||||
return "slope_flat_down"
|
||||
return "slope_down_strong"
|
||||
|
||||
|
||||
def run_backtest_trades() -> list[dict[str, Any]]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG)])
|
||||
config.update(
|
||||
{
|
||||
"strategy": STRAT,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": TIMERANGE,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": FEE + SLIP,
|
||||
"exchange": {
|
||||
**config.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
|
||||
rows = []
|
||||
for t in LocalTrade.bt_trades:
|
||||
rows.append(
|
||||
{
|
||||
"pair": t.pair,
|
||||
"enter_tag": t.enter_tag or "",
|
||||
"is_short": bool(t.is_short),
|
||||
"entry_date": t.open_date_utc.isoformat(),
|
||||
"exit_date": t.close_date_utc.isoformat() if t.close_date_utc else None,
|
||||
"profit_ratio": float(t.close_profit or 0.0),
|
||||
"exit_reason": t.exit_reason or "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def attribute(trades: list[dict], h1: pd.DataFrame, h8: pd.DataFrame) -> list[dict]:
|
||||
h1 = h1.set_index("date").sort_index()
|
||||
h8 = h8.set_index("date").sort_index()
|
||||
out = []
|
||||
for t in trades:
|
||||
ed = pd.Timestamp(t["entry_date"])
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
# asof merge:入场前最后一根已收盘特征
|
||||
i1 = h1.index.get_indexer([ed], method="ffill")[0]
|
||||
i8 = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if i1 < 0 or i8 < 0:
|
||||
continue
|
||||
r1 = h1.iloc[i1]
|
||||
r8 = h8.iloc[i8]
|
||||
ap = float(r1["atr_percentile"]) if pd.notna(r1["atr_percentile"]) else float("nan")
|
||||
slope = float(r8["ema_slope"]) if pd.notna(r8["ema_slope"]) else float("nan")
|
||||
adx = float(r8["adx"]) if pd.notna(r8["adx"]) else float("nan")
|
||||
era = "2023plus" if ed >= pd.Timestamp("2023-01-01", tz="UTC") else "pre_2023"
|
||||
rec = {
|
||||
**t,
|
||||
"market_regime": str(r8["market_regime"]),
|
||||
"8h_adx": round(adx, 2) if not np.isnan(adx) else None,
|
||||
"8h_ema_slope": round(slope, 5) if not np.isnan(slope) else None,
|
||||
"atr_percentile": round(ap, 3) if not np.isnan(ap) else None,
|
||||
"btc_cycle": str(r8["btc_cycle"]),
|
||||
"trend_strength": str(r8["trend_strength"]),
|
||||
"dist_ema200": round(float(r8["dist_ema200"]), 4) if pd.notna(r8["dist_ema200"]) else None,
|
||||
"atr_bucket": atr_bucket(ap),
|
||||
"slope_bucket": slope_bucket(slope),
|
||||
"era": era,
|
||||
"setup": t["enter_tag"] or ("UTAD_SHORT" if t["is_short"] else "SPRING_LONG"),
|
||||
"result": "win" if t["profit_ratio"] > 0 else "loss",
|
||||
}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def write_boundary(summary: dict[str, Any]) -> None:
|
||||
# 从桶结果提炼适用域草案(描述性,非自动交易规则)
|
||||
atr = summary["by_atr_bucket"]
|
||||
cycle = summary["by_btc_cycle"]
|
||||
era = summary["by_era"]
|
||||
ts = summary["by_trend_strength"]
|
||||
|
||||
def best_worst(d: dict) -> tuple[str, str]:
|
||||
items = [(k, v) for k, v in d.items() if v["n"] >= 5]
|
||||
if not items:
|
||||
return "n/a", "n/a"
|
||||
best = max(items, key=lambda x: x[1]["pf"])
|
||||
worst = min(items, key=lambda x: x[1]["pf"])
|
||||
return f"{best[0]} (PF {best[1]['pf']}, n={best[1]['n']})", f"{worst[0]} (PF {worst[1]['pf']}, n={worst[1]['n']})"
|
||||
|
||||
ab, aw = best_worst(atr)
|
||||
cb, cw = best_worst(cycle)
|
||||
tb, tw = best_worst(ts)
|
||||
|
||||
text = f"""# Validity Boundary — Spring Baseline (draft)
|
||||
|
||||
> 策略规则冻结。本文仅来自 Regime Attribution,**不是**新入场条件。
|
||||
|
||||
## Evidence snapshot
|
||||
|
||||
| Era | n | PF (net) | sum%% |
|
||||
|-----|---|----------|-------|
|
||||
| pre_2023 | {era.get('pre_2023', {}).get('n', 0)} | {era.get('pre_2023', {}).get('pf', 0)} | {era.get('pre_2023', {}).get('sum_pct', 0):.1f} |
|
||||
| 2023plus | {era.get('2023plus', {}).get('n', 0)} | {era.get('2023plus', {}).get('pf', 0)} | {era.get('2023plus', {}).get('sum_pct', 0):.1f} |
|
||||
|
||||
## Observed favorable (descriptive)
|
||||
|
||||
- ATR bucket best: **{ab}**
|
||||
- Cycle best: **{cb}**
|
||||
- Trend strength best: **{tb}**
|
||||
|
||||
## Observed unfavorable (descriptive)
|
||||
|
||||
- ATR bucket worst: **{aw}**
|
||||
- Cycle worst: **{cw}**
|
||||
- Trend strength worst: **{tw}**
|
||||
|
||||
## Draft Validity Boundary
|
||||
|
||||
```
|
||||
Spring Strategy (BTC)
|
||||
适用(研究假设,待 Decision Engine 验证):
|
||||
✓ BTC(非默认跨资产)
|
||||
✓ 2023+ 类「明确资金方向 / Markup 启动」环境
|
||||
✓ 高/中波动(ATR rising / mid-high percentile)若数据支持
|
||||
✓ Accumulation_like → Markup 过渡语境
|
||||
|
||||
不适用(当前证据):
|
||||
✗ 默认全历史无条件交易
|
||||
✗ 横盘 / range regime
|
||||
✗ 跨资产默认开启(ETH/SOL Phase3 未过)
|
||||
✗ 熊市 Markdown 快速崩跌阶段(若桶显示 PF 差)
|
||||
```
|
||||
|
||||
## Next for Decision Engine
|
||||
|
||||
Market State 先判定「是否落在适用域」→ 再允许 SPRING_LONG / UTAD_SHORT 信号。
|
||||
**禁止**把本文件桶标签直接写回 Baseline 参数扫参。
|
||||
"""
|
||||
OUT_BOUNDARY.write_text(text)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
install_offline_markets([PAIR])
|
||||
|
||||
print("===== 1) Frozen baseline backtest (BTC, net cost) =====", flush=True)
|
||||
raw = run_backtest_trades()
|
||||
print(f" trades={len(raw)}", flush=True)
|
||||
|
||||
print("===== 2) Build regime features =====", flush=True)
|
||||
h1, h8 = build_feature_frames()
|
||||
rows = attribute(raw, h1, h8)
|
||||
print(f" attributed={len(rows)}", flush=True)
|
||||
|
||||
with OUT_TRADES.open("w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"pair": PAIR,
|
||||
"timerange": TIMERANGE,
|
||||
"fee_model": f"fee {FEE}+slip {SLIP}",
|
||||
"n": len(rows),
|
||||
"overall_pf": round(_pf([r["profit_ratio"] for r in rows]), 3),
|
||||
"by_era": _bucket_stats(rows, "era"),
|
||||
"by_setup": _bucket_stats(rows, "setup"),
|
||||
"by_market_regime": _bucket_stats(rows, "market_regime"),
|
||||
"by_atr_bucket": _bucket_stats(rows, "atr_bucket"),
|
||||
"by_trend_strength": _bucket_stats(rows, "trend_strength"),
|
||||
"by_slope_bucket": _bucket_stats(rows, "slope_bucket"),
|
||||
"by_btc_cycle": _bucket_stats(rows, "btc_cycle"),
|
||||
"by_era_x_cycle": {},
|
||||
"by_era_x_atr": {},
|
||||
"interpretation": [],
|
||||
}
|
||||
|
||||
# 交叉:era × cycle / atr
|
||||
for era in ("pre_2023", "2023plus"):
|
||||
sub = [r for r in rows if r["era"] == era]
|
||||
summary["by_era_x_cycle"][era] = _bucket_stats(sub, "btc_cycle")
|
||||
summary["by_era_x_atr"][era] = _bucket_stats(sub, "atr_bucket")
|
||||
|
||||
# 自动写几条解释线索(非交易规则)
|
||||
era = summary["by_era"]
|
||||
if era.get("2023plus", {}).get("pf", 0) > era.get("pre_2023", {}).get("pf", 0):
|
||||
summary["interpretation"].append(
|
||||
"2023plus PF 显著高于 pre_2023 → 存在 regime/cycle 依赖,非随机噪声单一窗口。"
|
||||
)
|
||||
cyc = summary["by_btc_cycle"]
|
||||
if cyc:
|
||||
best_c = max(cyc.items(), key=lambda x: (x[1]["n"] >= 5, x[1]["pf"]))
|
||||
summary["interpretation"].append(
|
||||
f"全样本 cycle 最优桶(n≥5 优先): {best_c[0]} PF={best_c[1]['pf']} n={best_c[1]['n']}"
|
||||
)
|
||||
|
||||
print("\n===== 3) Attribution tables =====", flush=True)
|
||||
for name in (
|
||||
"by_era", "by_setup", "by_market_regime", "by_atr_bucket",
|
||||
"by_trend_strength", "by_slope_bucket", "by_btc_cycle",
|
||||
):
|
||||
print(f"\n-- {name} --")
|
||||
for k, v in summary[name].items():
|
||||
print(f" {k:<22} n={v['n']:<3} pf={v['pf']:<6} wr={v['winrate']:.0f}% sum={v['sum_pct']:.1f}%")
|
||||
|
||||
print("\n-- by_era_x_cycle --")
|
||||
print(json.dumps(summary["by_era_x_cycle"], indent=2, ensure_ascii=False))
|
||||
|
||||
write_boundary(summary)
|
||||
OUT_JSON.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
|
||||
# 更新 SYSTEM_STATUS
|
||||
if STATUS.exists():
|
||||
st = STATUS.read_text()
|
||||
marker = "## Frozen Baseline"
|
||||
block = (
|
||||
"**Status update (Regime Attribution):**\n"
|
||||
"Evidence: PASS (2023+ BTC) · Robustness: FAILED (multi-cycle) · "
|
||||
"Confidence: LOW-MEDIUM · Next: Decision Engine validity gate "
|
||||
f"(see `VALIDITY_BOUNDARY.md`, trades=`{OUT_TRADES.name}`).\n\n"
|
||||
)
|
||||
if "Status update (Regime Attribution)" not in st:
|
||||
st = st.replace(marker, block + marker)
|
||||
STATUS.write_text(st)
|
||||
|
||||
print(f"\nSaved {OUT_JSON}")
|
||||
print(f"Saved {OUT_TRADES}")
|
||||
print(f"Saved {OUT_BOUNDARY}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Soft-score Gate — 窄实验(研究纪律)
|
||||
|
||||
1) 仅在 pre_2023 比较少数 Gate 形式并选定阈值
|
||||
2) 锁定后评估 2023+ / full
|
||||
3) 禁止全样本扫参;判定不要求超过 baseline PF
|
||||
|
||||
候选:
|
||||
- state_set
|
||||
- soft_sum: state_set & (accum+markup) >= q q ∈ {80,100,120,140}
|
||||
- soft_bad_cap: state_set & max(bad) <= q q ∈ {40,50,60}
|
||||
|
||||
Fit 目标(pre_2023): n>=5 前提下优先更低 DD,其次更高 PF(非收益最大化)
|
||||
OOS 通过:
|
||||
- 2023+ PF >= 1.2
|
||||
- full DD 明显低于 baseline(<= baseline_dd * 0.7 或绝对差 >= 5pp)
|
||||
- pre_2023 n >= 5(非极低样本偶然)
|
||||
- 标签不漂移:gated 入场中 state∈{accumulation,markup}|UTAD镜像 比例 >= 0.95
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from user_data.Chan.scripts.wyckoff_tf_grid import install_offline_markets # noqa: E402
|
||||
|
||||
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC_GATED.py"
|
||||
BASE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json"
|
||||
GATE_CFG = ROOT / "user_data/Chan/config/Wyckoff_BTC_GATED.json"
|
||||
OUT = ROOT / "user_data/Chan/scripts/wyckoff_soft_gate_oos_result.json"
|
||||
PAIR = "BTC/USDT:USDT"
|
||||
|
||||
FIT_TR = "20190901-20230101"
|
||||
OOS_TR = "20230101-"
|
||||
FULL_TR = "20190901-"
|
||||
|
||||
CANDIDATES: list[dict[str, Any]] = [
|
||||
{"mode": "state_set", "q_sum": 100.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 80.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 100.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 120.0, "q_bad": 55.0},
|
||||
{"mode": "soft_sum", "q_sum": 140.0, "q_bad": 55.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 40.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 50.0},
|
||||
{"mode": "soft_bad_cap", "q_sum": 100.0, "q_bad": 60.0},
|
||||
]
|
||||
|
||||
|
||||
def set_gate(mode: str, q_sum: float, q_bad: float) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
text2, n1 = re.subn(
|
||||
r'^(\tgate_mode: str = )".*"',
|
||||
rf'\g<1>"{mode}"',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
text2, n2 = re.subn(
|
||||
r'^(\tgate_q_sum: float = )[0-9.]+',
|
||||
rf"\g<1>{float(q_sum)}",
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
text2, n3 = re.subn(
|
||||
r'^(\tgate_q_bad: float = )[0-9.]+',
|
||||
rf"\g<1>{float(q_bad)}",
|
||||
text2,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
if min(n1, n2, n3) < 1:
|
||||
raise RuntimeError(f"failed patching gate attrs n=({n1},{n2},{n3})")
|
||||
STRAT_PATH.write_text(text2)
|
||||
pyc = STRAT_PATH.parent / "__pycache__"
|
||||
if pyc.is_dir():
|
||||
for p in pyc.glob("Wyckoff_BTC_GATED*.pyc"):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def run_bt(strategy: str, config: Path, timerange: str) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod or "market_state" in mod:
|
||||
del sys.modules[mod]
|
||||
|
||||
cfg = Configuration.from_files([str(config)])
|
||||
cfg.update(
|
||||
{
|
||||
"strategy": strategy,
|
||||
"strategy_path": str(ROOT / "user_data/Chan/strategies"),
|
||||
"timerange": timerange,
|
||||
"timeframe": "1h",
|
||||
"export": "none",
|
||||
"runmode": RunMode.BACKTEST,
|
||||
"datadir": ROOT / "user_data/data/binance",
|
||||
"user_data_dir": ROOT / "user_data",
|
||||
"enable_protections": False,
|
||||
"fee": 0.0010,
|
||||
"exchange": {
|
||||
**cfg.get("exchange", {}),
|
||||
"name": "binance",
|
||||
"pair_whitelist": [PAIR],
|
||||
},
|
||||
}
|
||||
)
|
||||
bt = Backtesting(cfg)
|
||||
bt.start()
|
||||
st = bt.results["strategy"].get(strategy) or list(bt.results["strategy"].values())[0]
|
||||
profit = st.get("profit_total_pct")
|
||||
if profit is None:
|
||||
profit = float(st.get("profit_total") or 0) * 100
|
||||
|
||||
profits = [float(t.close_profit or 0.0) for t in LocalTrade.bt_trades]
|
||||
wins = [p for p in profits if p > 0]
|
||||
losses = [p for p in profits if p <= 0]
|
||||
avg_win = float(sum(wins) / len(wins)) if wins else 0.0
|
||||
avg_loss = float(sum(losses) / len(losses)) if losses else 0.0
|
||||
expectancy = float(sum(profits) / len(profits)) if profits else 0.0
|
||||
|
||||
# 标签漂移:用原生 8h 因果状态(不依赖 analyzed 缓存窗口)
|
||||
label_ok_rate = None
|
||||
try:
|
||||
import pandas as pd
|
||||
from engine.market_state import compute_market_state_8h
|
||||
|
||||
h8 = pd.read_feather(ROOT / "user_data/data/binance/futures/BTC_USDT_USDT-8h-futures.feather")
|
||||
h8["date"] = pd.to_datetime(h8["date"], utc=True)
|
||||
h8 = compute_market_state_8h(h8).set_index("date").sort_index()
|
||||
ok = tot = 0
|
||||
for t in LocalTrade.bt_trades:
|
||||
ed = pd.Timestamp(t.open_date_utc)
|
||||
if ed.tzinfo is None:
|
||||
ed = ed.tz_localize("UTC")
|
||||
idx = h8.index.get_indexer([ed], method="ffill")[0]
|
||||
if idx < 0:
|
||||
continue
|
||||
stt = str(h8.iloc[idx]["market_state"])
|
||||
tag = t.enter_tag or ""
|
||||
if "SPRING" in tag:
|
||||
ok += int(stt in ("accumulation", "markup"))
|
||||
elif "UTAD" in tag:
|
||||
ok += int(stt in ("distribution", "markdown"))
|
||||
else:
|
||||
ok += 1
|
||||
tot += 1
|
||||
label_ok_rate = (ok / tot) if tot else None
|
||||
except Exception:
|
||||
label_ok_rate = None
|
||||
|
||||
return {
|
||||
"profit_pct": float(profit),
|
||||
"trades": int(st.get("total_trades") or 0),
|
||||
"dd_pct": float(st.get("max_drawdown_account") or 0) * 100,
|
||||
"pf": float(st.get("profit_factor") or 0),
|
||||
"winrate": float(st.get("winrate") or 0) * 100,
|
||||
"expectancy": expectancy,
|
||||
"avg_win": avg_win,
|
||||
"avg_loss": avg_loss,
|
||||
"label_ok_rate": label_ok_rate,
|
||||
}
|
||||
|
||||
|
||||
def fit_score(m: dict[str, Any]) -> tuple:
|
||||
"""pre_2023 选择:n>=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()
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""离线网格:对比 Wyckoff 多周期组合(不依赖 Binance API)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
STRAT_PATH = ROOT / "user_data/Chan/strategies/Wyckoff_BTC.py"
|
||||
CONFIG_PATH = ROOT / "user_data/Chan/config/Wyckoff_BTC.json"
|
||||
|
||||
COMBOS = [
|
||||
("1h_4h_noBias", "1h", "4h", None),
|
||||
("1h_4h_8h", "1h", "4h", "8h"),
|
||||
("1h_8h_noBias", "1h", "8h", None),
|
||||
("30m_4h_8h", "30m", "4h", "8h"),
|
||||
("30m_4h_noBias", "30m", "4h", None),
|
||||
("15m_1h_4h", "15m", "1h", "4h"),
|
||||
("15m_4h_8h", "15m", "4h", "8h"),
|
||||
("4h_8h_noBias", "4h", "8h", None),
|
||||
]
|
||||
|
||||
|
||||
def stub_market(symbol: str = "BTC/USDT:USDT") -> dict[str, Any]:
|
||||
base = symbol.split("/")[0]
|
||||
return {
|
||||
"id": symbol,
|
||||
"symbol": symbol,
|
||||
"base": base,
|
||||
"quote": "USDT",
|
||||
"settle": "USDT",
|
||||
"baseId": base,
|
||||
"quoteId": "USDT",
|
||||
"settleId": "USDT",
|
||||
"type": "swap",
|
||||
"spot": False,
|
||||
"swap": True,
|
||||
"future": False,
|
||||
"option": False,
|
||||
"active": True,
|
||||
"contract": True,
|
||||
"linear": True,
|
||||
"inverse": False,
|
||||
"contractSize": 1.0,
|
||||
"precision": {"amount": 0.001, "price": 0.1},
|
||||
"limits": {
|
||||
"amount": {"min": 0.001, "max": 1000.0},
|
||||
"price": {"min": 0.1, "max": None},
|
||||
"cost": {"min": 5.0, "max": None},
|
||||
"leverage": {"min": 1.0, "max": 125.0},
|
||||
},
|
||||
"percentage": True,
|
||||
"taker": 0.0005,
|
||||
"maker": 0.0002,
|
||||
"info": {},
|
||||
}
|
||||
|
||||
|
||||
def install_offline_markets(pairs: Optional[list[str]] = None) -> None:
|
||||
import ccxt
|
||||
import freqtrade.exchange.exchange as exmod
|
||||
from freqtrade.util import dt_ts
|
||||
|
||||
if pairs is None:
|
||||
pairs = ["BTC/USDT:USDT"]
|
||||
markets = {p: stub_market(p) for p in pairs}
|
||||
tiers = {
|
||||
p: [
|
||||
{
|
||||
"minNotional": 0,
|
||||
"maxNotional": 1e12,
|
||||
"maintenanceMarginRate": 0.005,
|
||||
"maxLeverage": 125,
|
||||
"info": {},
|
||||
}
|
||||
]
|
||||
for p in pairs
|
||||
}
|
||||
|
||||
def fake_reload(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None:
|
||||
self._markets = markets
|
||||
try:
|
||||
self._api.precisionMode = ccxt.TICK_SIZE
|
||||
self._api_async.precisionMode = ccxt.TICK_SIZE
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._api_async.set_markets(markets)
|
||||
except Exception:
|
||||
pass
|
||||
self._last_markets_refresh = dt_ts()
|
||||
self._leverage_tiers = tiers
|
||||
self._trading_fees = {}
|
||||
|
||||
exmod.Exchange.reload_markets = fake_reload # type: ignore
|
||||
exmod.Exchange.fills_leverage_tiers = lambda self: setattr(self, "_leverage_tiers", tiers) # type: ignore
|
||||
|
||||
|
||||
def patch_strategy(exec_tf: str, structure_tf: str, bias_tf: Optional[str]) -> None:
|
||||
text = STRAT_PATH.read_text()
|
||||
bias_repr = "None" if bias_tf is None else f'"{bias_tf}"'
|
||||
text = re.sub(r'^(\ttimeframe = ).*$', rf'\g<1>"{exec_tf}"', text, count=1, flags=re.M)
|
||||
text = re.sub(
|
||||
r'^(\tstructure_timeframe = ).*$', rf'\g<1>"{structure_tf}"', text, count=1, flags=re.M
|
||||
)
|
||||
text = re.sub(
|
||||
r'^(\tbias_timeframe: Optional\[str\] = ).*$',
|
||||
rf'\g<1>{bias_repr}',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.M,
|
||||
)
|
||||
startup = 220 if exec_tf in ("1h", "4h", "8h") else 400
|
||||
text = re.sub(
|
||||
r'^(\tstartup_candle_count = ).*$', rf'\g<1>{startup}', text, count=1, flags=re.M
|
||||
)
|
||||
STRAT_PATH.write_text(text)
|
||||
|
||||
|
||||
def run_one(exec_tf: str, timerange: str) -> dict[str, Any]:
|
||||
from freqtrade.configuration import Configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
import freqtrade.optimize.optimize_reports.bt_output as bt_output
|
||||
|
||||
# 静默打印
|
||||
bt_output.show_backtest_results = lambda *a, **k: None # type: ignore
|
||||
bt_output.show_backtest_result = lambda *a, **k: None # type: ignore
|
||||
|
||||
for mod in list(sys.modules):
|
||||
if "Wyckoff_BTC" in mod or mod.endswith("Wyckoff_BTC"):
|
||||
del sys.modules[mod]
|
||||
|
||||
config = Configuration.from_files([str(CONFIG_PATH)])
|
||||
config["strategy"] = "Wyckoff_BTC"
|
||||
config["strategy_path"] = str(ROOT / "user_data/Chan/strategies")
|
||||
config["timerange"] = timerange
|
||||
config["timeframe"] = exec_tf
|
||||
config["export"] = "none"
|
||||
config["runmode"] = RunMode.BACKTEST
|
||||
config["datadir"] = ROOT / "user_data/data/binance"
|
||||
config["user_data_dir"] = ROOT / "user_data"
|
||||
config["enable_protections"] = False
|
||||
|
||||
bt = Backtesting(config)
|
||||
bt.start()
|
||||
stats = bt.results
|
||||
strat_stats = stats["strategy"].get("Wyckoff_BTC") or list(stats["strategy"].values())[0]
|
||||
trades = int(strat_stats.get("total_trades") or 0)
|
||||
profit_pct = strat_stats.get("profit_total_pct")
|
||||
if profit_pct is None:
|
||||
profit_pct = float(strat_stats.get("profit_total") or 0) * 100
|
||||
dd = float(strat_stats.get("max_drawdown_account") or 0) * 100
|
||||
wr = float(strat_stats.get("winrate") or 0) * 100
|
||||
return {
|
||||
"ok": True,
|
||||
"profit_pct": float(profit_pct),
|
||||
"trades": trades,
|
||||
"dd_pct": dd,
|
||||
"pf": float(strat_stats.get("profit_factor") or 0),
|
||||
"winrate": wr,
|
||||
"rejected": int(strat_stats.get("rejected_signals") or 0),
|
||||
"timeframe_used": config.get("timeframe"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.getLogger("freqtrade").setLevel(logging.ERROR)
|
||||
timerange = sys.argv[1] if len(sys.argv) > 1 else "20240101-"
|
||||
install_offline_markets()
|
||||
orig = STRAT_PATH.read_text()
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for label, exec_tf, stf, btf in COMBOS:
|
||||
print(f"=== {label} ===", flush=True)
|
||||
patch_strategy(exec_tf, stf, btf)
|
||||
try:
|
||||
res = run_one(exec_tf, timerange)
|
||||
except Exception as e:
|
||||
res = {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
res["label"] = label
|
||||
res["exec"] = exec_tf
|
||||
res["struct"] = stf
|
||||
res["bias"] = btf or "-"
|
||||
rows.append(res)
|
||||
if res.get("ok"):
|
||||
print(
|
||||
f" profit={res['profit_pct']:.2f}% trades={res['trades']} "
|
||||
f"dd={res['dd_pct']:.2f}% pf={res['pf']:.2f} wr={res['winrate']:.1f}% "
|
||||
f"rej={res['rejected']}",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f" FAILED: {res.get('error')}", flush=True)
|
||||
finally:
|
||||
STRAT_PATH.write_text(orig)
|
||||
|
||||
ok = [r for r in rows if r.get("ok")]
|
||||
ok.sort(key=lambda r: (r["profit_pct"], r["pf"]), reverse=True)
|
||||
print("\n========== RANKING ==========")
|
||||
print(f"{'label':<16} {'E':<5} {'S':<5} {'B':<5} {'profit%':>8} {'trades':>7} {'dd%':>7} {'pf':>6} {'wr%':>6}")
|
||||
for r in ok:
|
||||
print(
|
||||
f"{r['label']:<16} {r['exec']:<5} {r['struct']:<5} {r['bias']:<5} "
|
||||
f"{r['profit_pct']:>8.2f} {r['trades']:>7} {r['dd_pct']:>7.2f} {r['pf']:>6.2f} {r['winrate']:>6.1f}"
|
||||
)
|
||||
out = ROOT / "user_data/Chan/scripts/wyckoff_tf_grid_result.txt"
|
||||
out.write_text(json.dumps({"timerange": timerange, "rows": rows}, indent=2))
|
||||
print(f"\nSaved {out}")
|
||||
if ok:
|
||||
best = ok[0]
|
||||
print(f"BEST: {best['label']} -> 将写入策略默认周期")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
BTC Maker Micro Scalper v1.0
|
||||
|
||||
目标:在 BTCUSDT 永续 1m 级别,用盘口微结构(OBI / Delta / CVD / VWAP)
|
||||
做 Maker 挂单,捕捉约 0.03%~0.08% 的微小价差。
|
||||
|
||||
回测说明:
|
||||
- Freqtrade 标准回测只有 OHLCV,没有真实 L2 / Tick。
|
||||
- 本策略用 K 线代理重构 OBI / Delta / CVD,使逻辑可回测、可验证。
|
||||
- 实盘 / Dry-run 下,confirm_trade_entry 会用真实 10 档 orderbook 覆盖 OBI。
|
||||
|
||||
不要加入:RSI / MACD / 均线交叉 / 神经网络。
|
||||
|
||||
运行示例:
|
||||
freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||||
-t 1m --pairs BTC/USDT:USDT --timerange=20260101-
|
||||
|
||||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||||
--strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\
|
||||
--timerange=20260101- --fee 0.00016
|
||||
|
||||
python user_data/Chan/strategies/mms_stats.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy, DecimalParameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_div(num, den):
|
||||
return np.where(den != 0, num / den, 0.0)
|
||||
|
||||
|
||||
class BTC_Maker_Micro_Scalper(IStrategy):
|
||||
"""
|
||||
Maker Micro Scalping MVP — 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤。
|
||||
"""
|
||||
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe: str = "1m"
|
||||
can_short: bool = True
|
||||
process_only_new_candles: bool = True
|
||||
startup_candle_count: int = 120
|
||||
|
||||
# 固定小止盈 / 止损(价格百分比,非杠杆后权益)
|
||||
# ROI +0.05%;stoploss -0.03%;时间止损 3 分钟在 custom_exit
|
||||
minimal_roi = {"0": 0.0005}
|
||||
stoploss = -0.0003
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
use_custom_stoploss = False
|
||||
|
||||
# Maker 限价单
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {
|
||||
"entry": "GTC",
|
||||
"exit": "GTC",
|
||||
}
|
||||
|
||||
# ---- 可调参数(保持与规格一致;后续可 hyperopt)----
|
||||
maker_fee = 0.00016 # 0.016%
|
||||
atr_fee_mult = 3.0 # ATR > fee * 3
|
||||
obi_threshold = 0.15
|
||||
tp_pct = 0.0005 # +0.05%
|
||||
sl_pct = 0.0003 # -0.03%
|
||||
max_hold_minutes = 3
|
||||
stake_pct = 0.005 # 单次 0.5% 账户资金
|
||||
max_leverage = 3.0
|
||||
consecutive_loss_limit = 3
|
||||
pause_minutes = 30
|
||||
vwap_band = 0.001 # ±0.1%
|
||||
ob_levels = 10 # 实盘用 10 档
|
||||
tick_size = 0.1 # BTCUSDT 永续常见最小变动
|
||||
maker_offset_ticks = 1
|
||||
|
||||
# Hyperopt 可选(默认关闭,不改变 v1 逻辑)
|
||||
buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False)
|
||||
|
||||
# 运行时状态:连续亏损熔断
|
||||
_loss_streak: int = 0
|
||||
_pause_until: Optional[datetime] = None
|
||||
_maker_fills: int = 0
|
||||
_total_fills: int = 0
|
||||
|
||||
plot_config = {
|
||||
"main_plot": {
|
||||
"vwap": {"color": "orange"},
|
||||
},
|
||||
"subplots": {
|
||||
"OBI": {"obi": {"color": "blue"}},
|
||||
"Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}},
|
||||
"CVD": {"cvd": {"color": "purple"}},
|
||||
"ATR_pct": {"atr_pct": {"color": "red"}},
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖)
|
||||
# ------------------------------------------------------------------ #
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
|
||||
high = df["high"]
|
||||
low = df["low"]
|
||||
close = df["close"]
|
||||
volume = df["volume"].astype(float)
|
||||
|
||||
# ATR(20) 与相对波动
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = _safe_div(df["atr"], close)
|
||||
# 规格:ATR > 单边手续费 × 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,
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
BTC Maker Scalper v1.1 — Liquidity Providing
|
||||
|
||||
相对 v1.0 的核心变化:
|
||||
- 不再用 OBI/Delta/CVD 预测下一根涨跌(Directional Scalping)
|
||||
- 改为:卖压衰竭 + Bid 吸收 → 提供流动性接单(Liquidity Providing)
|
||||
- 挂单更深:Bid - 0~2 tick(等待被打)
|
||||
- 出场:盘口/价差优势恢复(非固定 0.05% TP)
|
||||
- 禁做市:5m EMA26 斜率过大 或 ATR 异常(单边趋势)
|
||||
|
||||
回测限制(仍然存在,但模型目标不同):
|
||||
- OHLCV 无法完美模拟 Maker 成交时点;本版用更严过滤降频到 ~10-30 笔/天量级做压力测试
|
||||
- 实盘用 orderbook 复核吸收/挂价
|
||||
|
||||
运行:
|
||||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\
|
||||
--strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\
|
||||
--timerange=20260701-20260708 --fee 0.00016 --enable-protections
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_div(num, den, fill=0.0):
|
||||
out = np.where((den is not None) & (den != 0), num / den, fill)
|
||||
return out
|
||||
|
||||
|
||||
class BTC_Maker_Micro_Scalper_v11(IStrategy):
|
||||
"""
|
||||
v1.1 Liquidity Providing:卖压衰竭 + 吸收 → Maker 接单;趋势中禁做市。
|
||||
"""
|
||||
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe = "1m"
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 200
|
||||
|
||||
# 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复)
|
||||
# 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉
|
||||
minimal_roi = {"0": 0.01}
|
||||
# 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复
|
||||
stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察)
|
||||
trailing_stop = False
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
use_custom_stoploss = False
|
||||
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||||
|
||||
# ---- 费用 / 风控 ----
|
||||
maker_fee = 0.00016
|
||||
stake_pct = 0.005
|
||||
max_leverage = 2.0 # v1.1 更克制
|
||||
consecutive_loss_limit = 10
|
||||
pause_minutes = 30
|
||||
max_hold_minutes = 5
|
||||
|
||||
# ---- 微结构代理窗口(1m 近似 20s/100trades)----
|
||||
sell_window = 3 # 近端卖量
|
||||
sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭”
|
||||
absorb_lookback = 5
|
||||
min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参)
|
||||
tick_size = 0.1
|
||||
maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick
|
||||
exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70%
|
||||
prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压)
|
||||
|
||||
# ---- 禁做市(趋势)----
|
||||
ema_slope_thr = 0.00018 # 更早禁止单边做市
|
||||
atr_spike_mult = 1.8
|
||||
min_atr_pct = 0.00035
|
||||
|
||||
# 目标退出:相对入场的“优势恢复”幅度(价格)
|
||||
edge_exit_pct = 0.00025
|
||||
adverse_exit_pct = 0.0006
|
||||
cooldown_minutes = 8 # 降频到验收带附近
|
||||
|
||||
ob_levels = 10
|
||||
|
||||
_loss_streak: int = 0
|
||||
_pause_until: Optional[datetime] = None
|
||||
_last_entry_time: Optional[datetime] = None
|
||||
|
||||
plot_config = {
|
||||
"main_plot": {
|
||||
"ema26_1m": {"color": "gray"},
|
||||
},
|
||||
"subplots": {
|
||||
"SellVol": {
|
||||
"sell_vol": {"color": "red"},
|
||||
"sell_vol_ma": {"color": "orange"},
|
||||
},
|
||||
"Absorb": {"absorb_ratio": {"color": "blue"}},
|
||||
"TrendBlock": {"trend_block": {"color": "black"}},
|
||||
},
|
||||
}
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe.copy()
|
||||
high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float)
|
||||
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).astype(float)
|
||||
hl_safe = hl.where(hl > 0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
|
||||
sell_frac = 1.0 - buy_frac
|
||||
buy_vol = volume * buy_frac
|
||||
sell_vol = volume * sell_frac
|
||||
df["buy_vol"] = buy_vol
|
||||
df["sell_vol"] = sell_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
# ---- A. 主动卖压衰竭(Long)----
|
||||
# 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低
|
||||
df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean()
|
||||
df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||||
sell_baseline = sell_vol.rolling(30, min_periods=10).mean()
|
||||
df["sell_exhaust"] = (
|
||||
(df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult)
|
||||
& (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio)
|
||||
& (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1))
|
||||
)
|
||||
|
||||
# 主动买压衰竭(Short 对称)
|
||||
df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean()
|
||||
df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||||
buy_baseline = buy_vol.rolling(30, min_periods=10).mean()
|
||||
df["buy_exhaust"] = (
|
||||
(df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult)
|
||||
& (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio)
|
||||
& (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1))
|
||||
)
|
||||
|
||||
# ---- B. Bid 吸收:成交卖量 / 价格跌幅 ----
|
||||
# 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零)
|
||||
px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0)
|
||||
px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||||
sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||||
# absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大
|
||||
df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0.0)
|
||||
|
||||
# 价格几乎不跌但有大量卖出 → 吸收极强:给高分
|
||||
flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median())
|
||||
df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[
|
||||
flat_sell.fillna(False), "absorb_ratio"
|
||||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||||
|
||||
# Ask 吸收(Short):买量 / 上涨幅度
|
||||
px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0)
|
||||
px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||||
buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||||
df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0.0)
|
||||
flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median())
|
||||
df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[
|
||||
flat_buy.fillna(False), "absorb_ratio_ask"
|
||||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||||
|
||||
df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio
|
||||
df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio
|
||||
|
||||
# ---- 波动与 ATR ----
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
atr_med = df["atr_pct"].rolling(60, min_periods=20).median()
|
||||
df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult)
|
||||
df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"])
|
||||
|
||||
# ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)----
|
||||
df["ema26_1m"] = ta.EMA(df, timeperiod=26)
|
||||
df["ema26_slope"] = (
|
||||
(df["ema26_1m"] - df["ema26_1m"].shift(5)) / close
|
||||
).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr
|
||||
|
||||
# 微结构“可做市”综合
|
||||
df["mm_regime"] = df["atr_ok"] & (~df["trend_block"])
|
||||
|
||||
# 中价 / 伪价差
|
||||
df["mid"] = (high + low) / 2.0
|
||||
df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||||
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
|
||||
# Long:卖压衰竭 + Bid 吸收 + 非趋势
|
||||
long_cond = (
|
||||
df["mm_regime"]
|
||||
& df["sell_exhaust"]
|
||||
& df["bid_absorb"]
|
||||
& (df["volume"] > 0)
|
||||
# 额外:近端 delta 不再恶化(卖压减弱)
|
||||
& (df["delta"] > df["delta"].shift(1))
|
||||
)
|
||||
|
||||
# Short:买压衰竭 + Ask 吸收 + 非趋势
|
||||
short_cond = (
|
||||
df["mm_regime"]
|
||||
& df["buy_exhaust"]
|
||||
& df["ask_absorb"]
|
||||
& (df["volume"] > 0)
|
||||
& (df["delta"] < df["delta"].shift(1))
|
||||
)
|
||||
|
||||
df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb")
|
||||
df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb")
|
||||
return df
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""信号层只做趋势禁做市强平;主出场交给 custom_exit。"""
|
||||
df = dataframe
|
||||
df["exit_long"] = 0
|
||||
df["exit_short"] = 0
|
||||
df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block")
|
||||
df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block")
|
||||
return df
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Maker 报价:Bid - depth ticks / Ask + depth ticks
|
||||
# ------------------------------------------------------------------ #
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = self.maker_depth_ticks * self.tick_size
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if side == "long" and bids:
|
||||
return max(float(bids[0][0]) - offset, self.tick_size)
|
||||
if side == "short" and asks:
|
||||
return float(asks[0][0]) + offset
|
||||
except Exception as e:
|
||||
logger.debug("v11 entry price ob fallback: %s", e)
|
||||
|
||||
# 回测:挂得更深,降低“虚假即时成交”概率(仍不完美)
|
||||
if side == "long":
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def custom_exit_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
current_profit: float,
|
||||
exit_tag: str | None,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = 1 * self.tick_size
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
# 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1
|
||||
if trade.is_short and bids:
|
||||
return float(bids[0][0]) + offset
|
||||
if (not trade.is_short) and asks:
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("v11 exit price ob fallback: %s", e)
|
||||
if trade.is_short:
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: float | None,
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
try:
|
||||
if self.wallets:
|
||||
free = self.wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception:
|
||||
pass
|
||||
return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake
|
||||
|
||||
def _paused(self, current_time: datetime) -> bool:
|
||||
if self._pause_until is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
until = (
|
||||
self._pause_until
|
||||
if self._pause_until.tzinfo
|
||||
else self._pause_until.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return now < until
|
||||
|
||||
def _in_cooldown(self, current_time: datetime) -> bool:
|
||||
if self._last_entry_time is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
last = (
|
||||
self._last_entry_time
|
||||
if self._last_entry_time.tzinfo
|
||||
else self._last_entry_time.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
return (now - last) < timedelta(minutes=self.cooldown_minutes)
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._paused(current_time) or self._in_cooldown(current_time):
|
||||
return False
|
||||
|
||||
# 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度)
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is not None and len(dataframe):
|
||||
last = dataframe.iloc[-1]
|
||||
if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))):
|
||||
return False
|
||||
|
||||
if self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if not bids or not asks:
|
||||
return False
|
||||
# 简单吸收代理:同价位附近挂单厚度
|
||||
bid_vol = sum(float(b[1]) for b in bids[:3])
|
||||
ask_vol = sum(float(a[1]) for a in asks[:3])
|
||||
if side == "long" and bid_vol < ask_vol * 0.8:
|
||||
# Bid 不够厚,吸收叙事弱
|
||||
return False
|
||||
if side == "short" and ask_vol < bid_vol * 0.8:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("v11 confirm entry: %s", e)
|
||||
|
||||
self._last_entry_time = current_time
|
||||
return True
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "time_stop_5m"
|
||||
|
||||
# 优势恢复出场(替代固定 0.05% TP)
|
||||
# long: 价格相对开仓上涨 edge_exit_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 = 100
|
||||
startup_candle_count = 1000
|
||||
|
||||
time5 = 5
|
||||
time15 = 15
|
||||
@@ -88,21 +88,14 @@ class ChanLun_BTC_15(IStrategy):
|
||||
time5 = 1440
|
||||
last_time = datetime.now()
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
dataframe = resampled_merge(dataframe, df_5m)
|
||||
dataframe = resampled_merge(dataframe, df_15m)
|
||||
return dataframe
|
||||
|
||||
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。
|
||||
|
||||
正式 Maker / L2 / Edge 采集已迁移到:
|
||||
nautilus_mm/ (NautilusTrader,独立 .venv)
|
||||
|
||||
本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。
|
||||
|
||||
运行 Nautilus:
|
||||
cd nautilus_mm && ./scripts/run_probe.sh
|
||||
|
||||
分析:
|
||||
cd nautilus_mm && ./scripts/analyze.sh
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.persistence import Trade, Order
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
from maker_edge_logger import MakerEdgeLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MakerEdgeProbe(IStrategy):
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "1m"
|
||||
can_short = True
|
||||
process_only_new_candles = False
|
||||
startup_candle_count = 60
|
||||
|
||||
minimal_roi = {"0": 0.01}
|
||||
stoploss = -0.002
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
|
||||
order_types = {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "market",
|
||||
"stoploss_on_exchange": False,
|
||||
}
|
||||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||||
|
||||
tick_size = 0.1
|
||||
quote_depth_ticks = 1
|
||||
max_leverage = 1.0
|
||||
stake_pct = 0.003
|
||||
max_hold_minutes = 5
|
||||
edge_exit_pct = 0.0002
|
||||
adverse_exit_pct = 0.0008
|
||||
cooldown_minutes = 5
|
||||
ob_levels = 10
|
||||
trade_lookback = 100
|
||||
ema_slope_thr = 0.0002
|
||||
book_sample_every_sec = 2.0
|
||||
|
||||
_logger: MakerEdgeLogger | None = None
|
||||
_last_mid: float | None = None
|
||||
_last_book_sample: float = 0.0
|
||||
_last_entry_time: Optional[datetime] = None
|
||||
_recent_high: float = 0.0
|
||||
_recent_low: float = 0.0
|
||||
_pending_quote_id: Optional[str] = None
|
||||
_fill_by_trade: dict[int, str] = {}
|
||||
|
||||
def bot_start(self, **kwargs) -> None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
self._fill_by_trade = {}
|
||||
logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir)
|
||||
|
||||
def _get_logger(self) -> MakerEdgeLogger:
|
||||
if self._logger is None:
|
||||
self._logger = MakerEdgeLogger(levels=self.ob_levels)
|
||||
return self._logger
|
||||
|
||||
def _fetch_trades(self, pair: str) -> list:
|
||||
try:
|
||||
ex = self.dp._exchange
|
||||
if ex is None:
|
||||
return []
|
||||
api = getattr(ex, "_api", None) or getattr(ex, "api", None)
|
||||
if api is None:
|
||||
return []
|
||||
return api.fetch_trades(pair, limit=self.trade_lookback) or []
|
||||
except Exception as e:
|
||||
logger.debug("fetch_trades failed: %s", e)
|
||||
return []
|
||||
|
||||
def _inventory(self) -> float:
|
||||
try:
|
||||
inv = 0.0
|
||||
for t in Trade.get_open_trades():
|
||||
amt = float(t.amount or 0.0)
|
||||
inv += -amt if t.is_short else amt
|
||||
return inv
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _market_state(self, pair: str) -> dict:
|
||||
state = {
|
||||
"trend_state": "UNKNOWN",
|
||||
"atr_pct": None,
|
||||
"volatility_regime": "UNKNOWN",
|
||||
"ema_slope": None,
|
||||
}
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is None or len(df) == 0:
|
||||
return state
|
||||
last = df.iloc[-1]
|
||||
slope = float(last.get("ema_slope") or 0.0)
|
||||
atr_pct = float(last.get("atr_pct") or 0.0)
|
||||
state["ema_slope"] = slope
|
||||
state["atr_pct"] = atr_pct
|
||||
if bool(last.get("trend_block", False)):
|
||||
state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN"
|
||||
else:
|
||||
state["trend_state"] = "RANGE"
|
||||
# 波动分位代理
|
||||
if "atr_pct" in df.columns:
|
||||
med = float(df["atr_pct"].tail(60).median() or 0)
|
||||
if atr_pct > med * 1.8:
|
||||
state["volatility_regime"] = "HIGH"
|
||||
elif atr_pct < med * 0.7:
|
||||
state["volatility_regime"] = "LOW"
|
||||
else:
|
||||
state["volatility_regime"] = "NORMAL"
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
def _snapshot(self, pair: str):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
trades = self._fetch_trades(pair)
|
||||
snap = MakerEdgeLogger.snapshot_from_orderbook(
|
||||
ob,
|
||||
levels=self.ob_levels,
|
||||
recent_trades=trades,
|
||||
last_mid=self._last_mid,
|
||||
liq_proxy_low=self._recent_low or None,
|
||||
liq_proxy_high=self._recent_high or None,
|
||||
)
|
||||
if snap.mid:
|
||||
self._last_mid = snap.mid
|
||||
return snap
|
||||
|
||||
def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
|
||||
if self.dp.runmode.value not in ("live", "dry_run"):
|
||||
return
|
||||
pair = self.config["exchange"]["pair_whitelist"][0]
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
tick = self.dp.ticker(pair) or {}
|
||||
last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid
|
||||
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df):
|
||||
self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last)
|
||||
self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last)
|
||||
|
||||
lg = self._get_logger()
|
||||
now = time.time()
|
||||
# 盘口历史(成交前5s恶化检测依赖此)
|
||||
if now - self._last_book_sample >= self.book_sample_every_sec:
|
||||
self._last_book_sample = now
|
||||
lg.record_book(snap, now=now)
|
||||
|
||||
if last:
|
||||
lg.update_paths(pair, last, now=now)
|
||||
except Exception as e:
|
||||
logger.warning("bot_loop_start probe error: %s", e)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
close, high, low = df["close"], df["high"], df["low"]
|
||||
volume = df["volume"].astype(float)
|
||||
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).replace(0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1)
|
||||
sell_vol = volume * (1.0 - buy_frac)
|
||||
buy_vol = volume * buy_frac
|
||||
df["sell_vol"] = sell_vol
|
||||
df["buy_vol"] = buy_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
vol_ma = volume.rolling(20, min_periods=5).mean()
|
||||
df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0)
|
||||
df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0)
|
||||
|
||||
drop = (close.shift(3) - low).clip(lower=0) / close.shift(3)
|
||||
up = (high - close.shift(3)).clip(lower=0) / close.shift(3)
|
||||
df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace(
|
||||
[np.inf, -np.inf], np.nan
|
||||
).fillna(0)
|
||||
|
||||
df["ema26"] = ta.EMA(df, timeperiod=26)
|
||||
df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0)
|
||||
df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr
|
||||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||||
df["atr_pct"] = (df["atr"] / close).fillna(0)
|
||||
|
||||
s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean()
|
||||
df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1))
|
||||
b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean()
|
||||
df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1))
|
||||
|
||||
df["roll_high"] = high.rolling(60, min_periods=10).max()
|
||||
df["roll_low"] = low.rolling(60, min_periods=10).min()
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
df = dataframe
|
||||
long_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_sell"].rolling(5).max().astype(bool)
|
||||
& (df["de_sell"] > 10)
|
||||
& df["sell_exhaust"]
|
||||
)
|
||||
short_c = (
|
||||
(~df["trend_block"])
|
||||
& df["shock_buy"].rolling(5).max().astype(bool)
|
||||
& (df["de_buy"] > 10)
|
||||
& df["buy_exhaust"]
|
||||
)
|
||||
df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp")
|
||||
df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp")
|
||||
return df
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
offset = self.quote_depth_ticks * self.tick_size
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
price = snap.best_bid - offset if side == "long" else snap.best_ask + offset
|
||||
state = self._market_state(pair)
|
||||
qid = self._get_logger().create_quote(
|
||||
pair=pair,
|
||||
side="bid" if side == "long" else "ask",
|
||||
quote_price=price,
|
||||
inventory=self._inventory(),
|
||||
snap=snap,
|
||||
reason=entry_tag or "entry",
|
||||
trade_id=trade.id if trade else None,
|
||||
state=state,
|
||||
)
|
||||
self._pending_quote_id = qid
|
||||
return price
|
||||
except Exception as e:
|
||||
logger.debug("custom_entry_price: %s", e)
|
||||
return proposed_rate - offset if side == "long" else proposed_rate + offset
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._last_entry_time:
|
||||
last = self._last_entry_time
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
if now - last < timedelta(minutes=self.cooldown_minutes):
|
||||
return False
|
||||
try:
|
||||
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)):
|
||||
return False
|
||||
snap = self._snapshot(pair)
|
||||
if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7:
|
||||
return False
|
||||
if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
self._last_entry_time = current_time
|
||||
return True
|
||||
|
||||
def check_entry_timeout(
|
||||
self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs
|
||||
) -> bool:
|
||||
"""超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。"""
|
||||
try:
|
||||
snap = self._snapshot(pair)
|
||||
self._get_logger().cancel_quote(
|
||||
quote_id=self._pending_quote_id,
|
||||
trade_id=trade.id,
|
||||
reason="entry_timeout",
|
||||
snap=snap,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("cancel_quote on timeout: %s", e)
|
||||
# False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True
|
||||
return False
|
||||
|
||||
def order_filled(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order: Order,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
try:
|
||||
lg = self._get_logger()
|
||||
# 入场成交
|
||||
if order.ft_order_side == trade.entry_side:
|
||||
snap = self._snapshot(pair)
|
||||
side = "short" if trade.is_short else "long"
|
||||
# 粗分 fill_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)
|
||||
@@ -0,0 +1,449 @@
|
||||
# --- Do not remove these libs ---
|
||||
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, stoploss_from_absolute
|
||||
from freqtrade.persistence import Trade
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# freqtrade trade -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies --timerange=20251201-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/Turtle_BTC.json -t 15m --pairs BTC/USDT:USDT --timerange=20240101-
|
||||
|
||||
|
||||
class Turtle_BTC(IStrategy):
|
||||
"""
|
||||
海龟交易法 (Turtle Trading) - 15m 优化版
|
||||
|
||||
相对经典日线参数,15m 上做了适配:
|
||||
- 通道周期拉长(约 1日 / 2日),降低噪音假突破
|
||||
- EMA200 趋势过滤:只做顺势方向
|
||||
- ADX 过滤:只在有趋势时开仓
|
||||
- 突破用「向上/向下穿越」,避免通道内反复信号
|
||||
- 单单元保证金上限,避免低波动时仓位占满账户
|
||||
- 系统2 优先、系统1 补漏(S1 带赢利跳过过滤)
|
||||
- trade_side 可限制只做多/只做空(默认 short,适配近段下跌市)
|
||||
"""
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "15m"
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
# 需覆盖 S2 入场周期 + EMA200
|
||||
startup_candle_count = 250
|
||||
|
||||
minimal_roi = {"0": 100}
|
||||
stoploss = -0.99
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
exit_profit_only = False
|
||||
ignore_roi_if_entry_signal = True
|
||||
|
||||
position_adjustment_enable = True
|
||||
max_entry_position_adjustment = 3 # 首仓 + 3 加仓 = 4 单元
|
||||
|
||||
# ---- 15m 适配后的默认周期(约 1日 / 2日)----
|
||||
# 96 根 15m ≈ 1 天;192 根 ≈ 2 天
|
||||
entry_period_s1 = IntParameter(48, 144, default=96, space="buy", optimize=True)
|
||||
exit_period_s1 = IntParameter(24, 96, default=48, space="sell", optimize=True)
|
||||
entry_period_s2 = IntParameter(120, 288, default=192, space="buy", optimize=True)
|
||||
exit_period_s2 = IntParameter(48, 144, default=96, space="sell", optimize=True)
|
||||
atr_period = IntParameter(14, 40, default=20, space="buy", optimize=False)
|
||||
stop_atr_mult = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="sell", optimize=True)
|
||||
pyramid_atr_mult = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
|
||||
risk_per_unit = DecimalParameter(0.005, 0.02, default=0.01, decimals=3, space="buy", optimize=False)
|
||||
adx_threshold = IntParameter(15, 35, default=20, space="buy", optimize=True)
|
||||
# 单单元保证金占可用资金上限(防止 15m 低波动时打满仓)
|
||||
max_unit_stake_pct = DecimalParameter(0.15, 0.40, default=0.25, decimals=2, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
use_s1_win_skip = True
|
||||
use_system1 = True
|
||||
use_system2 = True
|
||||
# 趋势 / 强度过滤
|
||||
use_ema_filter = True
|
||||
use_adx_filter = True
|
||||
# None=双向;可用 "long" / "short" 限制单边(勿用单段行情曲线拟合)
|
||||
trade_side: Optional[str] = None
|
||||
ema_period = 200
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
ep1 = int(self.entry_period_s1.value)
|
||||
xp1 = int(self.exit_period_s1.value)
|
||||
ep2 = int(self.entry_period_s2.value)
|
||||
xp2 = int(self.exit_period_s2.value)
|
||||
atr_n = int(self.atr_period.value)
|
||||
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=atr_n)
|
||||
dataframe["n"] = dataframe["atr"]
|
||||
dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.ema_period)
|
||||
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
|
||||
# 唐奇安通道(shift 1 防 lookahead)
|
||||
dataframe["dc_high_s1"] = dataframe["high"].rolling(ep1).max().shift(1)
|
||||
dataframe["dc_low_s1"] = dataframe["low"].rolling(ep1).min().shift(1)
|
||||
dataframe["dc_exit_high_s1"] = dataframe["high"].rolling(xp1).max().shift(1)
|
||||
dataframe["dc_exit_low_s1"] = dataframe["low"].rolling(xp1).min().shift(1)
|
||||
|
||||
dataframe["dc_high_s2"] = dataframe["high"].rolling(ep2).max().shift(1)
|
||||
dataframe["dc_low_s2"] = dataframe["low"].rolling(ep2).min().shift(1)
|
||||
dataframe["dc_exit_high_s2"] = dataframe["high"].rolling(xp2).max().shift(1)
|
||||
dataframe["dc_exit_low_s2"] = dataframe["low"].rolling(xp2).min().shift(1)
|
||||
|
||||
# 穿越突破(只在刚突破那根触发)
|
||||
dataframe["break_up_s1"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s1"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s1"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s1"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_up_s2"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s2"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s2"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s2"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s2"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s2"].shift(1))
|
||||
)
|
||||
|
||||
# 顺势过滤:价格相对 EMA200
|
||||
dataframe["trend_long"] = dataframe["close"] > dataframe["ema_trend"]
|
||||
dataframe["trend_short"] = dataframe["close"] < dataframe["ema_trend"]
|
||||
dataframe["adx_ok"] = dataframe["adx"] >= float(self.adx_threshold.value)
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * 0.8
|
||||
|
||||
if self.use_s1_win_skip:
|
||||
dataframe["skip_s1_long"] = self._s1_skip_mask(
|
||||
dataframe, long=True, exit_col="dc_exit_low_s1"
|
||||
)
|
||||
dataframe["skip_s1_short"] = self._s1_skip_mask(
|
||||
dataframe, long=False, exit_col="dc_exit_high_s1"
|
||||
)
|
||||
else:
|
||||
dataframe["skip_s1_long"] = False
|
||||
dataframe["skip_s1_short"] = False
|
||||
|
||||
return dataframe
|
||||
|
||||
@staticmethod
|
||||
def _s1_skip_mask(dataframe: DataFrame, long: bool, exit_col: str) -> pd.Series:
|
||||
"""系统1:上次同向突破盈利则跳过下一次。"""
|
||||
n = len(dataframe)
|
||||
skip = np.zeros(n, dtype=bool)
|
||||
in_trade = False
|
||||
entry_price = 0.0
|
||||
last_was_win = False
|
||||
closes = dataframe["close"].to_numpy()
|
||||
breaks = (dataframe["break_up_s1"] if long else dataframe["break_dn_s1"]).fillna(False).to_numpy()
|
||||
exits = dataframe[exit_col].to_numpy()
|
||||
|
||||
for i in range(n):
|
||||
if np.isnan(exits[i]) or np.isnan(closes[i]):
|
||||
continue
|
||||
if in_trade:
|
||||
hit_exit = closes[i] < exits[i] if long else closes[i] > exits[i]
|
||||
if hit_exit:
|
||||
pnl = (closes[i] - entry_price) if long else (entry_price - closes[i])
|
||||
last_was_win = pnl > 0
|
||||
in_trade = False
|
||||
elif breaks[i]:
|
||||
if last_was_win:
|
||||
skip[i] = True
|
||||
last_was_win = False
|
||||
else:
|
||||
in_trade = True
|
||||
entry_price = closes[i]
|
||||
return pd.Series(skip, index=dataframe.index)
|
||||
|
||||
def _entry_filters(self, dataframe: DataFrame, long: bool) -> pd.Series:
|
||||
base = (
|
||||
(dataframe["volume"] > 0)
|
||||
& dataframe["atr"].notna()
|
||||
& (dataframe["atr"] > 0)
|
||||
& dataframe["vol_ok"]
|
||||
)
|
||||
if self.use_ema_filter:
|
||||
base &= dataframe["trend_long"] if long else dataframe["trend_short"]
|
||||
if self.use_adx_filter:
|
||||
base &= dataframe["adx_ok"]
|
||||
return base
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
allow_long = self.trade_side in (None, "long")
|
||||
allow_short = self.trade_side in (None, "short")
|
||||
long_base = self._entry_filters(dataframe, long=True) if allow_long else False
|
||||
short_base = self._entry_filters(dataframe, long=False) if allow_short else False
|
||||
|
||||
# 系统2优先(更稳),系统1补漏
|
||||
if self.use_system2:
|
||||
if allow_long:
|
||||
long_s2 = long_base & dataframe["break_up_s2"]
|
||||
dataframe.loc[long_s2, ["enter_long", "enter_tag"]] = (1, "turtle_s2_long")
|
||||
if allow_short:
|
||||
short_s2 = short_base & dataframe["break_dn_s2"]
|
||||
dataframe.loc[short_s2, ["enter_short", "enter_tag"]] = (1, "turtle_s2_short")
|
||||
|
||||
if self.use_system1:
|
||||
if allow_long:
|
||||
long_s1 = (
|
||||
long_base & dataframe["break_up_s1"]
|
||||
& (~dataframe["skip_s1_long"])
|
||||
& (dataframe["enter_long"] != 1)
|
||||
)
|
||||
dataframe.loc[long_s1, ["enter_long", "enter_tag"]] = (1, "turtle_s1_long")
|
||||
if allow_short:
|
||||
short_s1 = (
|
||||
short_base & dataframe["break_dn_s1"]
|
||||
& (~dataframe["skip_s1_short"])
|
||||
& (dataframe["enter_short"] != 1)
|
||||
)
|
||||
dataframe.loc[short_s1, ["enter_short", "enter_tag"]] = (1, "turtle_s1_short")
|
||||
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""按入场系统使用对应退出通道;用 close 与 current_rate 双确认。"""
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
tag = trade.enter_tag or ""
|
||||
price = min(float(last["close"]), current_rate) if not trade.is_short else max(float(last["close"]), current_rate)
|
||||
|
||||
if trade.is_short:
|
||||
if "s1" in tag and price > float(last["dc_exit_high_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price > float(last["dc_exit_high_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
else:
|
||||
if "s1" in tag and price < float(last["dc_exit_low_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price < float(last["dc_exit_low_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
return None
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return proposed_stake
|
||||
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or current_rate <= 0:
|
||||
return proposed_stake
|
||||
|
||||
wallets = self.wallets
|
||||
available = wallets.get_total(self.config["stake_currency"]) if wallets else max_stake
|
||||
risk_amount = available * float(self.risk_per_unit.value)
|
||||
stop_dist = float(self.stop_atr_mult.value) * atr
|
||||
notional = risk_amount * current_rate / stop_dist
|
||||
stake = notional / max(leverage, 1.0)
|
||||
|
||||
# 单单元上限,避免低波动打满仓
|
||||
stake = min(stake, available * float(self.max_unit_stake_pct.value))
|
||||
|
||||
if min_stake is not None:
|
||||
stake = max(stake, min_stake)
|
||||
stake = min(stake, max_stake)
|
||||
return stake
|
||||
|
||||
def adjust_trade_position(
|
||||
self,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
current_entry_rate: float,
|
||||
current_exit_rate: float,
|
||||
current_entry_profit: float,
|
||||
current_exit_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
"""每朝有利方向 0.5N 加仓,最多 4 单元;有挂单时不加。"""
|
||||
if trade.has_open_orders:
|
||||
return None
|
||||
if trade.nr_of_successful_entries >= (1 + self.max_entry_position_adjustment):
|
||||
return None
|
||||
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0:
|
||||
return None
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
if entry_n is None:
|
||||
entry_n = atr
|
||||
trade.set_custom_data("entry_n", entry_n)
|
||||
|
||||
last_entry_price = trade.get_custom_data("last_entry_price")
|
||||
if last_entry_price is None:
|
||||
last_entry_price = trade.open_rate
|
||||
trade.set_custom_data("last_entry_price", last_entry_price)
|
||||
|
||||
# 已规划的下一单元序号(从第 2 单元起)
|
||||
next_unit = trade.nr_of_successful_entries + 1
|
||||
step = float(self.pyramid_atr_mult.value) * float(entry_n)
|
||||
# 相对首仓(或记录的单元锚定价)计算阈值,避免 after_fill 用均价漂移
|
||||
anchor = float(trade.get_custom_data("unit1_price") or trade.open_rate)
|
||||
# 第 n 单元触发价 = 首仓 ± (n-1)*0.5N
|
||||
offset = (next_unit - 1) * step
|
||||
|
||||
if trade.is_short:
|
||||
trigger = anchor - offset
|
||||
if current_rate > trigger:
|
||||
return None
|
||||
else:
|
||||
trigger = anchor + offset
|
||||
if current_rate < trigger:
|
||||
return None
|
||||
|
||||
stake = self.custom_stake_amount(
|
||||
pair=trade.pair,
|
||||
current_time=current_time,
|
||||
current_rate=current_rate,
|
||||
proposed_stake=max_stake,
|
||||
min_stake=min_stake,
|
||||
max_stake=max_stake,
|
||||
leverage=trade.leverage,
|
||||
entry_tag=trade.enter_tag,
|
||||
side="short" if trade.is_short else "long",
|
||||
)
|
||||
if stake <= 0:
|
||||
return None
|
||||
|
||||
return stake, f"turtle_pyramid_{next_unit}"
|
||||
|
||||
def custom_stoploss(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
after_fill: bool,
|
||||
**kwargs,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
止损 = 最近一单元入场价 ± 2N。
|
||||
加仓后整体移到新单元的 2N(海龟原版)。
|
||||
"""
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
|
||||
if after_fill:
|
||||
filled = trade.nr_of_successful_entries
|
||||
if filled <= 1:
|
||||
trade.set_custom_data("unit1_price", current_rate)
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
if atr > 0:
|
||||
trade.set_custom_data("entry_n", atr)
|
||||
else:
|
||||
# 加仓:用本次成交价作为最新单元锚点
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
n = float(entry_n) if entry_n is not None else atr
|
||||
if n <= 0:
|
||||
return None
|
||||
|
||||
last_entry = trade.get_custom_data("last_entry_price") or trade.open_rate
|
||||
mult = float(self.stop_atr_mult.value)
|
||||
|
||||
if trade.is_short:
|
||||
stop_price = float(last_entry) + mult * n
|
||||
else:
|
||||
stop_price = float(last_entry) - mult * n
|
||||
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
# 0 表示止损已在价格不利侧之外,保持不变
|
||||
return sl if sl > 0 else None
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return False
|
||||
row = dataframe.iloc[-1]
|
||||
if pd.isna(row["atr"]) or row["atr"] <= 0:
|
||||
return False
|
||||
if self.trade_side is not None and side != self.trade_side:
|
||||
return False
|
||||
if self.use_ema_filter:
|
||||
if side == "long" and not bool(row["trend_long"]):
|
||||
return False
|
||||
if side == "short" and not bool(row["trend_short"]):
|
||||
return False
|
||||
if self.use_adx_filter and not bool(row["adx_ok"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.lev, max_leverage)
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN (live alias of V1_BASELINE)
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range 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)
|
||||
@@ -0,0 +1,197 @@
|
||||
# --- 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
|
||||
@@ -0,0 +1,42 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
ARCHIVED — LPS 研究分支已冻结,禁止用于 dry-run / 生产。
|
||||
|
||||
见:
|
||||
user_data/Chan/research/SYSTEM_STATUS.md
|
||||
user_data/Chan/research/lps_v1_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v1_1_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v2_failed/REJECT.md
|
||||
user_data/Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py
|
||||
|
||||
Baseline: Wyckoff_BTC_V1_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
|
||||
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user