Merge branch 'chan' of ssh://git.jackyu66.com:2222/jack/chan into chan
This commit is contained in:
+52
-21
@@ -15,10 +15,12 @@ class ChanBI():
|
|||||||
self.sure_time = None
|
self.sure_time = None
|
||||||
self.klc_list = []
|
self.klc_list = []
|
||||||
self.klc_list.append(klc)
|
self.klc_list.append(klc)
|
||||||
|
self._klc_idx = {klc.index}
|
||||||
self.end_time = klc.end_time
|
self.end_time = klc.end_time
|
||||||
self.start_time = klc.start_time
|
self.start_time = klc.start_time
|
||||||
self.macd_hist = 0
|
self._macd_hist = 0
|
||||||
self.macd_div = 0
|
self._macd_div = 0
|
||||||
|
self._macd_dirty = False
|
||||||
self.seg = None
|
self.seg = None
|
||||||
self.height = 0
|
self.height = 0
|
||||||
self.width = 0
|
self.width = 0
|
||||||
@@ -33,26 +35,57 @@ class ChanBI():
|
|||||||
def set_seg(self, seg):
|
def set_seg(self, seg):
|
||||||
self.seg = seg
|
self.seg = seg
|
||||||
self.seg_index = len(seg.bi_list)-1
|
self.seg_index = len(seg.bi_list)-1
|
||||||
|
# macd_hist / macd_div 改为惰性。原来 add_klc 每加一根 KLC 就把整笔的
|
||||||
|
# 所有 KLU 重新累加一遍,是 O(k²);而这两个值只有背驰判定(bsp.py)在读,
|
||||||
|
# lean 模式下 bsp 根本不算,等于全程白算。这里只标脏,取值时才算。
|
||||||
|
# 语义不变:klc_list 只增不减(set_start_klc 会重置并同时清脏),
|
||||||
|
# dir 在 klc 累加期间固定,所以延后到读取时算与逐次重算结果相同。
|
||||||
|
@property
|
||||||
|
def macd_hist(self):
|
||||||
|
if self._macd_dirty:
|
||||||
|
self.cal_macdhist()
|
||||||
|
return self._macd_hist
|
||||||
|
|
||||||
|
@macd_hist.setter
|
||||||
|
def macd_hist(self, v):
|
||||||
|
self._macd_hist = v
|
||||||
|
self._macd_dirty = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def macd_div(self):
|
||||||
|
self.cal_macd_div()
|
||||||
|
return self._macd_div
|
||||||
|
|
||||||
|
@macd_div.setter
|
||||||
|
def macd_div(self, v):
|
||||||
|
self._macd_div = v
|
||||||
|
|
||||||
def set_macdhist(self, macd_hist):
|
def set_macdhist(self, macd_hist):
|
||||||
self.macd_hist = macd_hist
|
self.macd_hist = macd_hist
|
||||||
def set_macd_div(self, macd_div):
|
def set_macd_div(self, macd_div):
|
||||||
self.macd_div = macd_div
|
self.macd_div = macd_div
|
||||||
def cal_macd_div(self):
|
def cal_macd_div(self):
|
||||||
self.macd_div = 0.0
|
self._macd_div = 0.0
|
||||||
if self.pre and self.pre.pre:
|
if self.pre and self.pre.pre:
|
||||||
if self.pre.pre.macd_hist == 0:
|
if self.pre.pre.macd_hist == 0:
|
||||||
self.macd_div = 0.0
|
self._macd_div = 0.0
|
||||||
else:
|
else:
|
||||||
self.macd_div = self.macd_hist / self.pre.pre.macd_hist
|
self._macd_div = self.macd_hist / self.pre.pre.macd_hist
|
||||||
#print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div)
|
#print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div)
|
||||||
def cal_macdhist(self):
|
def cal_macdhist(self):
|
||||||
self.macd_hist = 0
|
self._macd_dirty = False
|
||||||
|
self._macd_hist = 0
|
||||||
|
up = self.dir == Chan_BI_DIR.UP
|
||||||
|
acc = 0
|
||||||
for klc in self.klc_list:
|
for klc in self.klc_list:
|
||||||
for klu in klc.klu_list:
|
for klu in klc.klu_list:
|
||||||
if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
|
h = klu.macdhist
|
||||||
self.macd_hist += klu.macdhist
|
if up:
|
||||||
if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
|
if h > 0:
|
||||||
self.macd_hist -= klu.macdhist
|
acc += h
|
||||||
|
elif h < 0:
|
||||||
|
acc -= h
|
||||||
|
self._macd_hist = acc
|
||||||
def check_bi_zs_overlap(self):
|
def check_bi_zs_overlap(self):
|
||||||
if self.next and self.next.next:
|
if self.next and self.next.next:
|
||||||
if self.dir == Chan_BI_DIR.UP:
|
if self.dir == Chan_BI_DIR.UP:
|
||||||
@@ -95,6 +128,10 @@ class ChanBI():
|
|||||||
self.start_klc = klc
|
self.start_klc = klc
|
||||||
self.klc_list = []
|
self.klc_list = []
|
||||||
self.klc_list.append(klc)
|
self.klc_list.append(klc)
|
||||||
|
# klc_list 被整个换掉,去重集合与惰性缓存都要跟着重置,
|
||||||
|
# 否则后续 add_klc 会以为旧下标还在里面而漏加
|
||||||
|
self._klc_idx = {klc.index}
|
||||||
|
self._macd_dirty = True
|
||||||
self.high = klc.high
|
self.high = klc.high
|
||||||
self.low = klc.low
|
self.low = klc.low
|
||||||
self.dir = ddir
|
self.dir = ddir
|
||||||
@@ -103,20 +140,14 @@ class ChanBI():
|
|||||||
def set_next(self, bi):
|
def set_next(self, bi):
|
||||||
self.next = bi
|
self.next = bi
|
||||||
def add_klc(self, klc):
|
def add_klc(self, klc):
|
||||||
added = False
|
# 去重原本是对 klc_list 线性扫描,配合下面每次全量重算的 macdhist,
|
||||||
if len(self.klc_list) > 0:
|
# 让「往一笔里加 k 根 KLC」变成 O(k²)。改用下标集合,O(1)。
|
||||||
for index in range(0, len(self.klc_list)):
|
if klc.index not in self._klc_idx:
|
||||||
if self.klc_list[index].index == klc.index:
|
self._klc_idx.add(klc.index)
|
||||||
added = True
|
|
||||||
break
|
|
||||||
if not added:
|
|
||||||
self.klc_list.append(klc)
|
self.klc_list.append(klc)
|
||||||
#print(self.start_time, klc.start_time)
|
|
||||||
#print(klc.end_time, klc.index)
|
|
||||||
self.end_klc = klc
|
self.end_klc = klc
|
||||||
self.end_time = klc.klu_list[-1].time
|
self.end_time = klc.klu_list[-1].time
|
||||||
self.cal_macdhist()
|
self._macd_dirty = True
|
||||||
self.cal_macd_div()
|
|
||||||
def append_klc_list(self, klc_list):
|
def append_klc_list(self, klc_list):
|
||||||
self.klc_list.append(klc_list)
|
self.klc_list.append(klc_list)
|
||||||
def get_decimal(self, value):
|
def get_decimal(self, value):
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ class IndicatorsBuilderMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def add_indicators(self, df):
|
def add_indicators(self, df):
|
||||||
|
"""算指标并一次性挂到 df 上。
|
||||||
|
|
||||||
|
这里不逐列 `df['x'] = ...`:那样每一列都触发一次 BlockManager 插入,
|
||||||
|
30 多列的开销比全部 TA 计算本身还大(2001 行实测 TA 合计 2.5ms,
|
||||||
|
逐列赋值 3.6ms)。增量路径每根都要走一遍,这笔开销是白付的。
|
||||||
|
"""
|
||||||
fast = 26
|
fast = 26
|
||||||
slow = 52
|
slow = 52
|
||||||
period = 9
|
period = 9
|
||||||
@@ -75,40 +81,43 @@ class IndicatorsBuilderMixin:
|
|||||||
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
|
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
|
||||||
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
|
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
|
||||||
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
||||||
df['bb2633upper'] = bb2633['upperband']
|
cols = {
|
||||||
df['bb2633lower'] = bb2633['lowerband']
|
'bb2633upper': bb2633['upperband'],
|
||||||
df['bbp2633'] = bbp2633
|
'bb2633lower': bb2633['lowerband'],
|
||||||
df['bb2633middle'] = bb2633['middleband']
|
'bbp2633': bbp2633,
|
||||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
'bb2633middle': bb2633['middleband'],
|
||||||
df['bbup365'] = bb365['upperband']
|
'atr': ta.ATR(df, timeperiod=14),
|
||||||
df['bblow365'] = bb365['lowerband']
|
'bbup365': bb365['upperband'],
|
||||||
df['bbp365'] = bbp365
|
'bblow365': bb365['lowerband'],
|
||||||
df['bbup120'] = bb120['upperband']
|
'bbp365': bbp365,
|
||||||
df['bblow120'] = bb120['lowerband']
|
'bbup120': bb120['upperband'],
|
||||||
df['bbp120'] = bbp120
|
'bblow120': bb120['lowerband'],
|
||||||
df['bbup30'] = bb30['upperband']
|
'bbp120': bbp120,
|
||||||
df['bblow30'] = bb30['lowerband']
|
'bbup30': bb30['upperband'],
|
||||||
df['bbmiddle30'] = bb30_middle # 添加bb30中轨
|
'bblow30': bb30['lowerband'],
|
||||||
df['bbp30'] = bbp30
|
'bbmiddle30': bb30_middle,
|
||||||
df['bbup302'] = bb302['upperband']
|
'bbp30': bbp30,
|
||||||
df['bblow302'] = bb302['lowerband']
|
'bbup302': bb302['upperband'],
|
||||||
df['bbp302'] = bbp302
|
'bblow302': bb302['lowerband'],
|
||||||
df['macd'] = macd['macd']
|
'bbp302': bbp302,
|
||||||
df['macdsignal'] = macd['macdsignal']
|
'macd': macd['macd'],
|
||||||
df['macdhist'] = macd['macdhist']
|
'macdsignal': macd['macdsignal'],
|
||||||
df['ema5'] = ta.EMA(df, timeperiod=5)
|
'macdhist': macd['macdhist'],
|
||||||
df['ema10'] = ta.EMA(df, timeperiod=10)
|
}
|
||||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
for _p, _n in ((5, 'ema5'), (10, 'ema10'), (24, 'ema24'), (52, 'ema52'),
|
||||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
(104, 'ema104'), (156, 'ema156'), (208, 'ema208'),
|
||||||
df['ema104'] = ta.EMA(df, timeperiod=104)
|
(26, 'ema26'), (13, 'ema13'), (7, 'ema7')):
|
||||||
df['ema156'] = ta.EMA(df, timeperiod=156)
|
cols[_n] = ta.EMA(df, timeperiod=_p)
|
||||||
df['ema208'] = ta.EMA(df, timeperiod=208)
|
cols['rsi'] = ta.RSI(df, timeperiod=14)
|
||||||
df['ema26'] = ta.EMA(df, timeperiod=26)
|
cols['volume_ratio'] = self.cal_volume_ratio(df)
|
||||||
df['ema13'] = ta.EMA(df, timeperiod=13)
|
|
||||||
df['ema7'] = ta.EMA(df, timeperiod=7)
|
# 重复调用(增量路径每根都会)时先摘掉旧列,否则 concat 出重名列。
|
||||||
df['rsi'] = ta.RSI(df, timeperiod=14)
|
# 摘掉再接回末尾,列序与逐列覆盖的结果一致。
|
||||||
df['volume_ratio'] = self.cal_volume_ratio(df)
|
new = pd.DataFrame(cols, index=df.index)
|
||||||
return df
|
dup = [c for c in new.columns if c in df.columns]
|
||||||
|
if dup:
|
||||||
|
df = df.drop(columns=dup)
|
||||||
|
return pd.concat([df, new], axis=1)
|
||||||
|
|
||||||
def get_ema_state(self, dataframe):
|
def get_ema_state(self, dataframe):
|
||||||
klu_list = self.get_klu_list(dataframe)
|
klu_list = self.get_klu_list(dataframe)
|
||||||
|
|||||||
@@ -128,23 +128,28 @@ class KlineBuilderMixin:
|
|||||||
return Chan_FX_TYPE.UNKNOWN
|
return Chan_FX_TYPE.UNKNOWN
|
||||||
|
|
||||||
def check_fx_pattern(self, klc):
|
def check_fx_pattern(self, klc):
|
||||||
|
"""给分型前后三根 KLC 的裸 K 打 pattern 标记。
|
||||||
|
|
||||||
|
原本还会把 `klu.to_string()` 拼成一个字符串——那是给下面那行注释掉的
|
||||||
|
print 用的,拼完就丢。它在 cal_bi_list 的内层,2000 根上要跑近三万次
|
||||||
|
f-string + 六万次 enum 格式化,是纯废动作,已删。
|
||||||
|
|
||||||
|
`klu.pattern` 只被 cal_klu_pattern 自己的双 K / 三 K 判定读,
|
||||||
|
不出这个模块,也不进 web 序列化。所以 lean 下整个调用可跳。
|
||||||
|
"""
|
||||||
|
if getattr(self, 'lean', False):
|
||||||
|
return
|
||||||
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
|
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
|
||||||
|
|
||||||
self.cal_klu_pattern(klu_list)
|
self.cal_klu_pattern(klu_list)
|
||||||
p = ""
|
|
||||||
for klu in klu_list:
|
|
||||||
p += klu.to_string()
|
|
||||||
#print(p)
|
|
||||||
|
|
||||||
def cal_volume_ratio(self, dataframe, window=10):
|
def cal_volume_ratio(self, dataframe, window=10):
|
||||||
df = dataframe.copy()
|
"""当根量 / 前 window 根均量。前 window-1 根无基准,填 1.0。
|
||||||
# 计算过去N根K线的平均成交量
|
|
||||||
df['avg_volume'] = df['volume'].rolling(window=window).mean()
|
原写法先 `dataframe.copy()` 再挂两列——为算一列 rolling 复制了整张
|
||||||
# 计算量比
|
四十列的表。直接在 Series 上算,结果逐值相同。
|
||||||
df['volume_ratio'] = df['volume'] / df['avg_volume']
|
"""
|
||||||
# 填充缺失值(前N根K线)
|
vol = dataframe['volume']
|
||||||
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
|
return (vol / vol.rolling(window=window).mean()).fillna(1.0).rename('volume_ratio')
|
||||||
return df['volume_ratio']
|
|
||||||
|
|
||||||
def cal_kl_data(self, dataframe:DataFrame):
|
def cal_kl_data(self, dataframe:DataFrame):
|
||||||
"""按行构造 KLU 链。
|
"""按行构造 KLU 链。
|
||||||
@@ -249,7 +254,12 @@ class KlineBuilderMixin:
|
|||||||
for klu in klu_list:
|
for klu in klu_list:
|
||||||
self._push_klu_into_klc_list(klc_list, klu, last_klu)
|
self._push_klu_into_klc_list(klc_list, klu, last_klu)
|
||||||
last_klu = klu
|
last_klu = klu
|
||||||
klc_list = self.cal_trend(klc_list)
|
# cal_trend 只产出 klc.trend,而全仓只有它自己(经 prev_klcs 读自身序列
|
||||||
|
# 状态)、一个 __str__ 和 web 的 klc_trend 图层消费它——笔与中枢不读。
|
||||||
|
# 增量路径的 klc 其 trend 恒为 UNKNOWN 却与批量构建逐字段相同,是实证。
|
||||||
|
# 所以 lean 下可跳;web 走非 lean,图层不受影响。
|
||||||
|
if not getattr(self, 'lean', False):
|
||||||
|
klc_list = self.cal_trend(klc_list)
|
||||||
return klc_list
|
return klc_list
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+203
-7
@@ -396,6 +396,56 @@ vr10 中位 1.76 vs 无前序 1.46)。2×2 交叉,最好的一档是两者
|
|||||||
|
|
||||||
> 尚未做的:把量比做成硬过滤(如砍掉最高四分位)要付 25% 的笔数,
|
> 尚未做的:把量比做成硬过滤(如砍掉最高四分位)要付 25% 的笔数,
|
||||||
> 而 1m 本来就稀疏(10 币约 2.5 笔/天)。**先当仓位权重用,别当开关。**
|
> 而 1m 本来就稀疏(10 币约 2.5 笔/天)。**先当仓位权重用,别当开关。**
|
||||||
|
> (已由 step51 §3.39 定论:该做成开关,不是权重。)
|
||||||
|
|
||||||
|
**❌ 但持仓中放量**不是**该平仓的信号——恰恰相反(step52)**
|
||||||
|
|
||||||
|
用户接着问:既然入场撞上放量是接盘,那持仓中出现放量根是不是也说明这一波
|
||||||
|
走完了、该直接平掉?测下来是反的,而且反得比入场那条还干净。
|
||||||
|
|
||||||
|
规则:持仓期间任一根 `vr60 ≥ 阈值` 就收盘市价平掉(taker)。同根内优先级
|
||||||
|
止损(盘中)> 目标(盘中)> 放量平仓(收盘)。10 币 3941 笔:
|
||||||
|
|
||||||
|
| 全样本 | 毛R | 净均R | R夏普 | PF | 余量bp | 胜率 | 均持仓 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **基线(不看量)** | **1.068** | **0.940** | **0.627** | **3.80** | **18.45** | 71.7% | 30.0 |
|
||||||
|
| vr60≥3 就平 | 0.617 | 0.475 | 0.459 | 2.81 | 7.81 | 69.7% | 11.1 |
|
||||||
|
| vr60≥5 就平 | 0.858 | 0.723 | 0.568 | 3.37 | 12.85 | 71.7% | 19.8 |
|
||||||
|
| vr60≥8 就平 | 1.009 | 0.878 | 0.617 | 3.69 | 16.68 | 71.9% | 26.3 |
|
||||||
|
|
||||||
|
**阈值越高(触发越少)越接近基线,单调——这条曲线的最优点是「永不触发」。**
|
||||||
|
样本内外一致。「浮盈才平」的变体把胜率抬到 74.4%(基线 71.7%)但毛R 掉到
|
||||||
|
0.655,是过早止盈的教科书特征:**胜率上升、期望下降**。
|
||||||
|
|
||||||
|
根因在这里。vr60≥5 触发的 1769 笔(45%),若不平,最终:
|
||||||
|
|
||||||
|
| | 占比 | 净均R | 对照:全体基线 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 走到 8ATR 止盈 | **45.7%** | +2.640 | 30.1% |
|
||||||
|
| 最终止损 | **16.6%** | −0.422 | 33.1% |
|
||||||
|
| 48 根超时 | 37.8% | +1.283 | 36.8% |
|
||||||
|
|
||||||
|
**持仓中的放量根标记的是最好的那批单子,不是最差的。** 在那里平掉每笔让出
|
||||||
|
+0.484R。幸存者偏差已控(触发组平均已活 9.6 根):按基线持仓 ≥K 根分层后
|
||||||
|
**5/5 档全部同向**,放量组止盈率约为无量组两倍——
|
||||||
|
|
||||||
|
| 存活≥K根 | 放量组止盈率 | 无量组止盈率 | 放量组净均R | 无量组净均R | R差 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 5 | 41.2% | 19.8% | 1.443 | 0.658 | +0.785 |
|
||||||
|
| 10 | 42.7% | 21.4% | 1.599 | 0.898 | +0.700 |
|
||||||
|
| 20 | 34.4% | 20.3% | 1.558 | 1.106 | +0.452 |
|
||||||
|
| 30 | 24.7% | 15.5% | 1.469 | 1.188 | +0.281 |
|
||||||
|
|
||||||
|
**同一个事件,入场时是负的、持仓时是正的,区别只在你站在它的哪一边。**
|
||||||
|
入场那根放量你是买方,付的是资金已经推到的价;持仓中那根放量是资金来接你的
|
||||||
|
货,那是兑现不是离场。用户「有资金的趋势才是好趋势」的直觉**成立,
|
||||||
|
但作用点在持仓期而非入场点**。
|
||||||
|
|
||||||
|
> 未测的建设性方向:既然持仓中放量预示止盈率翻倍,或许该**延长 runner 目标 /
|
||||||
|
> 加仓**而不是平仓。注意基线已经隐含吃到了这部分(它就是拿着不动),
|
||||||
|
> 所以要多拿必须主动改目标位,**这是新参数,要重新回测**。
|
||||||
|
> R差 随 K 递减(0.785 → 0.281)说明越晚出现的放量根剩余空间越小,
|
||||||
|
> 若要做,触发窗口应偏前段。
|
||||||
|
|
||||||
**TRX 应从币池剔除**:实盘口径 208 天只有 **5 笔**,ATR 门控几乎全刷掉
|
**TRX 应从币池剔除**:实盘口径 208 天只有 **5 笔**,ATR 门控几乎全刷掉
|
||||||
(与 §5.3「2026 只剩 4.2%」一致)。实际可用是 10 个币,不是 11 个。
|
(与 §5.3「2026 只剩 4.2%」一致)。实际可用是 10 个币,不是 11 个。
|
||||||
@@ -715,6 +765,11 @@ PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级
|
|||||||
但兑现方式不是仓位。(可能的出路是对这批放宽 ATR 门控,即多做几笔而非
|
但兑现方式不是仓位。(可能的出路是对这批放宽 ATR 门控,即多做几笔而非
|
||||||
每笔做大,那条仍未测。)
|
每笔做大,那条仍未测。)
|
||||||
|
|
||||||
|
> ⚠️ **本节推荐的开关(`vr60 ≥ 4 不做`)已被 §3.391 取代。**
|
||||||
|
> `mom60 ≥ 7 不做` 在盈亏平衡滑点、R夏普、回撤、总R、保留笔数五项上全面更优。
|
||||||
|
> 本节关于**「开关优于权重」「前序不该进仓位」「优势形态是削尾」**三条方法论
|
||||||
|
> 结论仍然成立,只是最佳的那个开关换了个因子。
|
||||||
|
|
||||||
**② 量因子「不做」优于「少做」。** 加权版 R夏普 0.442、硬砍版 0.496;
|
**② 量因子「不做」优于「少做」。** 加权版 R夏普 0.442、硬砍版 0.496;
|
||||||
回撤 12.7 vs 11.2;峰值并发 7.98 vs **6.00**。硬砍付出笔数 −22%
|
回撤 12.7 vs 11.2;峰值并发 7.98 vs **6.00**。硬砍付出笔数 −22%
|
||||||
(3941 → 3061)、总R −10%,换来回撤 −35% 和并发 −14%。
|
(3941 → 3061)、总R −10%,换来回撤 −35% 和并发 −14%。
|
||||||
@@ -731,6 +786,88 @@ PF 从 1.59 提到 1.85(§4 step30)。**两个过滤器的相对价值随级
|
|||||||
发现期才代表当前波动环境。13.5bp 与 `shadow_budget.BUDGET_PORTFOLIO_2026`
|
发现期才代表当前波动环境。13.5bp 与 `shadow_budget.BUDGET_PORTFOLIO_2026`
|
||||||
的 15.19bp 同量级,互为印证。**影子测量要对标的是 13.5 / 15.6,不是 18.6。**
|
的 15.19bp 同量级,互为印证。**影子测量要对标的是 13.5 / 15.6,不是 18.6。**
|
||||||
|
|
||||||
|
### 3.391 开仓前那段:量要小、势要有但不能过头(step53)⭐
|
||||||
|
|
||||||
|
用户提出看开仓**之前**的量与趋势方向。这是第三个位置——step50 管信号根、
|
||||||
|
step52 管持仓中,这里管入场前。
|
||||||
|
|
||||||
|
**先纠正一个错的心智模型(我和用户都以为的)**:B4/S4 不是回抽后进场。
|
||||||
|
`mom10` 中位 **+3.47 ATR**,为负的只占 **6%**。也就是说信号触发时,价格在前
|
||||||
|
10 根里已经顺着你的方向走了三个多 ATR,**你的 2 ATR 止损是架在一段已经走完
|
||||||
|
的行情后面**。所谓"回抽后转强"里的回抽,幅度远小于它之前那一冲。
|
||||||
|
|
||||||
|
这一条也解释了 step50:信号根是突破根,放量 = 追在最后一棒上。
|
||||||
|
|
||||||
|
**① 入场前的量:单调,越小越好,且独立于信号根的量**
|
||||||
|
|
||||||
|
| vpre10(前 10 根量均值,已 shift(1) 不含信号根) | 样本外毛R | 样本外余量 | 发现期毛R | 发现期余量 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Q1 最低(中位 0.61) | **1.474** | **27.25bp** | 1.074 | 17.85bp |
|
||||||
|
| Q2 | 1.266 | 23.45bp | 1.184 | 19.76bp |
|
||||||
|
| Q3 | 1.156 | 21.37bp | 1.033 | 18.61bp |
|
||||||
|
| Q4 最高(中位 2.21) | 0.573 | 8.51bp | 0.510 | 6.04bp |
|
||||||
|
|
||||||
|
vpre30 同形状。**控 `vr60` 后仍成立**:信号根低量层里毛R差 −0.284、
|
||||||
|
高量层 −0.541,与 `vr60` 相关只有 **+0.301**,不是同一件事换个说法。
|
||||||
|
|
||||||
|
**② 入场前的动量:驼峰形,不是单调**
|
||||||
|
|
||||||
|
| mom60(前 60 根顺向动量,ATR 单位) | 样本外毛R | 样本外余量 | 发现期毛R | 发现期余量 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Q1 最低(中位 0.33) | 1.252 | 22.60bp | 1.180 | 19.47bp |
|
||||||
|
| **Q2(中位 3.14)** | **1.473** | **28.68bp** | **1.355** | **26.26bp** |
|
||||||
|
| Q3 | 1.141 | 20.49bp | 0.981 | 16.23bp |
|
||||||
|
| Q4 最高(中位 8.33) | 0.603 | 8.65bp | **0.284** | **1.44bp** |
|
||||||
|
|
||||||
|
mom10 同形状(Q2 最好、Q4 最差),样本内外一致。**势要有——完全没动过的 Q1
|
||||||
|
也不如 Q2;但不能过头——Q4 在发现期余量只剩 1.44bp,等于不能做。**
|
||||||
|
|
||||||
|
⚠️ 驼峰形意味着**中位数二分法会把它测没**:控制表里 mom10 在"信号根低量"层
|
||||||
|
的毛R差是 +0.044(看着无效),那是因为二分把 Q1+Q2 和 Q3+Q4 各自平均了。
|
||||||
|
**对非单调因子不要用中位数分层做检验。**
|
||||||
|
|
||||||
|
**③ 逆向进场(真回抽)很差,但只占 6%**
|
||||||
|
|
||||||
|
| | 笔数 | 占比 | 毛R | 余量bp |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 顺向 × 缩量 | 1851 | 47% | **1.350** | **24.63** |
|
||||||
|
| 顺向 × 放量 | 1872 | 48% | 0.855 | 14.35 |
|
||||||
|
| 逆向 × 缩量 | 119 | 3% | 0.615 | 7.81 |
|
||||||
|
| 逆向 × 放量 | 99 | 3% | 0.362 | 3.61 |
|
||||||
|
|
||||||
|
**④ `mom60≥7 不做` 全面优于 §3.39 定的 `vr60≥4 不做` —— 换开关**
|
||||||
|
|
||||||
|
发现期(当前波动环境),阈值取整数不搜参:
|
||||||
|
|
||||||
|
| 方案 | 保留 | 盈亏平衡滑点 | R夏普 | 回撤R | 总R |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 等权(现状) | 100% | 13.5bp | 0.325 | 16.8 | 596.6 |
|
||||||
|
| 砍 vr60≥4(§3.39 的建议) | 69% | 15.6bp | 0.411 | 10.8 | 514.9 |
|
||||||
|
| **✅ 砍 mom60≥7** | **78%** | **17.5bp** | **0.476** | **7.7** | **657.7** |
|
||||||
|
| 砍 vr60≥4 + mom60≥7 | 58% | 18.3bp | 0.515 | 8.0 | 527.8 |
|
||||||
|
| 三个都砍(+vpre10≥1.5) | 42% | 20.0bp | 0.593 | 6.5 | 430.4 |
|
||||||
|
|
||||||
|
**`砍 mom60≥7` 在每一项上都赢过 `砍 vr60≥4`,还多留 9 个点的笔数。**
|
||||||
|
更要紧的是它**总R 比不砍还高**(657.7 vs 596.6):被砍掉那 22% 的单子期望为负,
|
||||||
|
砍掉不是花钱买稳健,是纯赚。样本外同向(21.4bp / 0.588 / 1923.6,
|
||||||
|
对照砍 vr60 的 19.8 / 0.529 / 1820.8)。
|
||||||
|
|
||||||
|
**选哪个方案取决于真实滑点,这是决策表**(发现期总R):
|
||||||
|
|
||||||
|
| 真实滑点 | 0bp | 5bp | 8bp | 10bp | 12bp | 15bp | 18bp | 20bp |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 等权 | **946** | 597 | 387 | 247 | 107 | −102 | −312 | −452 |
|
||||||
|
| 砍 mom60≥7 | 921 | **658** | **500** | **394** | **289** | 131 | −28 | −133 |
|
||||||
|
| 三个都砍 | 574 | 430 | 344 | 287 | 229 | **143** | **57** | −0 |
|
||||||
|
|
||||||
|
**5~12bp 区间 `砍 mom60≥7` 通吃**,那正是最可能的落点。只有真实滑点 ≥15bp
|
||||||
|
才该上"三个都砍",而那时策略本身已经在生死线上。**影子测量出数之前不用选,
|
||||||
|
但要知道选项长这样。**
|
||||||
|
|
||||||
|
> 阈值 `mom60 ≥ 7`、`vpre10 ≥ 1.5` 是贴着 Q4 边界取的整数,不是搜出来的,
|
||||||
|
> 但也**不是完全无关于数据**(我看过分位表才取的整)。真上线前应在更早的
|
||||||
|
> 时段上确认阈值附近没有断崖式敏感。
|
||||||
|
|
||||||
### 3.4 alpha 的来源(step32 消融)
|
### 3.4 alpha 的来源(step32 消融)
|
||||||
|
|
||||||
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
|
逐条拆掉 `fast_bsp3` 的条件后发现:**alpha 完全来自缠论中枢的上下文定位,
|
||||||
@@ -871,6 +1008,7 @@ step41 首轮跑的是错误的 3/1bp(见 §1.3),已用实际费率重算
|
|||||||
| **按 ATR 从大到小选币 / 追高 ATR 时刻** | 门控之上 ATR 高不加分。**币内严格单调递减**:ATR 最低 20% 分位净均R 1.040,最高 20% 只有 0.878。跨币看似正相关(r=+0.684)但**剔除 BTC 后掉到 +0.469**,其余 9 币极差仅 0.089。ATR 唯一真正给的是 bp 余量(机械关系)。且追高 ATR 会撞上流动性墙——现有 11 币全是头部流动性,外推到小市值币时多出的余量会被滑点吃回去。**ATR 是及格线不是排序键** | §3.33 |
|
| **按 ATR 从大到小选币 / 追高 ATR 时刻** | 门控之上 ATR 高不加分。**币内严格单调递减**:ATR 最低 20% 分位净均R 1.040,最高 20% 只有 0.878。跨币看似正相关(r=+0.684)但**剔除 BTC 后掉到 +0.469**,其余 9 币极差仅 0.089。ATR 唯一真正给的是 bp 余量(机械关系)。且追高 ATR 会撞上流动性墙——现有 11 币全是头部流动性,外推到小市值币时多出的余量会被滑点吃回去。**ATR 是及格线不是排序键** | §3.33 |
|
||||||
| **`available_ts` 改取 `bis[2]`(中枢成立即可用)以消除右边缘重画** | 重画确实从 6.5% 降到 1.2%、滞后 2.16→2.01,**但 alpha 被打成零**:实盘口径毛 R 0.933 → **−0.000**(扣费前就没了 edge),PF 3.22→0.80,余量 15.07→−2.20bp,8/8 币全变差。中枢没发育完就下注,支撑/压力还没立住。**「等中枢最后一笔」就是 alpha 本身,重画是必付代价。**推论:任何以「让信号更早确定」为目标的改动,先测毛 R,不要只看重画率和滞后 | step47 §5.42 |
|
| **`available_ts` 改取 `bis[2]`(中枢成立即可用)以消除右边缘重画** | 重画确实从 6.5% 降到 1.2%、滞后 2.16→2.01,**但 alpha 被打成零**:实盘口径毛 R 0.933 → **−0.000**(扣费前就没了 edge),PF 3.22→0.80,余量 15.07→−2.20bp,8/8 币全变差。中枢没发育完就下注,支撑/压力还没立住。**「等中枢最后一笔」就是 alpha 本身,重画是必付代价。**推论:任何以「让信号更早确定」为目标的改动,先测毛 R,不要只看重画率和滞后 | step47 §5.42 |
|
||||||
| **挑「信号根成交量大」的信号(有资金推的趋势)** | **方向完全相反**:vr60 四分位上毛R 单调 1.421→0.794、余量 27.18→13.47bp,样本内外 / 两套量比基准 / ATR 4 分位 4/4 / 逐时段 7/7 全部同向。止损率 22.5%→45.3% 翻倍,且**止盈率同时下降**(说明是方向变差,不是止损太窄)。根因:B4/S4 在突破根上进场,大量根 = 这一冲已经由别人的资金走完,你在收盘价接盘。**低量那一档才是好的**,可反向用作仓位权重 | step50 §3.31 |
|
| **挑「信号根成交量大」的信号(有资金推的趋势)** | **方向完全相反**:vr60 四分位上毛R 单调 1.421→0.794、余量 27.18→13.47bp,样本内外 / 两套量比基准 / ATR 4 分位 4/4 / 逐时段 7/7 全部同向。止损率 22.5%→45.3% 翻倍,且**止盈率同时下降**(说明是方向变差,不是止损太窄)。根因:B4/S4 在突破根上进场,大量根 = 这一冲已经由别人的资金走完,你在收盘价接盘。**低量那一档才是好的**,可反向用作仓位权重 | step50 §3.31 |
|
||||||
|
| **持仓中放量就平仓(把成交量当出场信号)** | **方向也是反的,且比入场那条更干净**:毛R 1.068 → 0.617(vr60≥3)/ 0.858(≥5)/ 1.009(≥8),**阈值越高越接近基线,最优点是「永不触发」**。触发的那批若不平,止盈率 45.7%、止损率 16.6%(全体基线 30.1% / 33.1%),**放量根标记的是最好的单子不是最差的**,平掉每笔让出 +0.484R。幸存者偏差已控(按持仓 ≥K 根分层 5/5 同向)。同一事件入场为负、持仓为正——入场时你是买方,持仓时资金是来接你货的 | step52 §3.31 |
|
||||||
| 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 |
|
| 线段(`Chan_XD`)做大级别 | 滞后太大,且中枢极少 | 早期,用户也这么说 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1418,7 +1556,7 @@ python step46_engine_parity.py --check --rows 200000 --out step46_baseline_big.j
|
|||||||
> 这处偏差值得记:它是读代码读出来的(把一个函数的内部自引用误当成外部
|
> 这处偏差值得记:它是读代码读出来的(把一个函数的内部自引用误当成外部
|
||||||
> 依赖),而对拍一次就定论了。同类判断优先用对拍。
|
> 依赖),而对拍一次就定论了。同类判断优先用对拍。
|
||||||
|
|
||||||
### 5.6 增量在影子路径上的落地(2026-08-28)
|
### 5.7 增量在影子路径上的落地(2026-08-28)
|
||||||
|
|
||||||
`shadow_signal.compute` 已改为按 `(symbol, timeframe)` 缓存流式对象。
|
`shadow_signal.compute` 已改为按 `(symbol, timeframe)` 缓存流式对象。
|
||||||
|
|
||||||
@@ -1447,6 +1585,56 @@ python step46_engine_parity.py --check --rows 200000 --out step46_baseline_big.j
|
|||||||
是 `build_htf_zones` / `find_fast_bsp3` / `attach_htf_context`。再压 chan
|
是 `build_htf_zones` / `find_fast_bsp3` / `attach_htf_context`。再压 chan
|
||||||
构建收益有限,要继续压得看下游那三个。
|
构建收益有限,要继续压得看下游那三个。
|
||||||
|
|
||||||
|
#### 5.71 第二轮引擎优化(2026-08-28,服务端反馈驱动)
|
||||||
|
|
||||||
|
承上——§5.7 末尾点的那两个引擎侧热点(`add_indicators` 全表重算 34%、
|
||||||
|
`cal_bi_list` 整表重扫 51%)。逐个查下来,**四处都是"算了没人要的东西",
|
||||||
|
不是算法本身慢**:
|
||||||
|
|
||||||
|
| 改动 | 性质 |
|
||||||
|
|---|---|
|
||||||
|
| `cal_trend` 挂到 lean 下 | 见上面那条更正——它不是笔的依赖。web 走非 lean,`klc_trend` 图层不受影响 |
|
||||||
|
| `check_fx_pattern` 删掉拼完就丢的字符串 | 它把 `klu.to_string()` 拼成 `p` 只为一行注释掉的 print。2000 根上近 3 万次 f-string + 6 万次 enum 格式化,在 `cal_bi_list` 内层。`klu.pattern` 只被 `cal_klu_pattern` 自己的双K/三K 判定读,不出模块、不进 web,故整个调用 lean 下也跳 |
|
||||||
|
| `ChanBI.add_klc` 去二次方 | 去重原本线性扫 `klc_list`,且**每加一根就把整笔所有 KLU 的 macdhist 重累一遍** → 往一笔加 k 根是 O(k²)。改下标集合 + `macd_hist`/`macd_div` 惰性求值。这两个值只有背驰判定(`bsp.py`)读,lean 下 bsp 根本不算 |
|
||||||
|
| `add_indicators` 批量挂列 | 2001 行上 TA 计算合计只有 2.5ms,而 30 多次 `df['x']=` 要 3.6ms——**开销大头是 BlockManager 逐列插入,不是计算**。改为一次 concat。`cal_volume_ratio` 里为算一列 rolling 而 `dataframe.copy()` 整张 40 列表,一并去掉 |
|
||||||
|
|
||||||
|
实测(本机,2001 根窗口)。**注意这组数和 §5.7 的 21.8ms 不同机**——本机基线
|
||||||
|
就是 13.9ms,服务端约慢 1.4~1.6 倍,别把两边的绝对值直接比:
|
||||||
|
|
||||||
|
| | 改前 | 改后 |
|
||||||
|
|---|---|---|
|
||||||
|
| `append_bar` | 13.9ms | **6.6ms** |
|
||||||
|
| └ `rebuild_bi_zs` | 8.7ms | 2.8ms |
|
||||||
|
| └ `add_indicators` | 4.4ms | 3.5ms |
|
||||||
|
| `TF_DF` lean | 49.8ms | **32.9ms** |
|
||||||
|
| `TF_DF` full | 72.7ms | 64.9ms |
|
||||||
|
|
||||||
|
**对拍**(`git worktree` 检出改动前的提交,同一份 BTC 1m 4000 根,
|
||||||
|
lean/full 两模式 × klc/klu/bi/zs/seg/bsp/指标列/列序,38 项指纹):
|
||||||
|
|
||||||
|
- **full 模式 19 项全部一致** → web 那条路一个字节没动。
|
||||||
|
- **lean 模式差 2 项,且正是设计要它差的两项**:`klc.trend`(跳了
|
||||||
|
`cal_trend`,现恒为 `UNKNOWN`)与 `klu.pattern`(跳了 `cal_klu_pattern`)。
|
||||||
|
- **lean 下的 `bi`/`zs`/`seg`/`bsp`/`dataframe` 全部一致** ——
|
||||||
|
这就是"这两个字段没人读"那句断言的实测证据:把它们打空,下游一位不变。
|
||||||
|
|
||||||
|
⚠️ 别把这写成"完全一致"。有两项按设计就该变,写成全等会掩盖掉真正要担保的
|
||||||
|
那件事:**变的只有这两个已确认无人消费的字段。** full 模式的 `bsp_list` 是
|
||||||
|
`macd_hist` 的唯一消费者,它没变才说明惰性求值是对的。
|
||||||
|
|
||||||
|
**剩下没做的:`add_indicators` 仍是全表重算**(为加一根算 2001 行)。
|
||||||
|
EMA/MACD/ATR 是递推的、BB/SMA/量比是窗口的,理论上都能 O(1) 更新到精确值,
|
||||||
|
但 Wilder RSI 需要额外维护 `avg_gain`/`avg_loss` 状态(从输出反推不出来)。
|
||||||
|
做完 `append_bar` 可到 3~4ms。**风险在于一处不精确就静默换掉一批信号,
|
||||||
|
要做必须先扩对拍。**
|
||||||
|
|
||||||
|
**瓶颈已经换位置了。** 本机分档(`research/live/probe_inner.py`,与服务端
|
||||||
|
同口径):`TF_DF` 两条腿占 70%、`build_htf_zones` 13%、`htf_fx_timeline` 6%、
|
||||||
|
`find_fast_bsp3`+ladder+attach 合计不到 2%。但服务端报的是 chan 构建 22ms /
|
||||||
|
信号链 86ms,按机器差(×1.36)解释不了四倍差距。
|
||||||
|
**曾怀疑是 payload 反序列化,实测 `_rebuild` 只有 1.0ms,假设不成立。**
|
||||||
|
两边跑同一个探针对分档表,才能定位那 86ms。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 接下来要做的事(按优先级)
|
## 6. 接下来要做的事(按优先级)
|
||||||
@@ -1764,6 +1952,8 @@ API 限流风险隔离三个好处。
|
|||||||
| 49 | **「有前序」样本外验证**(10 币 80 万根)→ 通过,余量 +6bp,6/6 时段同向 |
|
| 49 | **「有前序」样本外验证**(10 币 80 万根)→ 通过,余量 +6bp,6/6 时段同向 |
|
||||||
| 50 | **信号根成交量**(用户假设「有资金的趋势才是好趋势」)→ **方向相反**,高量毛R 0.794 vs 低量 1.421,ATR 4/4 + 时段 7/7 同向;根因是在突破根上给别人接盘 |
|
| 50 | **信号根成交量**(用户假设「有资金的趋势才是好趋势」)→ **方向相反**,高量毛R 0.794 vs 低量 1.421,ATR 4/4 + 时段 7/7 同向;根因是在突破根上给别人接盘 |
|
||||||
| 51 | **因子化仓位**(把 49/50 做成权重)→ 量因子该做成**开关**不是权重(硬砍 vs 加权:R夏普 0.496/0.442、并发 6.00/7.98);**前序因子不该进仓位**,边际收益买不起集中度。优势形态是削尾不是抬均值 |
|
| 51 | **因子化仓位**(把 49/50 做成权重)→ 量因子该做成**开关**不是权重(硬砍 vs 加权:R夏普 0.496/0.442、并发 6.00/7.98);**前序因子不该进仓位**,边际收益买不起集中度。优势形态是削尾不是抬均值 |
|
||||||
|
| 52 | **持仓中放量出场**(用户提议)→ 反的,最优点是永不触发;放量根标记的是止盈率翻倍的好单。自带模拟器与 `walk_exits` 逐笔对拍,10/10 币通过 |
|
||||||
|
| 53 | **开仓前的量与趋势方向**(用户提议)⭐ → 前段量单调(越小越好,独立于信号根量);前段动量**驼峰形**(Q2 最好、Q4 最差)。`砍 mom60≥7` 全面优于 §3.39 的 `砍 vr60≥4`,且总R 比不砍还高。附滑点决策表 |
|
||||||
|
|
||||||
输出都在 `research/out/`。**2026-08-27 清理过一轮**:step1~20 的输出(早期方法论
|
输出都在 `research/out/`。**2026-08-27 清理过一轮**:step1~20 的输出(早期方法论
|
||||||
已被推翻,含未来函数偏差)与旧口径备份一并删除,只留支撑当前结论的证据。
|
已被推翻,含未来函数偏差)与旧口径备份一并删除,只留支撑当前结论的证据。
|
||||||
@@ -1855,11 +2045,17 @@ API 限流风险隔离三个好处。
|
|||||||
- [x] **因子化仓位已评估完(step51,§3.39)**,结论与分层表给的印象相反:
|
- [x] **因子化仓位已评估完(step51,§3.39)**,结论与分层表给的印象相反:
|
||||||
量因子做成**开关**(vr60 ≥ 4 不做),前序因子**不进仓位**。
|
量因子做成**开关**(vr60 ≥ 4 不做),前序因子**不进仓位**。
|
||||||
剩下两件事:
|
剩下两件事:
|
||||||
- [ ] **决定要不要上「vr60 ≥ 4 不做」这个开关。** 收益是发现期盈亏平衡滑点
|
- [ ] **上「mom60 ≥ 7 不做」这个开关**(§3.391 已取代原来的 `vr60 ≥ 4`)。
|
||||||
13.5 → 15.6bp、回撤 −35%、峰值并发 7 → 6;代价是笔数 −22%
|
与其他开关不同,**这个不需要等滑点数据**:它在 0bp 到 20bp 的每一档上
|
||||||
(10 币已只有约 2.5 笔/天,砍完约 2.0)。**建议等影子测量出真实滑点
|
都不输给等权,5~12bp 区间还大幅领先,且总R 比不砍更高(657.7 vs 596.6)
|
||||||
再定**——若实测远低于 13.5bp,等权就够用,没必要付这 22%;
|
——被砍的那 22% 期望为负。**唯一的前置动作是确认阈值附近没有断崖敏感**
|
||||||
若实测贴着 13.5bp,这 2.1bp 的余量就是生死线。**这个开关只在
|
(阈值 7 是贴着 Q4 边界取的整数,看过分位表才取的)
|
||||||
live 信号路径加一行,随时可上,不必现在决定**
|
- [ ] 若真实滑点实测 ≥15bp,再考虑叠加 `vpre10 ≥ 1.5` 与 `vr60 ≥ 4`
|
||||||
|
("三个都砍",保留 42%)。滑点 <12bp 时这个叠加是亏的,别提前上
|
||||||
- [ ] `有前序` 的余量优势改用**放宽 ATR 门控**兑现(多做几笔而非每笔做大)。
|
- [ ] `有前序` 的余量优势改用**放宽 ATR 门控**兑现(多做几笔而非每笔做大)。
|
||||||
§3.31 已记,未回测
|
§3.31 已记,未回测
|
||||||
|
- [ ] **持仓中放量 → 延长 runner 目标 / 加仓**(step52 的建设性反面)。
|
||||||
|
已知:触发后止盈率 45.7% vs 全体 30.1%,控存活期后 5/5 档同向。
|
||||||
|
但基线已隐含吃到这部分(拿着不动就是),要多拿必须主动改目标位,
|
||||||
|
**那是新参数,要重新回测**。R差 随触发时点递减(0.785 → 0.281),
|
||||||
|
窗口应偏前段
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""把 `inner_ms` 拆成和本地一致的分档,用来定位两边测不一致的那部分。
|
||||||
|
|
||||||
|
起因:本地量到的构成是 TF_DF 占 ~80%、信号链 ~20%,服务器报的是 chan 构建
|
||||||
|
22ms、信号链 86ms。按机器差(×1.36)也解释不了四倍差距,说明两边测的不是
|
||||||
|
同一件事,或者有个环节只在服务器上贵。
|
||||||
|
|
||||||
|
用同一批窗口跑,比较分档而不是总数。两边都跑一遍再对表:
|
||||||
|
|
||||||
|
python research/live/probe_inner.py --syms BTC,ETH,SOL --repeat 5
|
||||||
|
|
||||||
|
分档口径(与 shadow_signal.compute 的调用顺序一致):
|
||||||
|
rebuild payload → DataFrame
|
||||||
|
ind_ltf/htf 两条腿各自的 add_indicators(TF_DF 内部会做,这里单独计时)
|
||||||
|
chan_ltf/htf TF_DF 构建(lean)
|
||||||
|
zones build_htf_zones
|
||||||
|
ladder add_zone_ladder
|
||||||
|
bsp find_fast_bsp3
|
||||||
|
timeline htf_fx_timeline(5m 分型时间线)
|
||||||
|
attach attach_htf_agree + attach_zone_ladder
|
||||||
|
|
||||||
|
注意 `ind_*` 与 `chan_*` 在真实路径里是合一的(TF_DF 内部调 add_indicators),
|
||||||
|
这里拆开只为定位。总和会略大于实际 inner_ms。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||||
|
os.environ.setdefault(_v, "1")
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve()
|
||||||
|
sys.path.insert(0, str(HERE.parents[1]))
|
||||||
|
sys.path.insert(0, str(HERE.parents[2]))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
|
||||||
|
LTF_BARS, HTF_BARS = 2001, 801
|
||||||
|
|
||||||
|
|
||||||
|
def med(fn, n: int):
|
||||||
|
ts = []
|
||||||
|
out = None
|
||||||
|
for _ in range(n):
|
||||||
|
t = time.perf_counter()
|
||||||
|
out = fn()
|
||||||
|
ts.append((time.perf_counter() - t) * 1000)
|
||||||
|
return float(np.median(ts)), out
|
||||||
|
|
||||||
|
|
||||||
|
def probe(sym: str, repeat: int) -> dict:
|
||||||
|
from chanlun import TF_DF
|
||||||
|
from chanlun.analysis.fast_bsp import (
|
||||||
|
add_zone_ladder, attach_htf_agree, attach_zone_ladder,
|
||||||
|
build_htf_zones, find_fast_bsp3, htf_fx_timeline,
|
||||||
|
)
|
||||||
|
from lib.data import fetch_ohlcv
|
||||||
|
|
||||||
|
dl = fetch_ohlcv(f"{sym}/USDT:USDT", "1m", LTF_BARS * 3).tail(LTF_BARS).reset_index(drop=True)
|
||||||
|
dh = fetch_ohlcv(f"{sym}/USDT:USDT", "5m", HTF_BARS * 3).tail(HTF_BARS).reset_index(drop=True)
|
||||||
|
|
||||||
|
probe_tf = TF_DF(lean=True)
|
||||||
|
r = {"sym": sym}
|
||||||
|
r["ind_ltf"], _ = med(lambda: probe_tf.add_indicators(dl.copy()), repeat)
|
||||||
|
r["ind_htf"], _ = med(lambda: probe_tf.add_indicators(dh.copy()), repeat)
|
||||||
|
r["chan_ltf"], cl = med(lambda: TF_DF(dl, 1, "1m", lean=True), repeat)
|
||||||
|
r["chan_htf"], ch = med(lambda: TF_DF(dh, 1, "5m", lean=True), repeat)
|
||||||
|
cdf = cl.dataframe
|
||||||
|
r["zones"], z = med(lambda: build_htf_zones(cdf, "1m", chan=cl), repeat)
|
||||||
|
if z is None or z.empty:
|
||||||
|
r["n_zones"] = 0
|
||||||
|
return r
|
||||||
|
z0 = z.reset_index(drop=True)
|
||||||
|
r["ladder"], zl = med(lambda: add_zone_ladder(z0), repeat)
|
||||||
|
r["bsp"], sg = med(lambda: find_fast_bsp3(cdf, zl), repeat)
|
||||||
|
r["timeline"], tl = med(lambda: htf_fx_timeline(ch, ch.dataframe), repeat)
|
||||||
|
r["attach"], _ = med(
|
||||||
|
lambda: attach_zone_ladder(attach_htf_agree(sg, cdf, tl), zl), repeat)
|
||||||
|
r["n_zones"], r["n_sig"] = len(z0), len(sg)
|
||||||
|
|
||||||
|
# 增量口径:init 一次后追加,看稳态单根成本
|
||||||
|
c = TF_DF(lean=True)
|
||||||
|
c.init_stream(dl.iloc[:-60].reset_index(drop=True), 1, "1m")
|
||||||
|
ts = []
|
||||||
|
for k in range(len(dl) - 60, len(dl)):
|
||||||
|
t = time.perf_counter()
|
||||||
|
c.append_bar(dl.iloc[k])
|
||||||
|
ts.append((time.perf_counter() - t) * 1000)
|
||||||
|
r["append_bar"] = float(np.median(ts[20:]))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--syms", default="BTC,ETH,SOL")
|
||||||
|
ap.add_argument("--repeat", type=int, default=5)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
rows = [probe(s.strip(), args.repeat) for s in args.syms.split(",") if s.strip()]
|
||||||
|
d = pd.DataFrame(rows).set_index("sym")
|
||||||
|
parts = [c for c in ("ind_ltf", "ind_htf", "chan_ltf", "chan_htf", "zones",
|
||||||
|
"ladder", "bsp", "timeline", "attach") if c in d]
|
||||||
|
d["合计"] = d[parts].sum(axis=1)
|
||||||
|
pd.set_option("display.width", 220)
|
||||||
|
print("\n分档耗时(ms,中位)")
|
||||||
|
print(d[parts + ["合计", "append_bar"]].round(2).to_string())
|
||||||
|
print("\n占比(%)")
|
||||||
|
print((d[parts].div(d["合计"], axis=0) * 100).round(1).to_string())
|
||||||
|
print("\n规模")
|
||||||
|
print(d[[c for c in ("n_zones", "n_sig") if c in d]].to_string())
|
||||||
|
print("\n注:ind_* 与 chan_* 在真实路径里合一(TF_DF 内部调 add_indicators),"
|
||||||
|
"拆开只为定位,合计会略大于实际 inner_ms。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -75,6 +75,26 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None:
|
|||||||
idx = sig["entry_idx"].to_numpy().astype(int)
|
idx = sig["entry_idx"].to_numpy().astype(int)
|
||||||
atr = cdf["atr"].to_numpy(float)[idx]
|
atr = cdf["atr"].to_numpy(float)[idx]
|
||||||
close = cdf["close"].to_numpy(float)[idx]
|
close = cdf["close"].to_numpy(float)[idx]
|
||||||
|
|
||||||
|
vol = cdf["volume"]
|
||||||
|
vr = vol / vol.rolling(60, min_periods=10).mean()
|
||||||
|
vr60 = vr.to_numpy(float)
|
||||||
|
# shift(1) 把信号根自己排除在外
|
||||||
|
vpre10 = vr.rolling(10, min_periods=5).mean().shift(1).to_numpy(float)
|
||||||
|
vpre30 = vr.rolling(30, min_periods=15).mean().shift(1).to_numpy(float)
|
||||||
|
c_all = cdf["close"].to_numpy(float)
|
||||||
|
a_all = cdf["atr"].to_numpy(float)
|
||||||
|
d_all = sig["direction"].to_numpy().astype(int)
|
||||||
|
|
||||||
|
def mom(k: int) -> np.ndarray:
|
||||||
|
"""顺方向动量(ATR 单位)。用全序列算好再取下标,避免逐笔切片。"""
|
||||||
|
prev = np.concatenate([np.full(k, np.nan), c_all[:-k]])
|
||||||
|
with np.errstate(invalid="ignore", divide="ignore"):
|
||||||
|
m = (c_all - prev) / a_all
|
||||||
|
out = np.full(len(idx), np.nan)
|
||||||
|
out[:] = m[idx] * d_all
|
||||||
|
return out
|
||||||
|
|
||||||
return pd.DataFrame({
|
return pd.DataFrame({
|
||||||
"sym": sym,
|
"sym": sym,
|
||||||
"date": cdf["date"].to_numpy()[idx],
|
"date": cdf["date"].to_numpy()[idx],
|
||||||
@@ -91,8 +111,15 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None:
|
|||||||
# 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。
|
# 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。
|
||||||
# vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。
|
# vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。
|
||||||
"vr10": cdf["volume_ratio"].to_numpy(float)[idx],
|
"vr10": cdf["volume_ratio"].to_numpy(float)[idx],
|
||||||
"vr60": (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
|
"vr60": vr60[idx],
|
||||||
).to_numpy(float)[idx],
|
# 开仓**之前**那段的量与走势。vpre 已 shift(1),不含信号根本身——
|
||||||
|
# 信号根的量单独由 vr60 承担,两者混在一起就分不清是哪一段在起作用。
|
||||||
|
"vpre10": vpre10[idx],
|
||||||
|
"vpre30": vpre30[idx],
|
||||||
|
# 顺方向动量,ATR 为单位。B4/S4 是回抽后转强,所以 mom10 多为负
|
||||||
|
# (入场前那几根逆着你走);负得多 = 回抽深。
|
||||||
|
"mom10": mom(10),
|
||||||
|
"mom60": mom(60),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" {sym} 失败: {e!r}", flush=True)
|
print(f" {sym} 失败: {e!r}", flush=True)
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Step 52:持仓中放量就平仓 —— 把成交量当出场信号。
|
||||||
|
|
||||||
|
用户提出:既然 step50 证明「入场根放量 = 给走完这一冲的人接盘」,那持仓过程中
|
||||||
|
出现放量根,是不是也说明这一波被走完了,该直接平掉?
|
||||||
|
|
||||||
|
这和 step50 是同一个机制的延伸,但**方向未知**:放量既可能是衰竭(该走),
|
||||||
|
也可能是突破续势的起点(走了就砍在起涨点)。step50 只证明了「入场时撞上放量
|
||||||
|
不好」,推不出「持仓时撞上放量该跑」——入场是你在接别人的盘,持仓时那根量
|
||||||
|
可能正是把你送上去的那批资金。
|
||||||
|
|
||||||
|
出场腿是 taker(收盘市价),成本按 TIME 计。触发优先级:同一根内止损(盘中)
|
||||||
|
> 目标(盘中)> 放量平仓(收盘)。止损优先是保守侧。
|
||||||
|
|
||||||
|
⚠️ 本步自己写了逐根模拟器,不走 walk_exits。**基线必须与 walk_exits 逐笔
|
||||||
|
相等才继续**——否则后面所有对比都是在和一个错的基线比。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||||
|
os.environ.setdefault(v, "1")
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
pd.set_option("display.width", 340)
|
||||||
|
|
||||||
|
SL, SCALE_AT, RUNNER, MAXB = 2.0, 3.0, 8.0, 48
|
||||||
|
GATE_BP = 8.0
|
||||||
|
THRESHOLDS = (3.0, 5.0, 8.0)
|
||||||
|
OUT = HERE / "out" / "step52_volume_exit.feather"
|
||||||
|
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
|
||||||
|
|
||||||
|
TP, SL_, TIME = 0, 1, 2
|
||||||
|
|
||||||
|
|
||||||
|
def simulate(cdf, sig, vr):
|
||||||
|
"""逐根前推。返回每笔在基线与各放量出场变体下的 (毛收益, 原因, 是否分批)。
|
||||||
|
|
||||||
|
runner 止损位与初始止损同为 2 ATR(HANDOFF §3.5 定的 rstop=SL),
|
||||||
|
所以全程止损线不动,不需要分段处理。
|
||||||
|
"""
|
||||||
|
high = cdf["high"].to_numpy(float)
|
||||||
|
low = cdf["low"].to_numpy(float)
|
||||||
|
open_ = cdf["open"].to_numpy(float)
|
||||||
|
close = cdf["close"].to_numpy(float)
|
||||||
|
atr = cdf["atr"].to_numpy(float)
|
||||||
|
n = len(cdf)
|
||||||
|
|
||||||
|
variants = ["base"]
|
||||||
|
for t in THRESHOLDS:
|
||||||
|
variants += [f"v{t:g}", f"v{t:g}w"]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for s, d in zip(sig["entry_idx"].astype(int), sig["direction"].astype(int)):
|
||||||
|
e = s + 1
|
||||||
|
if e >= n - 1:
|
||||||
|
continue
|
||||||
|
a = atr[s]
|
||||||
|
if not np.isfinite(a) or a <= 0:
|
||||||
|
continue
|
||||||
|
entry = open_[e]
|
||||||
|
end = min(e + MAXB, n - 1)
|
||||||
|
row = {"sig_idx": s, "atr_pct": a / entry}
|
||||||
|
|
||||||
|
for var in variants:
|
||||||
|
thr = None if var == "base" else float(var[1:].rstrip("w"))
|
||||||
|
only_win = var.endswith("w")
|
||||||
|
scaled = False
|
||||||
|
g = r = None
|
||||||
|
xb = end
|
||||||
|
for j in range(e, end + 1):
|
||||||
|
adv = (high[j] - entry) / a if d == 1 else (entry - low[j]) / a
|
||||||
|
ret = (entry - low[j]) / a if d == 1 else (high[j] - entry) / a
|
||||||
|
# ① 止损(盘中)。同根内优先于目标,保守侧
|
||||||
|
if ret >= SL:
|
||||||
|
hit = -SL * a / entry
|
||||||
|
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * hit if scaled else hit
|
||||||
|
r, xb = SL_, j
|
||||||
|
break
|
||||||
|
# ② 目标(盘中限价)
|
||||||
|
if not scaled and adv >= SCALE_AT:
|
||||||
|
# 同根内可能既到 3 ATR 又到 8 ATR,按先减仓后续跑处理
|
||||||
|
scaled = True
|
||||||
|
if adv >= RUNNER:
|
||||||
|
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * (RUNNER * a / entry)
|
||||||
|
r, xb = TP, j
|
||||||
|
break
|
||||||
|
elif scaled and adv >= RUNNER:
|
||||||
|
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * (RUNNER * a / entry)
|
||||||
|
r, xb = TP, j
|
||||||
|
break
|
||||||
|
# ③ 放量平仓(收盘市价)
|
||||||
|
if thr is not None and np.isfinite(vr[j]) and vr[j] >= thr:
|
||||||
|
px = d * (close[j] - entry) / entry
|
||||||
|
if not only_win or px > 0:
|
||||||
|
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * px if scaled else px
|
||||||
|
r, xb = TIME, j
|
||||||
|
break
|
||||||
|
if g is None: # 超时:末根收盘市价
|
||||||
|
px = d * (close[end] - entry) / entry
|
||||||
|
g = 0.5 * (SCALE_AT * a / entry) + 0.5 * px if scaled else px
|
||||||
|
r, xb = TIME, end
|
||||||
|
row[f"{var}_g"], row[f"{var}_r"] = g, r
|
||||||
|
row[f"{var}_c"] = int(scaled)
|
||||||
|
row[f"{var}_b"] = xb - e + 1
|
||||||
|
rows.append(row)
|
||||||
|
return pd.DataFrame(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(sym: str, rows: int):
|
||||||
|
import warnings as _w
|
||||||
|
_w.filterwarnings("ignore")
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
|
||||||
|
from chanlun import TF_DF
|
||||||
|
from chanlun.analysis.fast_bsp import (
|
||||||
|
add_zone_ladder, attach_htf_agree, attach_zone_ladder,
|
||||||
|
build_htf_zones, find_fast_bsp3, htf_fx_timeline,
|
||||||
|
)
|
||||||
|
from lib.data import fetch_ohlcv
|
||||||
|
from lib.exit_model import cfg_name, walk_exits
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = fetch_ohlcv(f"{sym}/USDT:USDT", "1m", rows)
|
||||||
|
if df is None or len(df) < 50_000:
|
||||||
|
return None
|
||||||
|
chan = TF_DF(df, 1, "1m", lean=True)
|
||||||
|
cdf = chan.dataframe
|
||||||
|
zones = build_htf_zones(cdf, "1m", chan=chan)
|
||||||
|
if zones.empty:
|
||||||
|
return None
|
||||||
|
zl = add_zone_ladder(zones.reset_index(drop=True))
|
||||||
|
sig = find_fast_bsp3(cdf, zl)
|
||||||
|
if sig.empty:
|
||||||
|
return None
|
||||||
|
dh = fetch_ohlcv(f"{sym}/USDT:USDT", "5m", 10 ** 9)
|
||||||
|
ch = TF_DF(dh, 1, "5m", lean=True)
|
||||||
|
sig = attach_zone_ladder(
|
||||||
|
attach_htf_agree(sig, cdf, htf_fx_timeline(ch, ch.dataframe)), zl)
|
||||||
|
|
||||||
|
vr = (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
|
||||||
|
).to_numpy(float)
|
||||||
|
res = simulate(cdf, sig, vr)
|
||||||
|
|
||||||
|
# 对拍:基线必须与已验证的 walk_exits 逐笔相等
|
||||||
|
ref = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||||
|
runners=(RUNNER,), runner_stops=(SL,))
|
||||||
|
cfg = cfg_name(SL, RUNNER, MAXB, SL)
|
||||||
|
if len(ref) != len(res):
|
||||||
|
print(f" {sym} 对拍失败:笔数 {len(ref)} vs {len(res)}", flush=True)
|
||||||
|
return None
|
||||||
|
dg = np.abs(ref[f"{cfg}_g"].to_numpy() - res["base_g"].to_numpy())
|
||||||
|
dr = (ref[f"{cfg}_r"].to_numpy() != res["base_r"].to_numpy()).sum()
|
||||||
|
if dg.max() > 1e-12 or dr:
|
||||||
|
print(f" {sym} 对拍失败:毛收益最大差 {dg.max():.3e},原因分歧 {dr} 笔",
|
||||||
|
flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
idx = sig["entry_idx"].to_numpy().astype(int)
|
||||||
|
keep = np.isin(idx, res["sig_idx"].to_numpy())
|
||||||
|
idx = idx[keep]
|
||||||
|
atr = cdf["atr"].to_numpy(float)[idx]
|
||||||
|
close = cdf["close"].to_numpy(float)[idx]
|
||||||
|
out = pd.DataFrame({
|
||||||
|
"sym": sym,
|
||||||
|
"date": cdf["date"].to_numpy()[idx],
|
||||||
|
"atr_bp": atr / close * 1e4,
|
||||||
|
"htf": sig["htf_agree"].to_numpy()[keep],
|
||||||
|
"lad": sig["ladder_ok"].to_numpy()[keep],
|
||||||
|
"vr60_entry": vr[idx],
|
||||||
|
})
|
||||||
|
for c in res.columns:
|
||||||
|
if c not in ("sig_idx",):
|
||||||
|
out[c] = res[c].to_numpy()
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {sym} 失败: {e!r}", flush=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stat(d: pd.DataFrame, var: str, label: str) -> dict:
|
||||||
|
from lib.exit_model import fee_of, taker_notional
|
||||||
|
|
||||||
|
g = d[f"{var}_g"].to_numpy()
|
||||||
|
r, c = d[f"{var}_r"].to_numpy(), d[f"{var}_c"].to_numpy()
|
||||||
|
tn = taker_notional(r, c)
|
||||||
|
net = g - fee_of(r, c)
|
||||||
|
risk = SL * d.atr_pct.to_numpy()
|
||||||
|
R = net / risk
|
||||||
|
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||||
|
return {"方案": label, "笔数": len(d),
|
||||||
|
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||||
|
"毛R": round((g / risk).mean(), 3), "净均R": round(R.mean(), 3),
|
||||||
|
"R夏普": round(R.mean() / R.std(ddof=1), 3),
|
||||||
|
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||||
|
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||||
|
"均持仓": round(d[f"{var}_b"].mean(), 1),
|
||||||
|
"触发率": f"{(r == TIME).mean()*100:.0f}%"}
|
||||||
|
|
||||||
|
|
||||||
|
def report(d: pd.DataFrame, label: str) -> None:
|
||||||
|
rows = [stat(d, "base", "基线(不看量)")]
|
||||||
|
for t in THRESHOLDS:
|
||||||
|
rows.append(stat(d, f"v{t:g}", f"vr60≥{t:g} 就平"))
|
||||||
|
rows.append(stat(d, f"v{t:g}w", f"vr60≥{t:g} 且浮盈才平"))
|
||||||
|
print(f"\n--- {label}({len(d)} 笔)---")
|
||||||
|
print(pd.DataFrame(rows).to_string(index=False))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA")
|
||||||
|
ap.add_argument("--rows", type=int, default=800_000)
|
||||||
|
ap.add_argument("--workers", type=int, default=3)
|
||||||
|
ap.add_argument("--reuse", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.reuse and OUT.exists():
|
||||||
|
d = pd.read_feather(OUT)
|
||||||
|
else:
|
||||||
|
syms = [s.strip() for s in args.symbols.split(",")]
|
||||||
|
print(f"[放量出场] {len(syms)} 币 × {args.rows} 根 1m\n", flush=True)
|
||||||
|
parts = []
|
||||||
|
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||||
|
fut = {ex.submit(collect, s, args.rows): s for s in syms}
|
||||||
|
for i, f in enumerate(as_completed(fut), 1):
|
||||||
|
r = f.result()
|
||||||
|
print(f" [{i}/{len(syms)}] {fut[f]} {0 if r is None else len(r)}"
|
||||||
|
f"{' ⚠对拍未过' if r is None else ' 对拍通过'}", flush=True)
|
||||||
|
if r is not None:
|
||||||
|
parts.append(r)
|
||||||
|
if not parts:
|
||||||
|
print("无结果")
|
||||||
|
return
|
||||||
|
d = pd.concat(parts, ignore_index=True)
|
||||||
|
d.to_feather(OUT)
|
||||||
|
|
||||||
|
d["date"] = pd.to_datetime(d["date"])
|
||||||
|
d = d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy()
|
||||||
|
print(f"\n实盘口径 {len(d)} 笔 {d.date.min():%Y-%m-%d} ~ {d.date.max():%Y-%m-%d}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("########## 放量出场 vs 基线 ##########")
|
||||||
|
report(d[d.date < IS_START], "样本外")
|
||||||
|
report(d[d.date >= IS_START], "发现期")
|
||||||
|
report(d, "全样本")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Step 53:开仓**之前**那段的量与走势方向。
|
||||||
|
|
||||||
|
用户提出的第三个位置。前两个已有定论:
|
||||||
|
step50 信号根放量 → 差(那根你是买方,付的是资金已推到的价)
|
||||||
|
step52 持仓中放量 → 好(那是资金来接你的货)
|
||||||
|
本步问:入场之前那几根呢?资金是不是已经在里面了。
|
||||||
|
|
||||||
|
这跟 B4/S4 的结构直接相关。它是回抽后转强,所以入场前那几根**通常逆着你走**
|
||||||
|
(mom10 多为负,负得多 = 回抽深)。那么「回抽时缩量」与「回抽时放量」是有
|
||||||
|
明确含义的区分——前者是没人卖,后者是真有人在卖。
|
||||||
|
|
||||||
|
四个因子,全部在信号根收盘时可知:
|
||||||
|
vpre10 / vpre30 前 10 / 30 根的相对量均值(已 shift(1),不含信号根)
|
||||||
|
mom10 / mom60 顺方向动量,ATR 为单位
|
||||||
|
|
||||||
|
⚠️ 必须控 `vr60`(信号根自身的量)。step50 已证明它是强负因子,不控的话
|
||||||
|
前段量会通过相关性借它的力,看着有效其实是同一件事。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
for v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||||
|
os.environ.setdefault(v, "1")
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
pd.set_option("display.width", 340)
|
||||||
|
|
||||||
|
SL = 2.0
|
||||||
|
GATE_BP = 8.0
|
||||||
|
OUT = HERE / "out" / "step53_pre_entry.feather"
|
||||||
|
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
|
||||||
|
FACTORS = [("vpre10", "前10根量"), ("vpre30", "前30根量"),
|
||||||
|
("mom10", "前10根顺向动量"), ("mom60", "前60根顺向动量")]
|
||||||
|
|
||||||
|
|
||||||
|
def collect(sym: str, rows: int):
|
||||||
|
import warnings as _w
|
||||||
|
_w.filterwarnings("ignore")
|
||||||
|
sys.path.insert(0, str(HERE))
|
||||||
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
from step48_signal_timing import collect as _c
|
||||||
|
return _c(sym, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def stat(g: pd.DataFrame, lab: str, denom: int) -> dict:
|
||||||
|
if len(g) < 40:
|
||||||
|
return {"分组": lab, "笔数": len(g), "备注": "样本不足"}
|
||||||
|
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
|
||||||
|
return {"分组": lab, "笔数": len(g), "占比": f"{len(g)/denom*100:.0f}%",
|
||||||
|
"胜率": f"{(g.net > 0).mean()*100:.1f}%",
|
||||||
|
"毛R": round(g.gR.mean(), 3), "净均R": round(g.R.mean(), 3),
|
||||||
|
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||||
|
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def quartiles(d: pd.DataFrame, col: str, name: str, label: str) -> None:
|
||||||
|
x = d[d[col].notna() & np.isfinite(d[col])]
|
||||||
|
if len(x) < 300:
|
||||||
|
print(f" {name}: 样本不足")
|
||||||
|
return
|
||||||
|
x = x.copy()
|
||||||
|
x["bin"] = pd.qcut(x[col], 4, labels=["Q1最低", "Q2", "Q3", "Q4最高"])
|
||||||
|
rows = [stat(g, str(b), len(x)) for b, g in x.groupby("bin", observed=True)]
|
||||||
|
t = pd.DataFrame(rows)
|
||||||
|
med = x.groupby("bin", observed=True)[col].median().round(2).to_dict()
|
||||||
|
t.insert(1, "中位", [med.get(b) for b in t["分组"]])
|
||||||
|
print(f"\n--- {label} / {name}({col})---")
|
||||||
|
print(t.to_string(index=False))
|
||||||
|
|
||||||
|
|
||||||
|
def control_vr60(d: pd.DataFrame, col: str, name: str) -> None:
|
||||||
|
"""在信号根量的高/低两半内部各切一次,看因子是否还独立成立。"""
|
||||||
|
x = d[d[col].notna() & np.isfinite(d[col]) & d.vr60.notna()].copy()
|
||||||
|
x["vr60半"] = np.where(x.vr60 >= x.vr60.median(), "信号根高量", "信号根低量")
|
||||||
|
rows = []
|
||||||
|
for half, g in x.groupby("vr60半"):
|
||||||
|
g = g.copy()
|
||||||
|
g["h"] = pd.qcut(g[col], 2, labels=["低", "高"])
|
||||||
|
lo, hi = g[g.h == "低"], g[g.h == "高"]
|
||||||
|
if min(len(lo), len(hi)) < 40:
|
||||||
|
continue
|
||||||
|
rows.append({"因子": name, "控制层": half, "笔数": len(g),
|
||||||
|
"低组毛R": round(lo.gR.mean(), 3), "高组毛R": round(hi.gR.mean(), 3),
|
||||||
|
"毛R差": round(hi.gR.mean() - lo.gR.mean(), 3),
|
||||||
|
"低组余量": round(lo.net.mean() / lo.tn.mean() * 1e4, 2),
|
||||||
|
"高组余量": round(hi.net.mean() / hi.tn.mean() * 1e4, 2)})
|
||||||
|
if rows:
|
||||||
|
print(pd.DataFrame(rows).to_string(index=False))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--symbols", default="BTC,BNB,ETH,SOL,LINK,LTC,AVAX,XRP,DOGE,ADA")
|
||||||
|
ap.add_argument("--rows", type=int, default=800_000)
|
||||||
|
ap.add_argument("--workers", type=int, default=3)
|
||||||
|
ap.add_argument("--reuse", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.reuse and OUT.exists():
|
||||||
|
d = pd.read_feather(OUT)
|
||||||
|
else:
|
||||||
|
syms = [s.strip() for s in args.symbols.split(",")]
|
||||||
|
print(f"[开仓前的量与方向] {len(syms)} 币 × {args.rows} 根 1m\n", flush=True)
|
||||||
|
parts = []
|
||||||
|
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||||
|
fut = {ex.submit(collect, s, args.rows): s for s in syms}
|
||||||
|
for i, f in enumerate(as_completed(fut), 1):
|
||||||
|
r = f.result()
|
||||||
|
print(f" [{i}/{len(syms)}] {fut[f]} {0 if r is None else len(r)}", flush=True)
|
||||||
|
if r is not None:
|
||||||
|
parts.append(r)
|
||||||
|
if not parts:
|
||||||
|
print("无结果")
|
||||||
|
return
|
||||||
|
d = pd.concat(parts, ignore_index=True)
|
||||||
|
d.to_feather(OUT)
|
||||||
|
|
||||||
|
from step50_volume import prep
|
||||||
|
|
||||||
|
d["date"] = pd.to_datetime(d["date"])
|
||||||
|
d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy())
|
||||||
|
oos, ins = d[d.date < IS_START], d[d.date >= IS_START]
|
||||||
|
print(f"\n实盘口径 {len(d)} 笔 | 样本外 {len(oos)} 发现期 {len(ins)}")
|
||||||
|
print(f"回抽确认:mom10 中位 {d.mom10.median():+.2f} ATR,"
|
||||||
|
f"为负的占 {(d.mom10 < 0).mean()*100:.0f}%")
|
||||||
|
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("########## 一、四个因子各自看(样本外 / 发现期)##########")
|
||||||
|
for col, name in FACTORS:
|
||||||
|
for lab, part in (("样本外", oos), ("发现期", ins)):
|
||||||
|
quartiles(part, col, name, lab)
|
||||||
|
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("########## 二、控信号根自身的量(vr60)后是否还成立 ##########")
|
||||||
|
for col, name in FACTORS:
|
||||||
|
control_vr60(d, col, name)
|
||||||
|
|
||||||
|
print("\n" + "=" * 118)
|
||||||
|
print("########## 三、缩量回抽 vs 放量回抽 ##########")
|
||||||
|
x = d[d.vpre10.notna() & np.isfinite(d.vpre10) & d.mom10.notna()].copy()
|
||||||
|
x["回抽"] = np.where(x.mom10 < 0, "回抽(逆向)", "顺向进场")
|
||||||
|
x["前段量"] = np.where(x.vpre10 >= x.vpre10.median(), "放量", "缩量")
|
||||||
|
rows = []
|
||||||
|
for a in ("回抽(逆向)", "顺向进场"):
|
||||||
|
for b in ("缩量", "放量"):
|
||||||
|
g = x[(x.回抽 == a) & (x.前段量 == b)]
|
||||||
|
rows.append(stat(g, f"{a} × {b}", len(x)))
|
||||||
|
print(pd.DataFrame(rows).to_string(index=False))
|
||||||
|
|
||||||
|
print("\n 与信号根量的相关性(防止是同一件事换个说法)")
|
||||||
|
for col, name in FACTORS:
|
||||||
|
v = x[[col, "vr60"]].dropna()
|
||||||
|
print(f" {name:<14} vs vr60 r = {v[col].corr(v.vr60):+.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user