Compare commits
60
Commits
d4fbe9d905
...
chan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1b500108 | ||
|
|
c22cd48f36 | ||
|
|
b301ad22c9 | ||
|
|
2cd50f1e01 | ||
|
|
6b72ba226b | ||
|
|
080ff7d20e | ||
|
|
3bbd92784c | ||
|
|
77056a8318 | ||
|
|
f14ef540c1 | ||
|
|
7585491481 | ||
|
|
1ee4c3d4e1 | ||
|
|
e21f1103c5 | ||
|
|
84ee59ecfb | ||
|
|
93ed315a4b | ||
|
|
ed741c16b8 | ||
|
|
380d6d61cd | ||
|
|
7292ef5dd4 | ||
|
|
5695d8e983 | ||
|
|
78708ddeee | ||
|
|
e137d92b50 | ||
|
|
9efbae6ade | ||
|
|
be3602f5ba | ||
|
|
409dc2f020 | ||
|
|
30592889aa | ||
|
|
6d93975e67 | ||
|
|
1ffc4fbb18 | ||
|
|
17fe965a7c | ||
|
|
881cc5e77d | ||
|
|
5ffbadb0b2 | ||
|
|
668ebe1e44 | ||
|
|
f62b4f62a5 | ||
|
|
c513455998 | ||
|
|
7435cef61f | ||
|
|
9562b8c598 | ||
|
|
d797adc77e | ||
|
|
040810d48d | ||
|
|
d884de2c36 | ||
|
|
b6ed5c8b50 | ||
|
|
999ad924ad | ||
|
|
15f4aa088e | ||
|
|
3e3d579e35 | ||
|
|
1642b1bbfc | ||
|
|
335d891478 | ||
|
|
af029bc26e | ||
|
|
cef894376c | ||
|
|
be442783a1 | ||
|
|
f11c6507b0 | ||
|
|
bfdb2f2e2a | ||
|
|
06928d1d5f | ||
|
|
431e776905 | ||
|
|
360e4e1c41 | ||
|
|
f716ddd149 | ||
|
|
c57adc8cb0 | ||
|
|
97457b0518 | ||
|
|
0745e73b4f | ||
|
|
545fcc7c77 | ||
|
|
34a8f37b39 | ||
|
|
0a0fd2f682 | ||
|
|
c8a0062707 | ||
|
|
f5f456c523 |
+12
@@ -46,3 +46,15 @@ research/out/*.jsonl.gz
|
||||
research/out/penetration.csv
|
||||
research/out/shadow_*.csv
|
||||
research/out/run_meta_*.json
|
||||
|
||||
# Telegram 凭据。**不要提交**
|
||||
research/live/deploy/tg.env
|
||||
research/.tg.env
|
||||
|
||||
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防
|
||||
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean
|
||||
# 清掉等于两道闸静默失忆
|
||||
/live/deploy/live.env
|
||||
live_state.json
|
||||
live_trades.jsonl
|
||||
signals_live.jsonl
|
||||
|
||||
+52
-21
@@ -15,10 +15,12 @@ class ChanBI():
|
||||
self.sure_time = None
|
||||
self.klc_list = []
|
||||
self.klc_list.append(klc)
|
||||
self._klc_idx = {klc.index}
|
||||
self.end_time = klc.end_time
|
||||
self.start_time = klc.start_time
|
||||
self.macd_hist = 0
|
||||
self.macd_div = 0
|
||||
self._macd_hist = 0
|
||||
self._macd_div = 0
|
||||
self._macd_dirty = False
|
||||
self.seg = None
|
||||
self.height = 0
|
||||
self.width = 0
|
||||
@@ -33,26 +35,57 @@ class ChanBI():
|
||||
def set_seg(self, seg):
|
||||
self.seg = seg
|
||||
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):
|
||||
self.macd_hist = macd_hist
|
||||
def set_macd_div(self, macd_div):
|
||||
self.macd_div = macd_div
|
||||
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.pre.macd_hist == 0:
|
||||
self.macd_div = 0.0
|
||||
self._macd_div = 0.0
|
||||
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)
|
||||
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 klu in klc.klu_list:
|
||||
if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
|
||||
self.macd_hist += klu.macdhist
|
||||
if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
|
||||
self.macd_hist -= klu.macdhist
|
||||
h = klu.macdhist
|
||||
if up:
|
||||
if h > 0:
|
||||
acc += h
|
||||
elif h < 0:
|
||||
acc -= h
|
||||
self._macd_hist = acc
|
||||
def check_bi_zs_overlap(self):
|
||||
if self.next and self.next.next:
|
||||
if self.dir == Chan_BI_DIR.UP:
|
||||
@@ -95,6 +128,10 @@ class ChanBI():
|
||||
self.start_klc = klc
|
||||
self.klc_list = []
|
||||
self.klc_list.append(klc)
|
||||
# klc_list 被整个换掉,去重集合与惰性缓存都要跟着重置,
|
||||
# 否则后续 add_klc 会以为旧下标还在里面而漏加
|
||||
self._klc_idx = {klc.index}
|
||||
self._macd_dirty = True
|
||||
self.high = klc.high
|
||||
self.low = klc.low
|
||||
self.dir = ddir
|
||||
@@ -103,20 +140,14 @@ class ChanBI():
|
||||
def set_next(self, bi):
|
||||
self.next = bi
|
||||
def add_klc(self, klc):
|
||||
added = False
|
||||
if len(self.klc_list) > 0:
|
||||
for index in range(0, len(self.klc_list)):
|
||||
if self.klc_list[index].index == klc.index:
|
||||
added = True
|
||||
break
|
||||
if not added:
|
||||
# 去重原本是对 klc_list 线性扫描,配合下面每次全量重算的 macdhist,
|
||||
# 让「往一笔里加 k 根 KLC」变成 O(k²)。改用下标集合,O(1)。
|
||||
if klc.index not in self._klc_idx:
|
||||
self._klc_idx.add(klc.index)
|
||||
self.klc_list.append(klc)
|
||||
#print(self.start_time, klc.start_time)
|
||||
#print(klc.end_time, klc.index)
|
||||
self.end_klc = klc
|
||||
self.end_time = klc.klu_list[-1].time
|
||||
self.cal_macdhist()
|
||||
self.cal_macd_div()
|
||||
self._macd_dirty = True
|
||||
def append_klc_list(self, klc_list):
|
||||
self.klc_list.append(klc_list)
|
||||
def get_decimal(self, value):
|
||||
|
||||
@@ -43,6 +43,29 @@ class ChanSEG():
|
||||
self.macd_hist = macd_hist
|
||||
def set_macd_div(self, macd_div):
|
||||
self.macd_div = macd_div
|
||||
def cal_macdhist(self):
|
||||
# 线段面积 = 同向笔 MACD 柱面积之和(与笔面积口径一致)
|
||||
acc = 0.0
|
||||
seg_dir_name = getattr(self.dir, 'name', None)
|
||||
for bi in self.bi_list:
|
||||
if bi is None:
|
||||
continue
|
||||
if getattr(getattr(bi, 'dir', None), 'name', None) != seg_dir_name:
|
||||
continue
|
||||
acc += float(bi.macd_hist or 0)
|
||||
self.macd_hist = acc
|
||||
return acc
|
||||
def cal_macd_div(self):
|
||||
# 与前一个同向线段比面积:seg.pre 是反向邻段,pre.pre 才是同向
|
||||
self.macd_div = 0.0
|
||||
prev = self.pre.pre if self.pre and self.pre.pre else None
|
||||
if prev is None:
|
||||
return 0.0
|
||||
prev_hist = float(prev.macd_hist or 0)
|
||||
if prev_hist == 0:
|
||||
return 0.0
|
||||
self.macd_div = float(self.macd_hist or 0) / prev_hist
|
||||
return self.macd_div
|
||||
def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI):
|
||||
self.end_bi = bi
|
||||
if bi and bi.is_sure:
|
||||
|
||||
@@ -668,13 +668,22 @@ class BiBuilderMixin:
|
||||
return bi_list
|
||||
|
||||
def check_top_fx(self, last_bottom, klc):
|
||||
if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100):
|
||||
#严格笔
|
||||
#last_bottom_high = max(last_bottom.high, last_bottom.pre.high, last_bottom.next.high)
|
||||
#缠论原著笔
|
||||
last_bottom_high = last_bottom.high
|
||||
if (last_bottom_high > klc.pre.low or last_bottom_high > klc.next.low) and (klc.index - last_bottom.index < 100):
|
||||
#print(klc.end_time, "check_top_fx False")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_bottom_fx(self, last_top, klc):
|
||||
if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
#严格笔
|
||||
#last_top_low = min(last_top.low, last_top.pre.low, last_top.next.low)
|
||||
#缠论原著笔
|
||||
last_top_low = last_top.low
|
||||
if (last_top_low < klc.pre.high or last_top_low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
return False
|
||||
return True
|
||||
# 线段内的中枢
|
||||
|
||||
@@ -54,8 +54,14 @@ class IndicatorsBuilderMixin:
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
fast = 26
|
||||
slow = 52
|
||||
"""算指标并一次性挂到 df 上。
|
||||
|
||||
这里不逐列 `df['x'] = ...`:那样每一列都触发一次 BlockManager 插入,
|
||||
30 多列的开销比全部 TA 计算本身还大(2001 行实测 TA 合计 2.5ms,
|
||||
逐列赋值 3.6ms)。增量路径每根都要走一遍,这笔开销是白付的。
|
||||
"""
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
@@ -75,40 +81,43 @@ class IndicatorsBuilderMixin:
|
||||
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
|
||||
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
|
||||
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
||||
df['bb2633upper'] = bb2633['upperband']
|
||||
df['bb2633lower'] = bb2633['lowerband']
|
||||
df['bbp2633'] = bbp2633
|
||||
df['bb2633middle'] = bb2633['middleband']
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['bbup365'] = bb365['upperband']
|
||||
df['bblow365'] = bb365['lowerband']
|
||||
df['bbp365'] = bbp365
|
||||
df['bbup120'] = bb120['upperband']
|
||||
df['bblow120'] = bb120['lowerband']
|
||||
df['bbp120'] = bbp120
|
||||
df['bbup30'] = bb30['upperband']
|
||||
df['bblow30'] = bb30['lowerband']
|
||||
df['bbmiddle30'] = bb30_middle # 添加bb30中轨
|
||||
df['bbp30'] = bbp30
|
||||
df['bbup302'] = bb302['upperband']
|
||||
df['bblow302'] = bb302['lowerband']
|
||||
df['bbp302'] = bbp302
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema5'] = ta.EMA(df, timeperiod=5)
|
||||
df['ema10'] = ta.EMA(df, timeperiod=10)
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
df['ema104'] = ta.EMA(df, timeperiod=104)
|
||||
df['ema156'] = ta.EMA(df, timeperiod=156)
|
||||
df['ema208'] = ta.EMA(df, timeperiod=208)
|
||||
df['ema26'] = ta.EMA(df, timeperiod=26)
|
||||
df['ema13'] = ta.EMA(df, timeperiod=13)
|
||||
df['ema7'] = ta.EMA(df, timeperiod=7)
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||
df['volume_ratio'] = self.cal_volume_ratio(df)
|
||||
return df
|
||||
cols = {
|
||||
'bb2633upper': bb2633['upperband'],
|
||||
'bb2633lower': bb2633['lowerband'],
|
||||
'bbp2633': bbp2633,
|
||||
'bb2633middle': bb2633['middleband'],
|
||||
'atr': ta.ATR(df, timeperiod=14),
|
||||
'bbup365': bb365['upperband'],
|
||||
'bblow365': bb365['lowerband'],
|
||||
'bbp365': bbp365,
|
||||
'bbup120': bb120['upperband'],
|
||||
'bblow120': bb120['lowerband'],
|
||||
'bbp120': bbp120,
|
||||
'bbup30': bb30['upperband'],
|
||||
'bblow30': bb30['lowerband'],
|
||||
'bbmiddle30': bb30_middle,
|
||||
'bbp30': bbp30,
|
||||
'bbup302': bb302['upperband'],
|
||||
'bblow302': bb302['lowerband'],
|
||||
'bbp302': bbp302,
|
||||
'macd': macd['macd'],
|
||||
'macdsignal': macd['macdsignal'],
|
||||
'macdhist': macd['macdhist'],
|
||||
}
|
||||
for _p, _n in ((5, 'ema5'), (10, 'ema10'), (24, 'ema24'), (52, 'ema52'),
|
||||
(104, 'ema104'), (156, 'ema156'), (208, 'ema208'),
|
||||
(26, 'ema26'), (13, 'ema13'), (7, 'ema7')):
|
||||
cols[_n] = ta.EMA(df, timeperiod=_p)
|
||||
cols['rsi'] = ta.RSI(df, timeperiod=14)
|
||||
cols['volume_ratio'] = self.cal_volume_ratio(df)
|
||||
|
||||
# 重复调用(增量路径每根都会)时先摘掉旧列,否则 concat 出重名列。
|
||||
# 摘掉再接回末尾,列序与逐列覆盖的结果一致。
|
||||
new = pd.DataFrame(cols, index=df.index)
|
||||
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):
|
||||
klu_list = self.get_klu_list(dataframe)
|
||||
|
||||
@@ -128,23 +128,28 @@ class KlineBuilderMixin:
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
df = dataframe.copy()
|
||||
# 计算过去N根K线的平均成交量
|
||||
df['avg_volume'] = df['volume'].rolling(window=window).mean()
|
||||
# 计算量比
|
||||
df['volume_ratio'] = df['volume'] / df['avg_volume']
|
||||
# 填充缺失值(前N根K线)
|
||||
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
|
||||
return df['volume_ratio']
|
||||
"""当根量 / 前 window 根均量。前 window-1 根无基准,填 1.0。
|
||||
|
||||
原写法先 `dataframe.copy()` 再挂两列——为算一列 rolling 复制了整张
|
||||
四十列的表。直接在 Series 上算,结果逐值相同。
|
||||
"""
|
||||
vol = dataframe['volume']
|
||||
return (vol / vol.rolling(window=window).mean()).fillna(1.0).rename('volume_ratio')
|
||||
|
||||
def cal_kl_data(self, dataframe:DataFrame):
|
||||
"""按行构造 KLU 链。
|
||||
@@ -249,7 +254,12 @@ class KlineBuilderMixin:
|
||||
for klu in klu_list:
|
||||
self._push_klu_into_klc_list(klc_list, klu, last_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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Bitget v2 合约 REST 的最小客户端,只覆盖实盘执行要用的几个端点。
|
||||
|
||||
## 为什么不用 Hummingbot 下单
|
||||
|
||||
Hummingbot 的 Bitget 连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单。
|
||||
于是 `PositionExecutor` 的止损只能在本地控制循环里盯价、触发时才发市价单——
|
||||
**进程一死仓位就是裸的**。
|
||||
|
||||
而交易所本身完全支持:`place-order` 有 `presetStopLossPrice`,下单时就把止损
|
||||
挂到服务端。所以整个结构变成两个调用,止损从入场那一刻起就不依赖我们的进程
|
||||
存活。绕过连接器不是图省事,是为了消掉一整类故障。
|
||||
|
||||
## 止盈为什么不用 presetStopSurplusPrice
|
||||
|
||||
它触发后按**市价**执行。而成本模型里止盈是 maker——那 60% 的出场不吃滑点、
|
||||
按 maker 费率计(见 `lib/shadow_budget.LEG_IS_TAKER`)。用 preset 会让这部分
|
||||
变成 taker,预算模型就不成立了。所以止盈单独挂 `post_only` 的 reduce-only
|
||||
限价单。
|
||||
|
||||
止损反过来:必须是市价。stop-limit 在急跌里可能不成交,损失远大于省下的费。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
BASE = "https://api.bitget.com"
|
||||
PRODUCT = "usdt-futures"
|
||||
MARGIN_COIN = "USDT"
|
||||
|
||||
|
||||
class BitgetError(RuntimeError):
|
||||
def __init__(self, code: str, msg: str, path: str):
|
||||
super().__init__(f"{path} → [{code}] {msg}")
|
||||
self.code, self.msg = code, msg
|
||||
|
||||
|
||||
class Bitget:
|
||||
def __init__(self, key: str = "", secret: str = "", passphrase: str = "",
|
||||
dry: bool = False):
|
||||
self.key = key or os.environ.get("BITGET_API_KEY", "")
|
||||
self.secret = secret or os.environ.get("BITGET_API_SECRET", "")
|
||||
# 两个名字都收:另外两项是 BITGET_API_KEY / BITGET_API_SECRET,
|
||||
# 这一项却没有 API_,很容易顺手写成 BITGET_API_PASSPHRASE。写错的
|
||||
# 后果是"密钥像是填了"但签名一直失败,排查起来很绕
|
||||
self.passphrase = (passphrase
|
||||
or os.environ.get("BITGET_PASSPHRASE", "")
|
||||
or os.environ.get("BITGET_API_PASSPHRASE", ""))
|
||||
self.dry = dry
|
||||
self._sess = None
|
||||
|
||||
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
|
||||
msg = f"{ts}{method.upper()}{path}{body}"
|
||||
return base64.b64encode(hmac.new(
|
||||
self.secret.encode(), msg.encode(), hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
async def _req(self, method: str, path: str, params: dict | None = None,
|
||||
body: dict | None = None) -> dict:
|
||||
import aiohttp
|
||||
if self._sess is None:
|
||||
self._sess = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=15))
|
||||
qs = ""
|
||||
if params:
|
||||
qs = "?" + "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
||||
payload = json.dumps(body) if body else ""
|
||||
ts = str(int(time.time() * 1000))
|
||||
headers = {
|
||||
"ACCESS-KEY": self.key,
|
||||
"ACCESS-SIGN": self._sign(ts, method, path + qs, payload),
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"ACCESS-TIMESTAMP": ts,
|
||||
"Content-Type": "application/json",
|
||||
"locale": "en-US",
|
||||
}
|
||||
async with self._sess.request(method, BASE + path + qs,
|
||||
headers=headers,
|
||||
data=payload or None) as r:
|
||||
d = await r.json()
|
||||
if str(d.get("code")) != "00000":
|
||||
raise BitgetError(str(d.get("code")), str(d.get("msg")), path)
|
||||
return d.get("data")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._sess is not None:
|
||||
await self._sess.close()
|
||||
self._sess = None
|
||||
|
||||
# ── 只读 ──────────────────────────────────────────────────────
|
||||
async def contracts(self) -> dict:
|
||||
"""合约规则。用于数量步长与价格 tick。"""
|
||||
d = await self._req("GET", "/api/v2/mix/market/contracts",
|
||||
{"productType": PRODUCT})
|
||||
return {c["symbol"]: c for c in d}
|
||||
|
||||
async def positions(self) -> list:
|
||||
d = await self._req("GET", "/api/v2/mix/position/all-position",
|
||||
{"productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN})
|
||||
return [p for p in (d or []) if float(p.get("total") or 0) != 0]
|
||||
|
||||
async def history_positions(self, start_ms: int | None = None,
|
||||
limit: int = 100) -> list:
|
||||
"""已平仓位,用来取**已实现盈亏**。
|
||||
|
||||
为什么必须问交易所而不是自己算:止损与止盈都挂在交易所侧成交,本进程
|
||||
看不到成交价;而且要算准还得含手续费与资金费。这个端点的 `netProfit`
|
||||
已经是 `pnl + totalFunding + openFee + closeFee`,正是日亏损上限该用
|
||||
的数。自己按标记价估会把费用漏掉,方向还总是偏乐观。
|
||||
|
||||
返回形状按文档是 `data.list`,但也见过直接给数组的写法,两种都收。
|
||||
时间字段文档写 `ctime/utime`,官方 TS 类型写 `cTime/uTime`,同样都读。
|
||||
"""
|
||||
p: dict = {"productType": PRODUCT, "limit": str(limit)}
|
||||
if start_ms:
|
||||
p["startTime"] = str(int(start_ms))
|
||||
d = await self._req("GET", "/api/v2/mix/position/history-position", p)
|
||||
if isinstance(d, dict):
|
||||
return list(d.get("list") or [])
|
||||
return list(d or [])
|
||||
|
||||
async def fee_rate(self, symbol: str) -> dict:
|
||||
"""账户在该合约上的**实际**费率档。
|
||||
|
||||
这一项决定 ATR 门控阈值(约 5 + 1.1×taker_bp),进而决定可交易币池。
|
||||
接口的合约默认档是 VIP0,不是账户档,必须问这个端点。
|
||||
"""
|
||||
return await self._req("GET", "/api/v2/mix/market/query-position-lever",
|
||||
{"symbol": symbol, "productType": PRODUCT})
|
||||
|
||||
async def account(self) -> dict:
|
||||
return await self._req("GET", "/api/v2/mix/account/account",
|
||||
{"symbol": "BTCUSDT", "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN})
|
||||
|
||||
# ── 写 ────────────────────────────────────────────────────────
|
||||
async def set_leverage(self, symbol: str, lev: int,
|
||||
hold_side: str | None = None) -> dict:
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN, "leverage": str(lev)}
|
||||
if hold_side:
|
||||
body["holdSide"] = hold_side
|
||||
return await self._req("POST", "/api/v2/mix/account/set-leverage",
|
||||
body=body)
|
||||
|
||||
async def set_margin_mode(self, symbol: str,
|
||||
mode: str = "isolated") -> dict:
|
||||
return await self._req("POST", "/api/v2/mix/account/set-margin-mode",
|
||||
body={"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN,
|
||||
"marginMode": mode})
|
||||
|
||||
async def set_position_mode(self, mode: str = "one_way_mode") -> dict:
|
||||
"""单向 / 双向持仓。**按 productType 生效,不是按 symbol。**
|
||||
|
||||
必须显式设,因为它决定下单体的语法,两者不匹配会被整体拒单:
|
||||
|
||||
单向:side=buy/sell,**不带** tradeSide;平仓用 reduceOnly=YES
|
||||
双向:side + tradeSide=open/close;reduceOnly 在这个模式下无效
|
||||
|
||||
实盘上曾因为带着 tradeSide 打到单向账户,连续 8 次下单全被 40774 拒掉
|
||||
(4 个信号 × 2 条腿),而链路其余部分完全正常。
|
||||
|
||||
我们永不同时持有两个方向,所以单向是对的模式;且 reduceOnly 只在单向
|
||||
下可用,而出场腿依赖它防止反手开出反向仓。
|
||||
|
||||
交易所侧有持仓或挂单时切换会失败——所以调用点放在 reconcile 之后。
|
||||
"""
|
||||
return await self._req("POST", "/api/v2/mix/account/set-position-mode",
|
||||
body={"productType": PRODUCT, "posMode": mode})
|
||||
|
||||
async def entry_with_stop(self, symbol: str, side: str, size: str,
|
||||
stop_px: str, client_oid: str) -> dict:
|
||||
"""市价入场,**同时**把止损挂到服务端。
|
||||
|
||||
`presetStopLossPrice` 触发后按市价执行,这正是成本模型要的(止损是
|
||||
taker)。`clientOid` 给交易所级幂等——重发同一个 oid 会被拒,比本地
|
||||
去重可靠,因为「已发出但没收到回复」这种情况本地判不了。
|
||||
|
||||
**不带 `tradeSide`**:账户是单向持仓,带了会被 40774 整体拒单。
|
||||
"""
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side,
|
||||
"orderType": "market", "clientOid": client_oid,
|
||||
"presetStopLossPrice": stop_px}
|
||||
if self.dry:
|
||||
print(f" [dry] 入场+止损 {body}", flush=True)
|
||||
return {"orderId": "dry", "clientOid": client_oid}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def tp_limit(self, symbol: str, side: str, size: str, px: str,
|
||||
client_oid: str) -> dict:
|
||||
"""挂 maker 止盈。
|
||||
|
||||
`side` 传的是**平仓方向**(多头止盈是 sell)。`post_only` 保证是 maker:
|
||||
成本模型里止盈那 60% 按 maker 费率计且不吃滑点,用 taker 会破坏预算。
|
||||
|
||||
单向持仓下平仓的写法是 `side` 取反 + `reduceOnly=YES`,**不带**
|
||||
`tradeSide`。`reduceOnly` 也正好只在单向模式下有效,它防止反手开出
|
||||
一个反向仓。
|
||||
"""
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side,
|
||||
"orderType": "limit", "price": px, "force": "post_only",
|
||||
"reduceOnly": "YES", "clientOid": client_oid}
|
||||
if self.dry:
|
||||
print(f" [dry] 止盈限价 {body}", flush=True)
|
||||
return {"orderId": "dry", "clientOid": client_oid}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def close_market(self, symbol: str, hold_side: str,
|
||||
size: str, client_oid: str) -> dict:
|
||||
"""市价平(超时腿与对账用)。
|
||||
|
||||
同 tp_limit:单向持仓下是 `side` 取反 + `reduceOnly`,不带 `tradeSide`。
|
||||
"""
|
||||
side = "sell" if hold_side == "long" else "buy"
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginMode": "isolated", "marginCoin": MARGIN_COIN,
|
||||
"size": size, "side": side,
|
||||
"orderType": "market", "reduceOnly": "YES",
|
||||
"clientOid": client_oid}
|
||||
if self.dry:
|
||||
print(f" [dry] 市价平 {body}", flush=True)
|
||||
return {"orderId": "dry"}
|
||||
return await self._req("POST", "/api/v2/mix/order/place-order",
|
||||
body=body)
|
||||
|
||||
async def cancel_all(self, symbol: str) -> dict:
|
||||
body = {"symbol": symbol, "productType": PRODUCT,
|
||||
"marginCoin": MARGIN_COIN}
|
||||
if self.dry:
|
||||
print(f" [dry] 撤全部挂单 {symbol}", flush=True)
|
||||
return {}
|
||||
return await self._req("POST", "/api/v2/mix/order/cancel-all-orders",
|
||||
body=body)
|
||||
@@ -0,0 +1,170 @@
|
||||
# 小额实盘执行器 · 部署
|
||||
|
||||
## 为什么是两台机
|
||||
|
||||
Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;信号是新加坡那台采集器
|
||||
算出来的。于是分工固定成:
|
||||
|
||||
```
|
||||
新加坡(采集/研究机) AWS(生产机)
|
||||
shadow_hb.py 算信号 ship_signals.py 拉总线
|
||||
└→ ~/chan-live/state/ └→ /var/lib/chan-live/state/
|
||||
signals_live.jsonl ──ssh tail──→ signals_live.jsonl
|
||||
live_exec.py 读总线 → Bitget REST
|
||||
```
|
||||
|
||||
**生产机上不装采集侧的任何东西**(Docker / Hummingbot / pandas / chanlun)。
|
||||
理由不是洁癖,是四条具体代价:
|
||||
|
||||
1. `live_state.json` 原先落在 `research/out/`,而那个目录 `shadow_hb.py` 会在
|
||||
CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件装
|
||||
的是 `MAX_DAY_LOSS` 累计与已处理信号键,**被清掉不报错,只是两道闸静默
|
||||
失效**。现在改到 `/var/lib/chan-live`。
|
||||
2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。
|
||||
3. 研究侧的探针 OOM 过一次(14.9 GB、负载 12)。当时若有仓位在场,执行器会被
|
||||
一起杀掉,只剩交易所侧止损兜着。
|
||||
4. 依赖面:执行器只需标准库 + `aiohttp`。原先为读两个常量 import 研究侧的
|
||||
`step43`,把 numpy/pandas/pyarrow 全拖进实盘进程。
|
||||
|
||||
`install.sh` 里有一条断言会真的挡住第 4 条回归。
|
||||
|
||||
## 机器要求
|
||||
|
||||
CPU 无所谓(执行器几乎不算东西,信号 6.8 个/天)。要的是:
|
||||
|
||||
- 出口 IP 固定,且已加进 Bitget 该 key 的白名单
|
||||
- `chrony` 能同步。**这一条是硬要求**:`LIVE_STALE_S` 那道闸靠两机时钟一致才
|
||||
有意义,采集机时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的参考价会被
|
||||
当成新鲜的照做
|
||||
- 能 ssh 到采集机(拉总线用)
|
||||
|
||||
## 一、生产机(AWS)
|
||||
|
||||
```bash
|
||||
# 1. 取代码。只需要 live/ 这一个子树,但整仓克隆更省事
|
||||
git clone -b chan <repo> /tmp/chan && cd /tmp/chan
|
||||
sudo ./live/deploy/install.sh
|
||||
# 或让它自己克隆:sudo REPO=<repo> ./live/deploy/install.sh
|
||||
|
||||
# 2. 填密钥与参数
|
||||
sudo vi /etc/chan-live/live.env
|
||||
```
|
||||
|
||||
`live.env` 里必须改的四项:`BITGET_API_KEY` / `SECRET` / `PASSPHRASE` /
|
||||
`SHIP_FROM`。密钥权限**只勾只读 + 交易,不要勾提币**。
|
||||
|
||||
```bash
|
||||
# 3. 装拉总线用的 ssh key
|
||||
sudo -u chan ssh-keygen -t ed25519 -N '' \
|
||||
-f /var/lib/chan-live/home/.ssh/id_ed25519
|
||||
sudo cat /var/lib/chan-live/home/.ssh/id_ed25519.pub
|
||||
# 把这一行加到采集机的 ~/.ssh/authorized_keys
|
||||
|
||||
# 4. 空跑验全链(不下真单)
|
||||
sudo ./live/deploy/dryrun.sh
|
||||
```
|
||||
|
||||
`dryrun.sh` 验的是那些"上线才暴露、且暴露方式是花钱"的环节:密钥能不能用、
|
||||
IP 白名单对不对、chrony 同步没有、ssh 通不通、对端总线有没有信号、数量与价位
|
||||
取整合不合交易所规则。**不要跳过。**
|
||||
|
||||
```bash
|
||||
# 5. 真跑
|
||||
sudo systemctl enable --now chan-live-ship chan-live-exec
|
||||
./live/deploy/status.sh
|
||||
```
|
||||
|
||||
## 二、采集机(新加坡)
|
||||
|
||||
采集器要重启一次才会开始往总线写——`signal_bus.emit` 是后加的,跑着的进程没有
|
||||
加载。重启会丢已采的几分钟,数据本身不受影响(CSV 是追加的)。
|
||||
|
||||
```bash
|
||||
cd <repo> && git pull
|
||||
docker stop shadow && docker rm shadow
|
||||
SHADOW_SITE=sg-tencent SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC \
|
||||
bash research/live/deploy/start.sh
|
||||
```
|
||||
|
||||
`start.sh` 会把 `$HOME/chan-live/state` 挂进容器成 `/bus`,总线落在
|
||||
`$HOME/chan-live/state/signals_live.jsonl`。**总线刻意放在仓库外**,因为它是
|
||||
交给另一台机的交接点,而仓库会被 git 动。
|
||||
|
||||
确认在写:
|
||||
|
||||
```bash
|
||||
ls -la ~/chan-live/state/
|
||||
# 等一个信号(6.8 个/天,可能要等几小时)
|
||||
tail -f ~/chan-live/state/signals_live.jsonl
|
||||
```
|
||||
|
||||
## 日常
|
||||
|
||||
```bash
|
||||
./live/deploy/status.sh # 一屏体检
|
||||
journalctl -u chan-live-exec -f # 执行器日志
|
||||
journalctl -u chan-live-ship -f # 搬运日志
|
||||
```
|
||||
|
||||
**怎么判健康:** 信号 6.8 个/天,所以"很久没有新信号"是正常的,不能当健康
|
||||
指标。要看的是 `chan-live-ship` 的心跳(每 5 分钟一条),里面报 ssh 在线时长与
|
||||
重连次数。管道死了但进程还活着是这里最危险的状态——`ServerAliveInterval=15`
|
||||
负责让它变成一次可见的断开。
|
||||
|
||||
**Telegram** 在 `live.env` 里填 `TG_TOKEN` / `TG_CHAT` 就开。启动时会推一条
|
||||
"执行器启动",兼作通道自检——配错了当场就知道,而不是等几小时后第一个真信号
|
||||
来时才发现。之后每小时一条在线(`TG_HB_MIN`,默认 60),带建仓数、当日盈亏
|
||||
和搬运 ssh 新鲜度——执行器活着不代表上游还在投信号。推开仓、平仓(带已实现
|
||||
盈亏)、被硬约束挡住、报错、对账平仓、跨日结算;不推信号过期跳过(常态)和
|
||||
5 分钟日志心跳。约 55 条/天上限。
|
||||
|
||||
## 停机与回滚
|
||||
|
||||
```bash
|
||||
# 停新开仓,但保留在场仓位的管理(48 分钟超时平仓在执行器进程里)
|
||||
sudo systemctl stop chan-live-ship
|
||||
|
||||
# 全停。执行器收到 SIGTERM 会**撤挂单 + 市价平掉在场仓位**再退出
|
||||
# (内部平仓上限 60s,systemd 给了 90s 停机窗口)
|
||||
sudo systemctl stop chan-live-exec
|
||||
|
||||
# 代码回滚
|
||||
cd /opt/chan && sudo git reset --hard <sha> && sudo systemctl restart chan-live-exec
|
||||
```
|
||||
|
||||
⚠️ **状态目录 `/var/lib/chan-live` 不要跟着回滚。** 它存的是当日计数与已处理
|
||||
信号键;清掉等于日上限归零、且可能重开已经做过的仓。
|
||||
|
||||
## 故障处理
|
||||
|
||||
| 症状 | 大概率原因 |
|
||||
|---|---|
|
||||
| 启动即 `40018` / 签名错 | 出口 IP 不在白名单,或密钥抄错。`curl https://api.ipify.org` 对一下 |
|
||||
| 搬运日志 `Permission denied (publickey)` | 第 3 步的 pubkey 没加到采集机 |
|
||||
| 搬运在线但一直没信号 | 采集机没重启过(`signal_bus.emit` 没加载),或对端总线路径不对 |
|
||||
| Telegram 在线报「读不到搬运」 | 搬运没起,或还是没落 `ship_alive.json` 的旧版本,两边一起重启 |
|
||||
| Telegram 在线报「ssh 已断开」 | 采集机 ssh 断了,搬运在重连。看 `chan-live-ship` 日志 |
|
||||
| 日志 `⛔ 时间倒流 Xs` | 两机时钟不同步,**staleness 闸已不可信**。查两边 `chronyc tracking` |
|
||||
| 信号收到但都被跳过 | `age > LIVE_STALE_S`。看是搬运慢还是时钟偏;也可能是重连重放的旧信号(这种跳过是对的) |
|
||||
| `systemctl status` 显示 start-limit-hit | 5 分钟内重启 5 次,systemd 停手了。先看 journal 找真因,再 `systemctl reset-failed` |
|
||||
| 执行器起不来,报缺 numpy/pandas | 有人给生产侧加了研究侧的 import。`install.sh` 的依赖断言就是挡这个 |
|
||||
|
||||
## 已知的退化边界
|
||||
|
||||
- **进程死掉不会变成裸仓。** 止损与止盈都挂在交易所侧(`presetStopLossPrice`
|
||||
与 post-only reduce-only 限价单),只有 48 分钟超时平仓在本进程。所以进程死
|
||||
掉的后果是持仓超过 48 根,不是失去保护。
|
||||
- **崩溃与主动停机的处理不同,是刻意的。** 崩溃后 systemd 几秒内重启,
|
||||
`reconcile` 接着撤挂单 + 平掉遗留仓位,空窗期由交易所侧止损兜着。主动
|
||||
`systemctl stop` 则在退出前就平掉——因为停机后没人重启,仓位会一直挂到止损
|
||||
或止盈,超时腿丢了就不是回测那个出场结构了。
|
||||
- **断线超过 20s 就等于漏掉那期间的信号。** 重连会把总线重放上来,但旧信号会被
|
||||
staleness 闸挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。
|
||||
- **重启时会平掉交易所上已有的仓位**(`reconcile`)。接管要重建入场价、ATR、
|
||||
剩余半仓状态和已过根数,任一项猜错就跑成另一个收益结构,所以选择平掉。
|
||||
- **日亏损上限依赖 `watch()` 循环**。止损与止盈在交易所侧成交,本进程收不到
|
||||
通知,所以有个 10s 轮询去 `history-position` 取 `netProfit`(含手续费与资金
|
||||
费)记回闸。这个循环停了,`pnl_day` 会恒为 0,`MAX_DAY_LOSS` 静默失效。
|
||||
心跳里会打 `当日 PnL x/-20`,值一直是 0.00 而又确实有平仓,就是它出了问题。
|
||||
- **平仓后 `MAX_OPEN` 名额由 `watch()` 释放**,不是立刻。最坏延迟 10s。若
|
||||
历史记录还没落库,会等下一轮,日志里打"历史未就绪,下轮再结算"。
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=chan 小额实盘执行器(读信号总线,直接调 Bitget REST)
|
||||
# 搬运挂了执行器仍要活着——它得继续管在场仓位的 48 分钟超时平仓。
|
||||
# 所以这里只写 Wants(弱依赖),不写 Requires
|
||||
Wants=network-online.target chan-live-ship.service
|
||||
After=network-online.target chan-live-ship.service
|
||||
# 频繁重启说明有真问题,别让它无限打交易所。5 分钟内起 5 次就停下等人。
|
||||
# 注意这两项在 systemd 229+ 属于 [Unit],写在 [Service] 里会被忽略
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=chan
|
||||
Group=chan
|
||||
WorkingDirectory=/opt/chan
|
||||
# 密钥与参数在这个文件里,权限必须 600。用 EnvironmentFile 而不是
|
||||
# Environment=,后者会出现在 `systemctl show` 的输出里
|
||||
EnvironmentFile=/etc/chan-live/live.env
|
||||
ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/live_exec.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# 收到 stop 时给足时间:执行器接到 SIGTERM 会撤挂单 + 平掉在场仓位再退出
|
||||
# (live_exec.shutdown(),内部平仓上限 60s)。默认的 90s 停机窗口留了余量。
|
||||
# 不需要 KillSignal=SIGINT——SIGTERM 已在代码里显式挂了处理器
|
||||
TimeoutStopSec=90
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=chan-live-exec
|
||||
|
||||
# ── 收紧权限 ──────────────────────────────────────────────────────
|
||||
# 生产进程只需要读 /opt/chan 和读写状态目录,别的一概不给。这几条很廉价,
|
||||
# 但真出了远程代码执行,爆炸半径小很多——而这个进程手里有交易权限的密钥
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/chan-live
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryMax=512M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=chan 信号搬运(把采集机的总线拉到本机)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=chan
|
||||
Group=chan
|
||||
WorkingDirectory=/opt/chan
|
||||
EnvironmentFile=/etc/chan-live/live.env
|
||||
# SHIP_FROM 在 live.env 里给,形如 sg-collector 或 user@1.2.3.4
|
||||
ExecStart=/opt/chan/.venv/bin/python /opt/chan/live/ship_signals.py \
|
||||
--from ${SHIP_FROM} --remote-bus ${SHIP_REMOTE_BUS}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=chan-live-ship
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
# chan 用户的家目录放在 /var/lib/chan-live/home,只装拉总线用的那一把 ssh
|
||||
# key。这样 ProtectHome=true 挡住 /home 与 /root 的同时,ssh 仍能读到
|
||||
# ~/.ssh(它在 /var/lib 下,不受 ProtectHome 影响),也能写 known_hosts
|
||||
ProtectHome=true
|
||||
Environment=HOME=/var/lib/chan-live/home
|
||||
ReadWritePaths=/var/lib/chan-live
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# 空跑验全链:真连交易所读规则/持仓,**不下任何真单**。
|
||||
#
|
||||
# 上真单之前必须过这一步。它验的是那些"上线才会暴露、且暴露方式是花钱"的
|
||||
# 环节:密钥能不能用、IP 白名单对不对、时钟同不同步、搬运通不通、
|
||||
# 数量与价位取整合不合交易所规则。
|
||||
set -euo pipefail
|
||||
|
||||
APP=/opt/chan
|
||||
CONF=/etc/chan-live/live.env
|
||||
STATE=/var/lib/chan-live
|
||||
USER_NAME=chan
|
||||
SECS="${SECS:-90}"
|
||||
|
||||
die() { echo "⛔ $*" >&2; exit 1; }
|
||||
ok() { echo " ✓ $*"; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "要 root:sudo $0"
|
||||
[[ -f "$CONF" ]] || die "缺 $CONF,先跑 install.sh"
|
||||
|
||||
set -a; . "$CONF"; set +a
|
||||
|
||||
echo "── 1. 配置自检 ──"
|
||||
for v in BITGET_API_KEY BITGET_API_SECRET BITGET_PASSPHRASE SHIP_FROM; do
|
||||
[[ -n "${!v:-}" ]] || die "$CONF 里 $v 还是空的"
|
||||
done
|
||||
ok "密钥三项与 SHIP_FROM 都已填"
|
||||
[[ "$LIVE_HOME" != /opt/chan* ]] || die "LIVE_HOME 不能指到仓库里(会被 git 清掉)"
|
||||
ok "LIVE_HOME=$LIVE_HOME 在仓库外"
|
||||
|
||||
echo "── 2. 时钟 ──"
|
||||
# staleness 闸靠两机时钟一致才有意义。这里只能验本机;跨机偏差由
|
||||
# ship_signals 在收到信号时报「时间倒流」
|
||||
if command -v chronyc >/dev/null; then
|
||||
chronyc tracking | grep -E "Reference ID|System time|Leap status" | sed 's/^/ /'
|
||||
src="$(chronyc tracking | awk '/Reference ID/{print $NF}')"
|
||||
[[ "$src" != "()" && -n "$src" ]] || die "chrony 还没同步上,等一会再跑"
|
||||
ok "chrony 已同步"
|
||||
else
|
||||
die "没装 chrony。staleness 闸不可信,先 apt install chrony"
|
||||
fi
|
||||
|
||||
echo "── 3. 出口 IP 是否在白名单内 ──"
|
||||
myip="$(curl -s --max-time 10 https://api.ipify.org || true)"
|
||||
[[ -n "$myip" ]] && echo " 本机出口 IP:$myip" || echo " ⚠ 取不到出口 IP"
|
||||
echo " 对照 Bitget 后台该 key 的 IP 白名单,不一致下面会报 40018 之类"
|
||||
|
||||
echo "── 4. 能否 ssh 到采集机 ──"
|
||||
# 不能用 ssh <host> 'echo ok' 来试:采集机那侧的 authorized_keys 用了强制命令
|
||||
# (把这把 key 锁成只能跑 tail,拿不到 shell),任何请求都会变成 tail -F,
|
||||
# 而它**永不返回**——ConnectTimeout 只管建连不管命令时长,测试会永久挂住。
|
||||
# 所以照 ship_signals 的真实用法读流:tail -c +0 会先把整个文件吐出来,
|
||||
# 数一下就等于对端总线的条数,之后它挂着等新内容,由 timeout 收掉。
|
||||
rb="${SHIP_REMOTE_BUS:-\$HOME/chan-live/state/signals_live.jsonl}"
|
||||
err="$(mktemp)"; outf="$(mktemp)"
|
||||
# timeout 要套在 sudo **里面**:套外面时 SIGTERM 只到 sudo,未必传给 ssh,
|
||||
# 于是该被收掉的 tail 会继续挂着
|
||||
sudo -u "$USER_NAME" timeout 12 ssh -T -o BatchMode=yes -o ConnectTimeout=10 \
|
||||
"$SHIP_FROM" "tail -c +0 -F $rb" >"$outf" 2>"$err" || true
|
||||
n="$(wc -l < "$outf")"
|
||||
if grep -qi "Host key verification failed" "$err"; then
|
||||
fp="(在采集机上跑 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub 拿)"
|
||||
rm -f "$err" "$outf"
|
||||
die "主机指纹没确认过。这不是 key 的问题,装 key 也修不了。
|
||||
$USER_NAME 的 known_hosts 是空的,而 BatchMode=yes 不允许交互确认。
|
||||
**不要**用 StrictHostKeyChecking=no 糊过去,那等于放弃中间人防护。
|
||||
正确做法:在采集机上读出权威指纹 $fp,
|
||||
再在这台上写入并核对:
|
||||
sudo -u $USER_NAME ssh-keyscan -t ed25519 <采集机IP> \\
|
||||
| sudo -u $USER_NAME tee -a $STATE/home/.ssh/known_hosts
|
||||
sudo -u $USER_NAME ssh-keygen -lf $STATE/home/.ssh/known_hosts"
|
||||
elif grep -qiE "Permission denied|publickey" "$err"; then
|
||||
rm -f "$err" "$outf"
|
||||
die "认证被拒。指纹是通的,是 key 没装到采集机上:
|
||||
sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519
|
||||
再把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys"
|
||||
elif [[ -s "$err" ]] && ! grep -q "^" "$outf" 2>/dev/null; then
|
||||
msg="$(head -3 "$err")"; rm -f "$err" "$outf"
|
||||
die "ssh $SHIP_FROM 不通:$msg"
|
||||
else
|
||||
ok "ssh $SHIP_FROM 通"
|
||||
echo " 对端总线现有 $n 条信号"
|
||||
[[ "$n" -gt 0 ]] || echo " ⚠ 对端总线是空的。信号 6.8 个/天,刚重启过就是空的很正常;但要确认采集机接了总线"
|
||||
rm -f "$err" "$outf"
|
||||
fi
|
||||
|
||||
echo "── 5. 执行器空跑 ${SECS}s(真连交易所,不下单)──"
|
||||
# --dry-run 下只读不写:下单一律只打印。合约规则那个端点是**公开**的、不验签,
|
||||
# 所以空跑里专门补了一次带签名的 account() —— 否则这一步会"通过"却根本没测到
|
||||
# 密钥与 IP 白名单,等第一个真信号来时才暴露,而那时信号正在过期
|
||||
# 让 chan 自己 source 配置($CONF 是 640 root:chan,它读得到)。
|
||||
# 不用 `env "$(grep ...)"` 那种拼法:值里有空格就会被切开
|
||||
set +e
|
||||
sudo -u "$USER_NAME" bash -c \
|
||||
"set -a; . '$CONF'; set +a; exec timeout $SECS \
|
||||
'$APP/.venv/bin/python' '$APP/live/live_exec.py' --dry-run"
|
||||
rc=$?
|
||||
set -e
|
||||
# timeout 到点是 124,属于预期
|
||||
[[ $rc -eq 124 || $rc -eq 0 ]] || die "空跑退出码 $rc,看上面报错"
|
||||
ok "空跑没有报错退出"
|
||||
|
||||
echo
|
||||
echo "空跑过了。真跑:"
|
||||
echo " sudo systemctl enable --now chan-live-ship chan-live-exec"
|
||||
echo " $APP/live/deploy/status.sh"
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在生产机(有 Bitget API key 白名单的那台)上装实盘执行器。
|
||||
#
|
||||
# 只装执行侧:标准库 + aiohttp。**不装** Docker / Hummingbot / pandas /
|
||||
# chanlun 引擎——那些是采集与研究侧的依赖,理由见 live/live_exec.py 文件头。
|
||||
#
|
||||
# sudo ./install.sh # 从当前 checkout 安装
|
||||
# sudo REPO=git@host:jack/chan.git ./install.sh # 或指定远程克隆
|
||||
#
|
||||
# 幂等:重复跑只会更新代码与依赖,不动 /etc/chan-live/live.env 和状态目录。
|
||||
set -euo pipefail
|
||||
|
||||
APP=/opt/chan
|
||||
STATE=/var/lib/chan-live
|
||||
CONF=/etc/chan-live
|
||||
USER_NAME=chan
|
||||
BRANCH="${BRANCH:-chan}"
|
||||
REPO="${REPO:-}"
|
||||
|
||||
die() { echo "⛔ $*" >&2; exit 1; }
|
||||
say() { echo " $*"; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "要 root:sudo $0"
|
||||
|
||||
# ── --sync-env:把模板新增的项追加到已有配置 ────────────────────────
|
||||
# 独立成一个模式而不是塞进安装流程:追加会改一个装着密钥的文件,这种事应当
|
||||
# 由你显式发起。只追加缺的项(连它上面的注释一起),**不动**已有任何一行。
|
||||
if [[ "${1:-}" == "--sync-env" ]]; then
|
||||
EX="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/live.env.example"
|
||||
[[ -f "$EX" ]] || die "找不到模板 $EX"
|
||||
[[ -f "$CONF/live.env" ]] || die "$CONF/live.env 还不存在,先跑一次 sudo $0"
|
||||
cp -a "$CONF/live.env" "$CONF/live.env.bak.$(date +%Y%m%d%H%M%S)"
|
||||
python3 - "$EX" "$CONF/live.env" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
ex_path, live_path = sys.argv[1], sys.argv[2]
|
||||
ex = open(ex_path, encoding="utf-8").read().splitlines()
|
||||
live = open(live_path, encoding="utf-8").read()
|
||||
KEY = re.compile(r"^([A-Z_][A-Z0-9_]*)=")
|
||||
have = set(KEY.match(x).group(1) for x in live.splitlines() if KEY.match(x))
|
||||
|
||||
add, block = [], []
|
||||
for line in ex:
|
||||
if KEY.match(line):
|
||||
k = KEY.match(line).group(1)
|
||||
if k not in have:
|
||||
add += block + [line]
|
||||
block = []
|
||||
elif line.startswith("#") or (not line.strip() and block):
|
||||
block.append(line)
|
||||
else:
|
||||
block = []
|
||||
|
||||
if not add:
|
||||
print(" 配置已是最新,无需追加")
|
||||
raise SystemExit(0)
|
||||
with open(live_path, "a", encoding="utf-8") as f:
|
||||
f.write("\n\n# ── 以下由 install.sh --sync-env 追加 ──\n")
|
||||
f.write("\n".join(add) + "\n")
|
||||
n = sum(1 for x in add if KEY.match(x))
|
||||
print(f" 追加了 {n} 项:" +
|
||||
" ".join(KEY.match(x).group(1) for x in add if KEY.match(x)))
|
||||
PY
|
||||
say "原文件已备份为 $CONF/live.env.bak.*"
|
||||
say "追加的项多半是空值,逐项填完再重启:sudo systemctl restart chan-live-exec"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 1. 系统依赖 ────────────────────────────────────────────────────
|
||||
say "装系统包"
|
||||
if command -v apt-get >/dev/null; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
# chrony 不是可选项:staleness 闸靠两机时钟一致才有意义,
|
||||
# 采集机时钟快 5 分钟就等于把闸放宽 5 分钟(见 ship_signals.py)
|
||||
apt-get install -y -qq python3-venv python3-pip git chrony openssh-client
|
||||
elif command -v dnf >/dev/null; then
|
||||
dnf install -y -q python3 python3-pip git chrony openssh-clients
|
||||
else
|
||||
die "只认 apt/dnf,其他发行版请手工装 python3-venv git chrony"
|
||||
fi
|
||||
systemctl enable --now chrony 2>/dev/null || systemctl enable --now chronyd
|
||||
|
||||
# ── 2. 专用用户与目录 ──────────────────────────────────────────────
|
||||
if ! id -u "$USER_NAME" >/dev/null 2>&1; then
|
||||
say "建系统用户 $USER_NAME(无登录 shell)"
|
||||
useradd --system --home-dir "$STATE/home" --create-home \
|
||||
--shell /usr/sbin/nologin "$USER_NAME"
|
||||
fi
|
||||
install -d -o "$USER_NAME" -g "$USER_NAME" -m 750 "$STATE" "$STATE/state" "$STATE/home"
|
||||
install -d -o "$USER_NAME" -g "$USER_NAME" -m 700 "$STATE/home/.ssh"
|
||||
install -d -o root -g "$USER_NAME" -m 750 "$CONF"
|
||||
|
||||
# ── 3. 代码 ────────────────────────────────────────────────────────
|
||||
if [[ -n "$REPO" ]]; then
|
||||
if [[ -d "$APP/.git" ]]; then
|
||||
say "更新已有 checkout"
|
||||
git -C "$APP" fetch --quiet origin "$BRANCH"
|
||||
git -C "$APP" checkout --quiet "$BRANCH"
|
||||
git -C "$APP" reset --hard --quiet "origin/$BRANCH"
|
||||
else
|
||||
say "克隆 $REPO"
|
||||
rm -rf "$APP"; git clone --quiet --branch "$BRANCH" "$REPO" "$APP"
|
||||
fi
|
||||
else
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
[[ -f "$SRC/live/live_exec.py" ]] || die "$SRC 不像仓库根(缺 live/live_exec.py)"
|
||||
if [[ "$SRC" != "$APP" ]]; then
|
||||
say "从 $SRC 同步代码到 $APP"
|
||||
install -d "$APP"
|
||||
# 只同步生产要的那一个子树。研究侧的 research/ 不上生产机:
|
||||
# 它带 pandas/pyarrow/hummingbot,而且探针 OOM 过一次(14.9GB)
|
||||
cp -a "$SRC/live" "$APP/"
|
||||
fi
|
||||
fi
|
||||
chown -R root:root "$APP/live"
|
||||
find "$APP/live" -type f -exec chmod 644 {} + ; chmod 755 "$APP/live" "$APP/live/deploy"
|
||||
chmod 755 "$APP"/live/deploy/*.sh
|
||||
|
||||
# ── 4. venv ───────────────────────────────────────────────────────
|
||||
say "建 venv 并装依赖(应当只有 aiohttp)"
|
||||
[[ -d "$APP/.venv" ]] || python3 -m venv "$APP/.venv"
|
||||
"$APP/.venv/bin/pip" install --quiet --upgrade pip
|
||||
"$APP/.venv/bin/pip" install --quiet -r "$APP/live/requirements.txt"
|
||||
|
||||
# 断言生产进程没被拖进重量级依赖。这条会真挡住——曾经为读两个常量
|
||||
# import 研究侧的 step43,把 numpy/pandas/pyarrow 全拉进实盘进程
|
||||
say "验依赖面"
|
||||
"$APP/.venv/bin/python" - <<'PY' || die "生产进程拖进了重量级依赖,看上面输出"
|
||||
import sys
|
||||
sys.path.insert(0, "/opt/chan/live")
|
||||
import live_exec
|
||||
live_exec.assert_decomposable()
|
||||
heavy = [m for m in ("numpy", "pandas", "pyarrow", "hummingbot", "scipy")
|
||||
if m in sys.modules]
|
||||
if heavy:
|
||||
print(f" ⛔ 启动路径加载了 {heavy}")
|
||||
raise SystemExit(1)
|
||||
print(f" ✓ 只有标准库 + aiohttp · 出场结构 "
|
||||
f"{live_exec.SL_ATR}/{live_exec.SCALE_ATR}/{live_exec.RUNNER_ATR} ATR "
|
||||
f"/{live_exec.MAXB} 根")
|
||||
PY
|
||||
|
||||
# ── 5. 配置模板 ────────────────────────────────────────────────────
|
||||
if [[ ! -f "$CONF/live.env" ]]; then
|
||||
say "写配置模板 $CONF/live.env(密钥要你手工填)"
|
||||
install -o root -g "$USER_NAME" -m 640 \
|
||||
"$APP/live/deploy/live.env.example" "$CONF/live.env"
|
||||
NEED_FILL=1
|
||||
else
|
||||
say "$CONF/live.env 已存在,不覆盖(里面是密钥)"
|
||||
# 但要报出模板新增的项。不报的话,以后往 example 里加配置,已有部署会
|
||||
# **永远拿不到且毫无提示**——静默漂移,等到出事才发现某个开关根本没生效
|
||||
keys() { grep -oE '^[A-Z_][A-Z0-9_]*=' "$1" | tr -d '=' | sort -u; }
|
||||
MISSING="$(comm -23 <(keys "$APP/live/deploy/live.env.example") \
|
||||
<(keys "$CONF/live.env") | tr '\n' ' ')"
|
||||
if [[ -n "${MISSING// }" ]]; then
|
||||
say "⚠ 模板比你的配置多了这些项:$MISSING"
|
||||
say " 逐项看说明:$APP/live/deploy/live.env.example"
|
||||
say " 要把缺的那几行连注释一起追加过去,跑:"
|
||||
say " sudo $APP/live/deploy/install.sh --sync-env"
|
||||
NEED_FILL=1
|
||||
fi
|
||||
OBSOLETE="$(comm -13 <(keys "$APP/live/deploy/live.env.example") \
|
||||
<(keys "$CONF/live.env") | tr '\n' ' ')"
|
||||
[[ -n "${OBSOLETE// }" ]] && \
|
||||
say " 另有模板里已没有的项(可能已废弃):$OBSOLETE"
|
||||
fi
|
||||
|
||||
# ── 6. systemd ────────────────────────────────────────────────────
|
||||
say "装 systemd 单元"
|
||||
install -m 644 "$APP"/live/deploy/chan-live-*.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo
|
||||
echo "装好了。接下来按顺序做(**不要**跳过空跑那步):"
|
||||
echo
|
||||
if [[ -n "${NEED_FILL:-}" ]]; then
|
||||
echo " 1. 填密钥与参数:sudo vi $CONF/live.env"
|
||||
echo " BITGET_API_KEY / SECRET / PASSPHRASE 用只读+交易权限,"
|
||||
echo " **不要开提币权限**。SHIP_FROM 填采集机的 ssh 目标。"
|
||||
echo
|
||||
fi
|
||||
echo " 2. 装拉总线用的 ssh key:"
|
||||
echo " sudo -u $USER_NAME ssh-keygen -t ed25519 -N '' -f $STATE/home/.ssh/id_ed25519"
|
||||
echo " # 把 $STATE/home/.ssh/id_ed25519.pub 加到采集机的 authorized_keys"
|
||||
echo
|
||||
echo " 还要写 known_hosts,否则报 Host key verification failed —— 服务跑"
|
||||
echo " 起来会撞同一个墙,因为 BatchMode=yes 不允许交互确认:"
|
||||
echo " sudo -u $USER_NAME ssh-keyscan -t ed25519 <采集机IP> \\"
|
||||
echo " | sudo -u $USER_NAME tee -a $STATE/home/.ssh/known_hosts"
|
||||
echo " sudo -u $USER_NAME ssh-keygen -lf $STATE/home/.ssh/known_hosts"
|
||||
echo " # 把指纹跟采集机上 ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub"
|
||||
echo " # 的输出比一遍。**不要**图省事用 StrictHostKeyChecking=no,"
|
||||
echo " # 那等于放弃中间人防护,而这条链路上跑的是下单信号"
|
||||
echo
|
||||
echo " 3. 空跑验全链(不下真单,跑够看到一次心跳再停):"
|
||||
echo " sudo $APP/live/deploy/dryrun.sh"
|
||||
echo
|
||||
echo " 4. 真跑:"
|
||||
echo " sudo systemctl enable --now chan-live-ship chan-live-exec"
|
||||
echo " $APP/live/deploy/status.sh"
|
||||
@@ -0,0 +1,71 @@
|
||||
# 生产配置。装到 /etc/chan-live/live.env,权限 640 root:chan。
|
||||
# **不要提交填好的版本**——这里有交易权限的密钥。
|
||||
#
|
||||
# 改完要重启:sudo systemctl restart chan-live-exec
|
||||
|
||||
# ── Bitget 密钥 ───────────────────────────────────────────────────
|
||||
# 权限只勾「只读」+「交易」,**不要勾提币**。
|
||||
# IP 白名单填这台机的公网出口 IP(curl -s https://api.ipify.org 看)。
|
||||
# 这也是执行器必须跑在这台机上的唯一原因——密钥绑了这个 IP。
|
||||
BITGET_API_KEY=
|
||||
BITGET_API_SECRET=
|
||||
BITGET_PASSPHRASE=
|
||||
|
||||
# ── 信号来源(采集机)─────────────────────────────────────────────
|
||||
# ssh 目标。可以是 ~/.ssh/config 里的别名,或 user@ip
|
||||
SHIP_FROM=sg-collector
|
||||
# 采集机上总线文件的路径(在对端 shell 里展开,可用 ~)
|
||||
SHIP_REMOTE_BUS=~/chan-live/state/signals_live.jsonl
|
||||
|
||||
# ── 状态与总线 ────────────────────────────────────────────────────
|
||||
# 生产状态的根。**不要指到仓库里**:git checkout/clean 会动仓库,而这里存的
|
||||
# 是日亏损累计与在场仓位,被清掉等于 MAX_DAY_LOSS / MAX_OPEN 两道闸失忆。
|
||||
# systemd 单元里 ReadWritePaths 也是这个路径,改了要一起改
|
||||
LIVE_HOME=/var/lib/chan-live
|
||||
SIGNAL_BUS=/var/lib/chan-live/state/signals_live.jsonl
|
||||
|
||||
# ── 仓位 ─────────────────────────────────────────────────────────
|
||||
# 每笔名义额(USDT)。杠杆**不改**手续费与滑点(都按名义额收),所以抬名义额
|
||||
# 有真实成本;抬它的唯一理由是压掉步长取整:实测最差币的偏差
|
||||
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%
|
||||
LIVE_NOTIONAL=500
|
||||
# 杠杆只影响占用保证金,不影响名义敞口/手续费/滑点/盈亏绝对值。
|
||||
# 名义 500 在 10x 下占 50 USDT 保证金;止损在 2 ATR ≈ 0.2%,而 10x 强平约需
|
||||
# 逆向 10% = 100 个 ATR,差 50 倍。交易所侧记得设**逐仓**
|
||||
LIVE_LEVERAGE=10
|
||||
|
||||
# ── 硬约束:封的是「代价不随仓位缩小」的那几类故障 ─────────────────
|
||||
# 并发仓位数。信号 6.8 笔/天、持仓 48 分钟 → 期望并发 0.23 笔,3 已很宽。
|
||||
# 超了说明有 bug,不是行情好
|
||||
LIVE_MAX_OPEN=3
|
||||
# 日开仓上限。专门封「循环里的 bug 反复开仓」——单笔小但笔数无界
|
||||
LIVE_MAX_DAY=15
|
||||
# 日亏损上限(USDT)。一笔止损约 1 USDT,15 笔全亏 15 USDT
|
||||
LIVE_MAX_DAY_LOSS=20
|
||||
# 信号超过这么久就不做。参考成交价是次根开盘价,过期后跑的不是回测那个价。
|
||||
# ⚠️ 这道闸依赖两机时钟一致,chrony 必须在跑(install.sh 会装)
|
||||
LIVE_STALE_S=20
|
||||
|
||||
# 交易的币池。要与采集机一致,否则会收到不做的币的信号(会被忽略但徒增噪声)
|
||||
SYMS=BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC
|
||||
|
||||
# 盯交易所侧出场的轮询间隔(秒)。**别关掉这个循环**:止损与止盈在交易所侧
|
||||
# 成交,本进程收不到通知,缺了它 pnl_day 恒为 0,MAX_DAY_LOSS 就是死的
|
||||
LIVE_WATCH_S=10
|
||||
|
||||
# ── Telegram 通知 ─────────────────────────────────────────────────
|
||||
# 拿 token:Telegram 里找 @BotFather → /newbot
|
||||
# 拿 chat id:给 bot 随便发一句,再开
|
||||
# https://api.telegram.org/bot<TOKEN>/getUpdates 看 result[0].message.chat.id
|
||||
#
|
||||
# 留空则完全不推(不报错)。推的内容:开仓、平仓(带已实现盈亏)、被硬约束
|
||||
# 挡住、报错、对账平仓、跨日结算、启动与停机、整点在线。
|
||||
# **不推**信号过期跳过(常态,搬运重连会重放旧信号)和 5 分钟日志心跳。
|
||||
# 量级约 55 条/天上限(含 24 条在线)。
|
||||
TG_TOKEN=
|
||||
TG_CHAT=
|
||||
# 前缀,用来和采集机推的手工信号区分开——两边可以共用同一个 bot 和对话
|
||||
TG_TAG=实盘
|
||||
# 整点在线的间隔(分钟)。0 关掉。60 = 每天 24 条,和成交推送量级相当。
|
||||
# 这条必须带上游新鲜度:执行器活着不代表链路活着
|
||||
TG_HB_MIN=60
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# 生产机一屏体检。不改任何状态,随时可跑。
|
||||
set -uo pipefail
|
||||
|
||||
APP=/opt/chan
|
||||
STATE=/var/lib/chan-live/state
|
||||
CONF=/etc/chan-live/live.env
|
||||
|
||||
hr() { printf '─── %s\n' "$1"; }
|
||||
|
||||
hr "服务"
|
||||
for u in chan-live-ship chan-live-exec; do
|
||||
act="$(systemctl is-active "$u" 2>/dev/null)"
|
||||
since="$(systemctl show -p ActiveEnterTimestamp --value "$u" 2>/dev/null)"
|
||||
nrs="$(systemctl show -p NRestarts --value "$u" 2>/dev/null)"
|
||||
printf ' %-16s %-8s 自 %s · 重启 %s 次\n' \
|
||||
"$u" "$act" "${since:-?}" "${nrs:-0}"
|
||||
done
|
||||
|
||||
hr "时钟(staleness 闸依赖它)"
|
||||
if command -v chronyc >/dev/null; then
|
||||
chronyc tracking 2>/dev/null | grep -E "Reference ID|System time" | sed 's/^/ /'
|
||||
else
|
||||
echo " ⚠ 没装 chrony"
|
||||
fi
|
||||
|
||||
hr "信号总线"
|
||||
bus="${SIGNAL_BUS:-$STATE/signals_live.jsonl}"
|
||||
[[ -f "$CONF" ]] && bus="$(grep -E '^SIGNAL_BUS=' "$CONF" | tail -1 | cut -d= -f2-)"
|
||||
bus="${bus:-$STATE/signals_live.jsonl}"
|
||||
if [[ -s "$bus" ]]; then
|
||||
n="$(wc -l < "$bus")"
|
||||
last_ts="$(tail -1 "$bus" | grep -o '"kline_ts":[0-9]*' | cut -d: -f2)"
|
||||
if [[ -n "$last_ts" ]]; then
|
||||
age=$(( $(date +%s) - last_ts / 1000 ))
|
||||
printf ' %s 条 · 最近一条 %d 分钟前\n' "$n" "$((age / 60))"
|
||||
else
|
||||
echo " $n 条(最后一行没有 kline_ts)"
|
||||
fi
|
||||
# 信号 6.8 个/天,所以「几小时没有」是正常的。真要看的是搬运连着没有
|
||||
echo " 注:6.8 个/天,长时间没有新信号是正常的;要判健康看下面的搬运心跳"
|
||||
else
|
||||
echo " 空或不存在:$bus"
|
||||
fi
|
||||
|
||||
hr "闸的状态"
|
||||
# 在场仓位**不落盘**:重启时由 reconcile 查交易所并平掉(见 live_exec.py
|
||||
# 的 reconcile 注释)。所以这里只报当日计数,仓位要看交易所或下面的成交流
|
||||
if [[ -f "$STATE/live_state.json" ]]; then
|
||||
python3 - "$STATE/live_state.json" <<'PY'
|
||||
import json, sys, time
|
||||
d = json.load(open(sys.argv[1]))
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
day = d.get("day", "?")
|
||||
stale = "" if day == today else f" ⚠ 是 {day} 的,跨日后首次开仓时才归零"
|
||||
print(f" 当日 {day}{stale}")
|
||||
pnl, n = d.get("pnl_day", 0.0), d.get("n_day", 0)
|
||||
# pnl 恒为 0 而又确实开过仓,说明 watch() 没在记账 → MAX_DAY_LOSS 是死的
|
||||
warn = " ⚠ 开过仓但盈亏仍为 0,查 watch() 是否在跑" if n and pnl == 0 else ""
|
||||
print(f" 已开 {n} 笔 · 盈亏 {pnl:+.2f} USDT{warn}")
|
||||
print(f" 已处理信号键 {len(d.get('done', []))} 个(幂等去重用,留最近 5000)")
|
||||
PY
|
||||
else
|
||||
echo " 还没有状态文件(没开过仓)"
|
||||
fi
|
||||
|
||||
hr "最近成交"
|
||||
if [[ -s "$STATE/live_trades.jsonl" ]]; then
|
||||
tail -5 "$STATE/live_trades.jsonl" | sed 's/^/ /'
|
||||
# 「下过单但一次都没建上」是明确的故障,而它的外在表现和"没信号"一样,
|
||||
# 不主动判读就会白跑几小时。首日就是这么丢掉 4 个信号的
|
||||
nf="$(grep -c '"ev": "entry_fail"' "$STATE/live_trades.jsonl" || true)"
|
||||
nb="$(grep -c '"ev": "opened", "key": [^]]*\[{' "$STATE/live_trades.jsonl" || true)"
|
||||
ne="$(grep -c '"ev": "entry"' "$STATE/live_trades.jsonl" || true)"
|
||||
if [[ "${nf:-0}" -gt 0 ]]; then
|
||||
echo
|
||||
echo " ⛔ 有 $nf 次入场被拒(共尝试 $ne 个信号)"
|
||||
echo " 最后一条错误:"
|
||||
grep '"ev": "entry_fail"' "$STATE/live_trades.jsonl" | tail -1 \
|
||||
| sed 's/^/ /'
|
||||
echo " 40774 = 持仓模式不匹配 · 40762/40786 = 保证金不足"
|
||||
fi
|
||||
else
|
||||
echo " 还没有成交记录"
|
||||
fi
|
||||
|
||||
hr "搬运存活文件"
|
||||
if [[ -f "$STATE/ship_alive.json" ]]; then
|
||||
python3 - "$STATE/ship_alive.json" <<'PY'
|
||||
import json, sys, time
|
||||
d = json.load(open(sys.argv[1]))
|
||||
age = time.time() - d.get("ts", 0)
|
||||
up = d.get("up_s", 0)
|
||||
conn = d.get("connected")
|
||||
if age > 720:
|
||||
state = f"⛔ 已停更 {age/60:.0f} 分钟,进程可能死了"
|
||||
elif conn is False:
|
||||
state = "⛔ ssh 已断开,正在重连"
|
||||
elif conn is True:
|
||||
state = f"ssh 在线 {up/60:.0f} 分钟"
|
||||
else:
|
||||
state = "文件是旧格式(没有 connected),重启搬运后才会有"
|
||||
print(f" {state} · 重连 {d.get('n_reconnect', 0)} 次 · 新增 {d.get('n_new', 0)} 条")
|
||||
PY
|
||||
else
|
||||
echo " 还没有 ship_alive.json(搬运没起来,或还是没落盘的旧版本)"
|
||||
fi
|
||||
|
||||
hr "搬运心跳(最近 3 条)"
|
||||
journalctl -u chan-live-ship -n 200 --no-pager 2>/dev/null \
|
||||
| grep -F "[心跳]" | tail -3 | sed 's/^/ /' \
|
||||
|| echo " 还没有心跳(每 5 分钟一条)"
|
||||
|
||||
hr "最近报错"
|
||||
journalctl -u chan-live-exec -u chan-live-ship -n 400 --no-pager -p warning 2>/dev/null \
|
||||
| tail -8 | sed 's/^/ /' || echo " 无"
|
||||
@@ -0,0 +1,118 @@
|
||||
"""钱在哪个账户里。
|
||||
|
||||
用来解一个具体的矛盾:你确认往 U 本位合约充了钱,但
|
||||
`/api/v2/mix/account/account` 报的 accountEquity 只有一小部分。
|
||||
|
||||
`accountEquity` 是**总权益**而不是可用余额,locked 也是 0,所以不是被挂单
|
||||
或持仓占着。剩下的可能都是「这把 key 看到的不是你充钱的那个账户」:
|
||||
|
||||
· key 属于子账户,钱在主账户(或反过来)
|
||||
· 钱在现货账户,没划转到合约
|
||||
· 钱在 USDC 本位 / 币本位合约,不是 USDT 本位
|
||||
· 账户已迁到统一账户(UTA),经典 mix 接口读到的不是同一个池子
|
||||
|
||||
`/api/v2/account/all-account-balance` 会按账户类型列出全部余额,一次看清。
|
||||
|
||||
sudo -u chan bash -c 'set -a; . /etc/chan-live/live.env; set +a; \
|
||||
/opt/chan/.venv/bin/python /opt/chan/live/deploy/whereismoney.py'
|
||||
|
||||
只读,不下单、不划转。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from bitget_rest import MARGIN_COIN, PRODUCT, Bitget # noqa: E402
|
||||
|
||||
|
||||
def _perm_hint(e: Exception) -> str:
|
||||
"""把 40014 说成"正常"而不是"故障"。
|
||||
|
||||
这把 key 刻意只开合约权限,所以现货类端点必然报 40014。**不要**为了让
|
||||
这个探针看全就去加现货权限——那是白扩爆炸半径,而同样的信息在 App 里
|
||||
看一眼就有。
|
||||
"""
|
||||
s = str(e)
|
||||
if "40014" in s:
|
||||
return ("跳过:这把 key 没开现货权限(刻意的,最小权限)。"
|
||||
"这一栏改用 Bitget App 看,别为了探针去加权限")
|
||||
return f"查不了:{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
api = Bitget()
|
||||
if not (api.key and api.secret and api.passphrase):
|
||||
raise SystemExit("⛔ 没读到密钥。要 source /etc/chan-live/live.env")
|
||||
try:
|
||||
print("── 跨账户类型总览 ──")
|
||||
try:
|
||||
for b in await api._req("GET", "/api/v2/account/all-account-balance") or []:
|
||||
amt = float(b.get("usdtBalance") or 0)
|
||||
flag = " ← 钱在这里" if amt > 1 else ""
|
||||
print(f" {b.get('accountType'):<16} {amt:>12.2f} USDT{flag}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {_perm_hint(e)}")
|
||||
|
||||
print(f"\n── {PRODUCT} 下的各保证金币种 ──")
|
||||
try:
|
||||
rows = await api._req("GET", "/api/v2/mix/account/accounts",
|
||||
{"productType": PRODUCT}) or []
|
||||
for a in rows:
|
||||
eq = float(a.get("accountEquity") or 0)
|
||||
if eq or a.get("marginCoin") == MARGIN_COIN:
|
||||
print(f" {a.get('marginCoin'):<8} 权益 {eq:>12.4f} · "
|
||||
f"可用 {float(a.get('available') or 0):>12.4f}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {_perm_hint(e)}")
|
||||
|
||||
print("\n── 其他合约类型(钱可能充错了本位)──")
|
||||
for pt in ("coin-futures", "usdc-futures"):
|
||||
try:
|
||||
rows = await api._req("GET", "/api/v2/mix/account/accounts",
|
||||
{"productType": pt}) or []
|
||||
hit = [(a.get("marginCoin"), float(a.get("accountEquity") or 0))
|
||||
for a in rows if float(a.get("accountEquity") or 0) > 0]
|
||||
print(f" {pt:<14} {hit if hit else '空'}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {pt:<14} 查不了:{type(e).__name__}")
|
||||
|
||||
print("\n── 现货 ──")
|
||||
try:
|
||||
rows = await api._req("GET", "/api/v2/spot/account/assets") or []
|
||||
hit = [(a.get("coin"), float(a.get("available") or 0)) for a in rows
|
||||
if float(a.get("available") or 0) > 0]
|
||||
print(f" {hit if hit else '空'}"
|
||||
f"{' ← 要划转到 U 本位合约' if hit else ''}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {_perm_hint(e)}")
|
||||
|
||||
print("\n── 这把 key 属于哪个账户 ──")
|
||||
for path in ("/api/v2/spot/account/info", "/api/v2/user/account-info"):
|
||||
try:
|
||||
d = await api._req("GET", path) or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {path} 查不了:{type(e).__name__}: {e}")
|
||||
continue
|
||||
uid, par = d.get("userId"), d.get("parentId")
|
||||
print(f" userId {uid}")
|
||||
if par:
|
||||
print(f" parentId {par} → **这是子账户**。主账户的钱这把 key"
|
||||
f" 看不到也动不了,这是好事(爆炸半径被账户边界封住)。"
|
||||
f"\n 但充值要充到 userId {uid} 的 U 本位合约里。"
|
||||
f"\n 注意主→子划转默认落在子账户的**现货**钱包,"
|
||||
f"还要在子账户内部再划一次到 U 本位合约")
|
||||
else:
|
||||
print(" 没有 parentId → 这是主账户")
|
||||
print(f" 权限 {d.get('authorities')}(没有现货权限是刻意的)")
|
||||
print(f" IP 白名单 {d.get('ips')}")
|
||||
break
|
||||
finally:
|
||||
await api.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,27 @@
|
||||
"""出场结构的唯一来源。**只用标准库**,这是硬约束。
|
||||
|
||||
为什么单独一个模块、且不许引第三方库:执行器要跑在只装了 `aiohttp` 的生产机
|
||||
上。这几个数原先从 `research/step43_fill_aware_budget.py` 读,那个模块顶层
|
||||
`import pandas`,于是生产机为了两个 float 得装 pandas + pyarrow(实测
|
||||
`assert_decomposable()` 一调就把 numpy/pandas/pyarrow 全拖进来)。
|
||||
|
||||
方向也要注意:**生产拥有这个契约,研究侧反过来读它。** 反过来写成生产 import
|
||||
研究侧,就等于把回测的依赖树绑到实盘进程上。
|
||||
|
||||
⚠️ 研究侧还散着 6 处同样的字面量(step44/47/48/49/52 与 exit_model 的默认
|
||||
参数),本次没有统一。改这里的值**不会**自动改到那些脚本,对表要手工。
|
||||
统一它们要重跑那批脚本确认结果不变,属于独立的一次改动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# 以 ATR 为单位的出场结构。来源:research/step43_fill_aware_budget.py 的
|
||||
# 参数扫描结论(1m 主线)。含义见 live_exec.py 顶部注释
|
||||
SL = 2.0 # 止损:入场价的 2 ATR
|
||||
SCALE_AT = 3.0 # 减半点:3 ATR 处平掉一半
|
||||
RUNNER = 8.0 # 剩余半仓的目标:8 ATR
|
||||
RUNNER_STOP = 2.0 # 剩余半仓的止损,**不移动**,仍在入场价的 2 ATR
|
||||
MAXB = 48 # 超时:48 根(1m 上即 48 分钟)
|
||||
|
||||
# 两个半仓共用同一个不动止损,这是「一次入场拆两腿」能等价于回测的前提。
|
||||
# RUNNER_STOP != SL 时该等价性失效,`live_exec.assert_decomposable()` 会硬挡
|
||||
DECOMPOSABLE = RUNNER_STOP == SL
|
||||
@@ -0,0 +1,880 @@
|
||||
"""自动化小额实盘执行器。读信号总线,直接调 Bitget v2 REST 下单。
|
||||
|
||||
## 为什么不用 Hummingbot 的 PositionExecutor
|
||||
|
||||
它的连接器只暴露 LIMIT / LIMIT_MAKER / MARKET,没有触发单,于是
|
||||
`control_stop_loss()` 只能在本地盯价、触发时才发市价单——**进程一死仓位就是
|
||||
裸的**。而交易所本身支持 `place-order` 带 `presetStopLossPrice`,下单时就把
|
||||
止损挂到服务端。绕过连接器不是图省事,是为了消掉一整类故障。
|
||||
|
||||
另外 `TripleBarrierConfig` 只有单级止盈,装不下 3 ATR 减半 + 8 ATR 目标;
|
||||
自己写反而更短。
|
||||
|
||||
## 出场结构为什么能拆成两个半仓
|
||||
|
||||
回测结构是 2 ATR 止损 / 3 ATR 减半 / 8 ATR 目标 / 48 根超时,且**剩余半仓的
|
||||
止损保持在入场价的 2 ATR、不移动**。已核实 `research/lib/exit_model.py:151`——
|
||||
`runner_stops` 的 `ret` 是 `(entry - low[j]) / a`,从入场价算,且
|
||||
`RUNNER_STOP == SL == 2.0`。两半共用同一个不动的止损,所以:
|
||||
|
||||
半仓 A 市价入场 + 服务端止损 2 ATR · maker 止盈 3 ATR
|
||||
半仓 B 市价入场 + 服务端止损 2 ATR · maker 止盈 8 ATR
|
||||
|
||||
止损先到则两半都在 -2 ATR 出场;3 ATR 先到则 A 出场、B 继续且止损仍在 2 ATR。
|
||||
与回测逐情形一致。若哪天把 RUNNER_STOP 改成不等于 SL(比如移到成本),这个
|
||||
分解就**不再成立**,`assert_decomposable()` 会在启动时挡住。
|
||||
|
||||
## 三条出场腿各自挂在哪
|
||||
|
||||
止损 交易所侧(presetStopLossPrice,随入场单一起到)→ 进程死了仍在
|
||||
止盈 交易所侧(post_only reduce-only 限价) → 进程死了仍在
|
||||
超时 **本进程**,48 分钟到点市价平
|
||||
|
||||
所以进程死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
|
||||
|
||||
## 硬约束才是这个文件的重点
|
||||
|
||||
一笔止损只亏约 1 USDT,所以"亏损可控"对单笔成立。但三类故障的代价**不随仓位
|
||||
缩小**,必须显式封住:
|
||||
|
||||
失控下单 循环里的 bug 反复开仓,单笔小但笔数无界 → MAX_OPEN / MAX_DAY
|
||||
亏损累积 策略真的不行,但没人盯着 → MAX_DAY_LOSS
|
||||
裸仓 进程在"已入场、止损未挂"之间死掉 → 服务端止损 + 重启对账
|
||||
|
||||
## 为什么单独一个 live/ 子树、不放在 research/ 下
|
||||
|
||||
生产与研究共处一个目录/进程/机器有四条具体代价,其中第一条已经咬过一次:
|
||||
|
||||
1. `live_state.json` 原先落在 `research/out/`,而那里 `shadow_hb.py` 会在
|
||||
CSV 表头变化时自动 rename 归档、研究脚本会写、人也会手工清数据。那个文件
|
||||
装的是 MAX_DAY_LOSS 累计与在场仓位,**闸的状态被清掉不报错,只是静默
|
||||
失效**。所以生产状态改到独立目录(LIVE_HOME)。
|
||||
2. 采集器十币清空 300~560ms,直接叠在信号到达执行器的延迟上。
|
||||
3. 研究侧的探针 OOM 过一次(14.9GB、负载 12),当时若有仓位在场,执行器会
|
||||
被一起杀掉,只剩交易所侧止损兜着。
|
||||
4. 依赖面:本文件只需标准库 + aiohttp。原先为读两个常量 import 研究侧的
|
||||
step43,把 numpy/pandas/pyarrow 全拖进生产进程。
|
||||
|
||||
因此本目录**不 import research/ 下的任何东西**(`exit_params.py` 是生产自己
|
||||
持有的契约,研究侧反过来读它)。
|
||||
|
||||
python live/live_exec.py --dry-run # 只打印不下单
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
# 只插自己所在目录。**不要**把 research/ 加进来——见文件头第 4 条
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
import signal_bus # noqa: E402
|
||||
from bitget_rest import Bitget # noqa: E402
|
||||
import tg # noqa: E402
|
||||
from exit_params import MAXB, RUNNER, RUNNER_STOP, SCALE_AT, SL # noqa: E402
|
||||
|
||||
# 生产状态的根目录。默认放 ~/chan-live,**不落在仓库里**:仓库会被 git
|
||||
# checkout/clean 动,而这里存的是日亏损累计与在场仓位,丢了等于闸失忆
|
||||
LIVE_HOME = Path(os.environ.get("LIVE_HOME", Path.home() / "chan-live"))
|
||||
|
||||
SYMS = os.environ.get(
|
||||
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
|
||||
# 只认这些交易对。交易所的 all-position 返回**账户全部**仓位,不过滤的话
|
||||
# 账户上任何第三方仓位(手工单、另一个策略、试单忘了平)都会被 reconcile
|
||||
# 在下次重启时市价平掉;watch() 还会因为该 symbol 一直在场而永不结算对应的
|
||||
# key,MAX_OPEN 名额泄漏、pnl_day 不再更新。把「账户只归执行器」这个前提
|
||||
# 从口头约定变成代码里的过滤
|
||||
PAIRS = frozenset(f"{s}USDT" for s in SYMS)
|
||||
NOTIONAL = float(os.environ.get("LIVE_NOTIONAL", "500"))
|
||||
LEVERAGE = int(os.environ.get("LIVE_LEVERAGE", "10"))
|
||||
|
||||
# ── 硬约束 ────────────────────────────────────────────────────────────
|
||||
# 并发仓位数。1 笔约占 50 USDT 保证金,3 笔 150 USDT。信号速率 5.3 笔/天、
|
||||
# 持仓 48 分钟,期望并发只有 0.18 笔,所以 3 已经很宽——超了说明有 bug
|
||||
MAX_OPEN = int(os.environ.get("LIVE_MAX_OPEN", "3"))
|
||||
# 日开仓上限。实测 5.3 笔/天,给 3 倍余量。这一条专门封"失控下单"
|
||||
MAX_DAY = int(os.environ.get("LIVE_MAX_DAY", "15"))
|
||||
# 日亏损上限(USDT)。一笔止损约 1 USDT,15 笔全亏 15 USDT
|
||||
MAX_DAY_LOSS = float(os.environ.get("LIVE_MAX_DAY_LOSS", "20"))
|
||||
# 信号超过这么久就不做了。参考成交价是次根开盘价,过期后跑的不是回测那个价
|
||||
STALE_S = float(os.environ.get("LIVE_STALE_S", "20"))
|
||||
# 盯交易所侧出场的轮询间隔。10s 足够:出场后要做的只是记账与放开 MAX_OPEN
|
||||
# 名额,不涉及下单时效。太密会白耗 API 配额
|
||||
WATCH_S = float(os.environ.get("LIVE_WATCH_S", "10"))
|
||||
# 整点在线推送的间隔(分钟),0 关掉。60 分钟 = 24 条/天,和成交推送量级相当
|
||||
# 不会淹掉真事。调到 5 以下没意义:日志心跳就是 5 分钟一次
|
||||
TG_HB_MIN = float(os.environ.get("TG_HB_MIN", "60"))
|
||||
|
||||
STATE = Path(os.environ.get("LIVE_STATE", LIVE_HOME / "state" / "live_state.json"))
|
||||
TRADES = Path(os.environ.get("LIVE_TRADES", LIVE_HOME / "state" / "live_trades.jsonl"))
|
||||
|
||||
# 出场结构从 exit_params 读,本文件不再抄一份字面量。抄一份的问题不是难看,
|
||||
# 是改了回测参数后这边不会跟上,而且不报错
|
||||
SL_ATR, SCALE_ATR, RUNNER_ATR = SL, SCALE_AT, RUNNER
|
||||
|
||||
|
||||
def assert_decomposable() -> None:
|
||||
"""两个半仓的分解依赖 RUNNER_STOP == SL,不成立就必须停机。
|
||||
|
||||
若有人把剩余半仓的止损改成移到成本(RUNNER_STOP=0)或任何 != SL 的值,
|
||||
这个分解就变成"两半共用同一止损"的错误近似,实盘跑的是另一个收益结构,
|
||||
而且不会报错。所以在启动时硬挡。
|
||||
"""
|
||||
if float(RUNNER_STOP) != float(SL):
|
||||
raise SystemExit(
|
||||
f"⛔ RUNNER_STOP({RUNNER_STOP}) != SL({SL}),两个半仓的分解不再\n"
|
||||
f" 成立。live_exec 的出场结构会与回测不一致且不报错。\n"
|
||||
f" 要改成单执行器 + 手工两级止盈,或把这两个值改回一致。")
|
||||
|
||||
|
||||
class Guard:
|
||||
"""硬约束与当日计数。状态落盘,重启后不清零。
|
||||
|
||||
不落盘的话,进程反复重启就等于反复重置日上限——"失控下单"这一类恰好常常
|
||||
伴随反复重启,那时上限必须还记得。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path = STATE):
|
||||
self.path = path
|
||||
self.day = time.strftime("%Y-%m-%d")
|
||||
self.n_day = 0
|
||||
self.pnl_day = 0.0
|
||||
self.done: set = set()
|
||||
self.pending_roll: tuple | None = None
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
d = json.loads(self.path.read_text())
|
||||
except Exception:
|
||||
return
|
||||
# 跨日则计数归零,但已处理过的信号键要保留,否则会重开旧仓
|
||||
if d.get("day") == self.day:
|
||||
self.n_day = int(d.get("n_day", 0))
|
||||
self.pnl_day = float(d.get("pnl_day", 0.0))
|
||||
self.done = set(d.get("done", []))
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
# 原子替换:直接覆写时若在写一半崩溃,状态文件会变成半个 JSON,
|
||||
# 重启后读不出来 → 日计数归零 → 上限失效
|
||||
tmp.write_text(json.dumps({
|
||||
"day": self.day, "n_day": self.n_day, "pnl_day": self.pnl_day,
|
||||
# 只留最近的,否则文件无界增长
|
||||
"done": sorted(self.done)[-5000:]}))
|
||||
tmp.replace(self.path)
|
||||
|
||||
def roll(self) -> None:
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
if today != self.day:
|
||||
print(f" [guard] 跨日 {self.day} → {today},"
|
||||
f"当日 {self.n_day} 笔 / PnL {self.pnl_day:+.2f} USDT",
|
||||
flush=True)
|
||||
# roll() 是同步的,推送要 await,所以只留个待发件,由心跳取走
|
||||
self.pending_roll = (self.day, self.n_day, self.pnl_day)
|
||||
self.day, self.n_day, self.pnl_day = today, 0, 0.0
|
||||
self.save()
|
||||
|
||||
def blocks(self, key: str, n_open: int) -> str | None:
|
||||
"""返回拒绝原因,None 表示放行。"""
|
||||
self.roll()
|
||||
if key in self.done:
|
||||
return "已处理过(幂等)"
|
||||
if n_open >= MAX_OPEN:
|
||||
return f"并发仓位已达上限 {MAX_OPEN}"
|
||||
if self.n_day >= MAX_DAY:
|
||||
return f"当日开仓已达上限 {MAX_DAY}"
|
||||
if self.pnl_day <= -MAX_DAY_LOSS:
|
||||
return (f"当日亏损 {self.pnl_day:.2f} 已达上限 "
|
||||
f"-{MAX_DAY_LOSS},停止开新仓")
|
||||
return None
|
||||
|
||||
def took(self, key: str) -> None:
|
||||
self.done.add(key)
|
||||
self.n_day += 1
|
||||
self.save()
|
||||
|
||||
def realized(self, pnl: float) -> None:
|
||||
self.pnl_day += pnl
|
||||
self.save()
|
||||
|
||||
|
||||
def legs() -> list[dict]:
|
||||
"""两个半仓的止盈位,用 ATR 倍数表达。
|
||||
|
||||
止损两半相同(SL_ATR),所以不写在这里——它在 open_position 里算一次。
|
||||
"""
|
||||
return [{"tag": "scale", "atr": SCALE_ATR},
|
||||
{"tag": "runner", "atr": RUNNER_ATR}]
|
||||
|
||||
|
||||
def oid_of(key: str, tag: str) -> str:
|
||||
"""把信号键变成交易所能接受的 clientOid。
|
||||
|
||||
信号键形如 `SOL:1787904388411:+1`,里面的 `:` 和 `+` 未必被交易所接受,
|
||||
带过去会直接拒单——而拒单发生在入场腿上,等于这笔信号静默漏掉。只留
|
||||
字母数字和下划线。
|
||||
|
||||
clientOid 是**交易所级幂等**:重发同一个 oid 会被拒。这比本地去重可靠,
|
||||
因为「已发出但没收到回复」这种情况本地判不了,重试就会开两次仓。
|
||||
|
||||
调用方拼后缀时也要守这个字符集(用 `_tp` 而不是 `-tp`)。曾经拼过 `-`,
|
||||
和这里的理由自相矛盾;真被拒的话止盈单挂不上,而那条路径只告警不停机,
|
||||
收益结构会静默退化成「只有止损 + 超时」,空跑还验不到(dry 直接返回
|
||||
假成功)。
|
||||
"""
|
||||
# 方向必须显式编码:直接把非字母数字换成下划线,会让 `+1` 和 `-1` 都变成
|
||||
# `_1`,同一根上的多空信号得到相同 oid,第二笔被交易所当重复拒掉
|
||||
k = key.replace(":+1", ":L").replace(":-1", ":S")
|
||||
safe = "".join(c if c.isalnum() else "_" for c in f"{k}_{tag}")
|
||||
return safe[:60]
|
||||
|
||||
|
||||
def log_trade(rec: dict, path: Path = TRADES) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
|
||||
class Exec:
|
||||
def __init__(self, dry: bool, bus: Path):
|
||||
self.dry = dry
|
||||
self.bus = bus
|
||||
self.guard = Guard()
|
||||
self.api: Bitget | None = None
|
||||
self.rules: dict = {}
|
||||
self.execs: dict = {} # key → 该笔的腿与超时时刻
|
||||
self.offset = 0 # 已读到总线的哪一行
|
||||
self.n_seen = self.n_took = self.n_skip = 0
|
||||
# 建仓成功/失败分开计。只看 n_took 分不出"做了但下单被拒"——首日
|
||||
# 那 5 小时里 n_took=4 而实际一笔都没建上
|
||||
self.n_built = self.n_order_fail = 0
|
||||
self.t0 = time.time()
|
||||
# 置 0 让第一次 5 分钟心跳就推,不必等满一个周期:重启后最该尽早确认
|
||||
# 的是「上游也通」,而这个只有在线那条带得出来
|
||||
self.tg_hb_at = 0.0
|
||||
|
||||
# ── 启动 ──────────────────────────────────────────────────────
|
||||
async def start(self) -> None:
|
||||
assert_decomposable()
|
||||
# 只从"现在"往后做。历史信号的参考成交价早已过期,补做等于随机入场
|
||||
self.offset = sum(1 for _ in signal_bus.read_all(self.bus))
|
||||
print(f" 总线已有 {self.offset} 条历史信号,全部跳过(参考价已过期)",
|
||||
flush=True)
|
||||
|
||||
self.api = Bitget(dry=self.dry)
|
||||
self.rules = await self.api.contracts()
|
||||
print(f" 合约规则 {len(self.rules)} 个", flush=True)
|
||||
# 启动推送兼作"通道通不通"的自检:配错了这里就收不到,而不是等到
|
||||
# 几小时后第一个真信号来时才发现
|
||||
await tg.started(NOTIONAL, LEVERAGE, len(SYMS), self.dry)
|
||||
print(f" Telegram {'已启用' if tg.ENABLED else '未配置(不推送)'}",
|
||||
flush=True)
|
||||
if self.dry:
|
||||
print(" ⚠ 空跑模式:不下真单", flush=True)
|
||||
# 但仍要发一次**带签名**的请求。上面的 contracts() 是公开端点,
|
||||
# 不验签,光靠它空跑会"通过"却根本没测到密钥与 IP 白名单——
|
||||
# 那种假保证比不测更糟:等到第一个真信号来时才暴露,而信号那时
|
||||
# 正在过期,没有从容排查的余地
|
||||
if self.api.key and self.api.secret and self.api.passphrase:
|
||||
try:
|
||||
acc = await self.api.account() or {}
|
||||
print(" ✓ 密钥与 IP 白名单通", flush=True)
|
||||
# 打全几个余额字段,不只看 available:我们用逐仓,真正
|
||||
# 决定能不能开的是 isolatedMaxAvailable。只看一个字段,
|
||||
# 取错了就会把"有钱"误报成"没钱",或者反过来
|
||||
fields = ("accountEquity", "usdtEquity", "available",
|
||||
"isolatedMaxAvailable", "crossedMaxAvailable",
|
||||
"maxTransferOut", "locked", "unrealizedPL")
|
||||
shown = {k: acc.get(k) for k in fields if k in acc}
|
||||
print(f" 余额 {shown}", flush=True)
|
||||
# 逐仓下取 isolatedMaxAvailable,缺了才退回 available
|
||||
av = float(acc.get("isolatedMaxAvailable")
|
||||
or acc.get("available") or 0)
|
||||
need = NOTIONAL / LEVERAGE * MAX_OPEN
|
||||
print(f" 可开保证金 {av:.2f} USDT · {MAX_OPEN} 笔并发"
|
||||
f"需约 {need:.0f}(名义 {NOTIONAL:.0f} / "
|
||||
f"{LEVERAGE}x)", flush=True)
|
||||
if av < need:
|
||||
per = NOTIONAL / LEVERAGE
|
||||
fit = int(av // per) if per > 0 else 0
|
||||
print(f" ⚠ 保证金只够 {fit} 笔,而 MAX_OPEN="
|
||||
f"{MAX_OPEN}。", flush=True)
|
||||
# 这里不只是"少做几笔"。两条腿是分别下单的,第一条
|
||||
# 成了、第二条因保证金不足失败,就留下一个半仓——
|
||||
# 收益结构从「50% 在 3 ATR + 50% 在 8 ATR」变成只剩
|
||||
# 一条腿,而且是静默的。让闸按真实余额拦,比让交易所
|
||||
# 拒单干净
|
||||
print(f" 要么充钱到 {need:.0f}+ USDT,要么把 "
|
||||
f"LIVE_MAX_OPEN 降到 {max(fit, 1)}。不改的话"
|
||||
f"超出的信号会下单失败,且可能只成一条腿、"
|
||||
f"静默变成半仓(收益结构就不是设计的那个了)",
|
||||
flush=True)
|
||||
if fit == 0:
|
||||
print(f" 现在连一笔都开不了(每笔需 "
|
||||
f"{per:.0f} USDT)", flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 退出前要关会话。SystemExit 会绕过 run() 里的收尾,
|
||||
# 漏了就在日志尾部留一串 "Unclosed client session",
|
||||
# 把真正的报错顶出视野
|
||||
await self.api.close()
|
||||
raise SystemExit(
|
||||
f"⛔ 带签名的请求失败:{type(e).__name__}: {e}\n"
|
||||
f" 40018 = 出口 IP 不在该 key 的白名单里;\n"
|
||||
f" 40037 = key 不存在(填错或已删);\n"
|
||||
f" 40001/40009 = secret/passphrase 不对;\n"
|
||||
f" 40099 之类 = 权限没开够(要读+交易)。")
|
||||
else:
|
||||
print(" ⚠ 没填密钥,本次**未**验证密钥与 IP 白名单",
|
||||
flush=True)
|
||||
return
|
||||
if not (self.api.key and self.api.secret and self.api.passphrase):
|
||||
await self.api.close()
|
||||
raise SystemExit("⛔ 缺 BITGET_API_KEY / BITGET_API_SECRET / "
|
||||
"BITGET_PASSPHRASE(末项无 API_)。先跑 --dry-run。")
|
||||
await self.setup_symbols()
|
||||
await self.reconcile()
|
||||
# 放在 reconcile 之后:有持仓或挂单时交易所不允许切换持仓模式,而
|
||||
# reconcile 刚把仓位平干净
|
||||
await self.setup_position_mode()
|
||||
|
||||
async def setup_position_mode(self) -> None:
|
||||
"""把持仓模式钉成单向,并读回核对。
|
||||
|
||||
为什么必须显式设而不是假设:持仓模式决定下单体的语法,两者不匹配是
|
||||
**整体拒单**,不是部分降级。实盘上就因为带着 `tradeSide`(双向语法)
|
||||
打到单向账户,连续 8 次下单全被 40774 拒掉——4 个信号 × 2 条腿,而
|
||||
投递链路那时完全正常(延后 0.0~0.2s、ssh 零重连)。
|
||||
|
||||
单向是我们要的:永不同时持有两个方向,且出场腿依赖 `reduceOnly`,
|
||||
而它只在单向模式下有效。
|
||||
"""
|
||||
try:
|
||||
d = await self.api.set_position_mode("one_way_mode") or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⛔ 设持仓模式失败 {type(e).__name__}: {e}", flush=True)
|
||||
print(" 若账户实际是双向持仓,下单会被 40774 全部拒掉。"
|
||||
"先在 App 里平掉所有仓位与挂单,再切成单向", flush=True)
|
||||
await tg.error("设持仓模式失败,下单可能被 40774 全拒", str(e))
|
||||
return
|
||||
got = str(d.get("posMode") or "")
|
||||
if got and got != "one_way_mode":
|
||||
print(f" ⛔ 持仓模式仍是 {got},下单体是单向语法,会被 40774 拒",
|
||||
flush=True)
|
||||
await tg.error(f"持仓模式是 {got},不是单向", "下单会被 40774 拒掉")
|
||||
else:
|
||||
print(f" 已设持仓模式 单向{'(已读回核对)' if got else ''}",
|
||||
flush=True)
|
||||
|
||||
async def setup_symbols(self) -> None:
|
||||
"""逐仓 + 杠杆。每次启动都设一遍,不假设交易所侧的状态。
|
||||
|
||||
杠杆若被人在 App 里改过,仓位大小就不是我们算的那个。设成幂等操作比
|
||||
读回来核对简单,且失败会直接暴露。
|
||||
"""
|
||||
bad: list[str] = []
|
||||
for s in SYMS:
|
||||
sym = f"{s}USDT"
|
||||
for fn, arg in ((self.api.set_margin_mode, "isolated"),
|
||||
(self.api.set_leverage, LEVERAGE)):
|
||||
try:
|
||||
await fn(sym, arg)
|
||||
except Exception as e:
|
||||
print(f" ⚠ {sym} 设置失败 {type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
bad.append(f"{sym}/{fn.__name__}")
|
||||
if not bad:
|
||||
print(f" 已设 {len(SYMS)} 个币为逐仓 {LEVERAGE}x", flush=True)
|
||||
return
|
||||
# 不能无条件报"已设好"。杠杆设失败是有经济后果的:仓位大小由名义额
|
||||
# 算、与杠杆无关,但保证金要求会变。若交易所侧实际是 1x,每笔要 100
|
||||
# USDT 保证金,第 2、3 笔必然失败,且可能只成一条腿变成半仓
|
||||
print(f" ⛔ {len(bad)} 项设置失败,交易所侧的逐仓/杠杆**不是** "
|
||||
f"{LEVERAGE}x:{bad[:6]}{' …' if len(bad) > 6 else ''}",
|
||||
flush=True)
|
||||
print(" 后果不是不能交易,而是保证金要求与我们算的不一致——"
|
||||
"可能只成一条腿、静默变成半仓。先在 App 里核一遍再跑",
|
||||
flush=True)
|
||||
await tg.error(f"{len(bad)} 项逐仓/杠杆设置失败",
|
||||
f"交易所侧不是 {LEVERAGE}x:{bad[:10]}")
|
||||
|
||||
async def reconcile(self) -> None:
|
||||
"""启动时把交易所的实际持仓对上。
|
||||
|
||||
崩溃重启后交易所可能还有仓位。它们的服务端止损仍在(presetStopLossPrice
|
||||
挂在交易所侧,不随进程消失),但**超时腿丢了**,会一直持有到止损或止盈。
|
||||
|
||||
选择平掉而非接管:接管要重建入场价、ATR、剩余半仓状态和已过根数,任一项
|
||||
猜错就让出场结构变成另一个东西且不报错;平掉的代价只是一笔小额亏损,
|
||||
且行为确定。
|
||||
"""
|
||||
try:
|
||||
pos = await self.my_positions()
|
||||
except Exception as e:
|
||||
print(f" ⚠ 对账读持仓失败 {type(e).__name__}: {e}", flush=True)
|
||||
return
|
||||
if not pos:
|
||||
print(" 对账:交易所无持仓,干净启动", flush=True)
|
||||
return
|
||||
print(f" ⚠ 对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
|
||||
flush=True)
|
||||
await tg.error(f"对账:发现 {len(pos)} 个遗留持仓,撤挂单后市价平掉",
|
||||
"、".join(f"{p['symbol']} {p['holdSide']} {p['total']}"
|
||||
for p in pos))
|
||||
for p in pos:
|
||||
sym, hs, sz = p["symbol"], p["holdSide"], p["total"]
|
||||
print(f" {sym} {hs} {sz} @ {p.get('openPriceAvg')}",
|
||||
flush=True)
|
||||
try:
|
||||
await self.api.cancel_all(sym)
|
||||
await self.api.close_market(
|
||||
sym, hs, sz, f"recon:{int(time.time() * 1000)}")
|
||||
log_trade({"ev": "reconcile_flatten", "symbol": sym,
|
||||
"hold_side": hs, "size": sz})
|
||||
except Exception as e:
|
||||
print(f" ⛔ 平仓失败 {type(e).__name__}: {e},"
|
||||
f"需人工介入", flush=True)
|
||||
|
||||
# ── 主循环 ────────────────────────────────────────────────────
|
||||
async def poll(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.step()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f" ⛔ 主循环异常 {type(e).__name__}: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def step(self) -> None:
|
||||
recs = list(signal_bus.read_all(self.bus))
|
||||
if len(recs) <= self.offset:
|
||||
return
|
||||
new, self.offset = recs[self.offset:], len(recs)
|
||||
for r in new:
|
||||
self.n_seen += 1
|
||||
await self.on_signal(r)
|
||||
|
||||
# 一条记录要能下单,这些字段一个都不能缺
|
||||
NEEDED = ("key", "sym", "emit_ms", "direction", "entry_px", "atr_pct")
|
||||
|
||||
async def my_positions(self) -> list:
|
||||
"""只看 SYMS 里那些币的仓位。
|
||||
|
||||
**不要**直接用 api.positions():它返回账户全部仓位,理由见 PAIRS。
|
||||
残留的局限要知道:同一个币上的第三方仓位(比如你手工开了 ADA)仍然
|
||||
分不出来,那需要按 clientOid 逐单认领,成本不划算。所以账户仍应专用。
|
||||
"""
|
||||
return [p for p in await self.api.positions() if p["symbol"] in PAIRS]
|
||||
|
||||
async def on_signal(self, r: dict) -> None:
|
||||
# 先校验再取值。缺了这一步,总线上一条字段不全的记录会抛 KeyError,
|
||||
# 把 poll 任务打死,进而**整个执行器停止交易**——一行坏数据换全面停摆,
|
||||
# 代价完全不对等。搬运侧已挡半行,但挡不住字段级的不全
|
||||
miss = [k for k in self.NEEDED if r.get(k) is None]
|
||||
if miss:
|
||||
self.n_skip += 1
|
||||
print(f" ⊘ 丢弃畸形记录,缺字段 {miss}:{str(r)[:200]}", flush=True)
|
||||
log_trade({"ev": "malformed", "missing": miss, "rec": str(r)[:500]})
|
||||
await tg.error("总线上有畸形记录,已丢弃",
|
||||
f"缺字段 {miss}\n{str(r)[:300]}")
|
||||
return
|
||||
age = time.time() - r["emit_ms"] / 1000.0
|
||||
n_open = sum(1 for v in self.execs.values() if v)
|
||||
why = self.guard.blocks(r["key"], n_open)
|
||||
# 同币占用检查。整套记账隐含「一个币最多一个仓位」这个前提,但它原先
|
||||
# 只存在于口头上。同币开两笔时交易所会**净成一个仓位**,于是:
|
||||
# · watch() 按 pair 判出场,两个 key 同时进 gone;_match_hist 给它们
|
||||
# 返回同一条历史记录,realized() 被调两次 → pnl_day 翻倍。而这正是
|
||||
# MAX_DAY_LOSS 读的数:亏损翻倍会提前停机,盈利翻倍会让闸变迟钝
|
||||
# · sweep() 在第一笔截止时平掉合并后的整个仓位,把第二笔才持有十分钟
|
||||
# 的部分一并平掉
|
||||
# 合并后的行为(一个止损、两个不同价位的止盈、超时一锅端)不是任何一版
|
||||
# 回测建模的东西,所以跳过第二个信号是最接近安全的近似。期望并发 0.18
|
||||
# 笔,这一跳损失极小
|
||||
if why is None:
|
||||
dup = [k for k, v in self.execs.items() if v["sym"] == r["sym"]]
|
||||
if dup:
|
||||
why = (f"{r['sym']} 已有在场仓位 {dup[0]}——同币开两笔会在"
|
||||
f"交易所侧净成一个仓位,把盈亏记账和超时腿都搞错")
|
||||
if why is None and age > STALE_S:
|
||||
why = f"信号已过期 {age:.1f}s > {STALE_S:.0f}s"
|
||||
if why:
|
||||
self.n_skip += 1
|
||||
print(f" ⊘ {r['key']} 跳过:{why}", flush=True)
|
||||
log_trade({"ev": "skip", "key": r["key"], "why": why,
|
||||
"age_s": round(age, 2)})
|
||||
# 只有被硬约束挡住才推。过期跳过是常态(搬运重连会重放旧信号),
|
||||
# 推了会把真事淹掉
|
||||
if "过期" not in why and "已做过" not in why:
|
||||
await tg.blocked(r["key"], why)
|
||||
return
|
||||
|
||||
self.guard.took(r["key"])
|
||||
self.n_took += 1
|
||||
lg = legs()
|
||||
side = "LONG" if r["direction"] > 0 else "SHORT"
|
||||
print(f" ▶ {r['key']} {side} 名义 {NOTIONAL:.0f} {LEVERAGE}x "
|
||||
f"· 延后 {age:.1f}s · ATR {r['atr_pct'] * 1e4:.1f}bp",
|
||||
flush=True)
|
||||
for x in lg:
|
||||
print(f" {x['tag']:<7}止盈 {x['atr']:.0f} ATR = "
|
||||
f"{x['atr'] * r['atr_pct'] * 1e4:.1f}bp · 止损 "
|
||||
f"{SL_ATR * r['atr_pct'] * 1e4:.1f}bp · 超时 {MAXB}min",
|
||||
flush=True)
|
||||
log_trade({"ev": "entry", "key": r["key"], "side": side,
|
||||
"entry_px": r["entry_px"], "atr_pct": r["atr_pct"],
|
||||
"notional": NOTIONAL, "leverage": LEVERAGE,
|
||||
"age_s": round(age, 2), "legs": lg, "dry": self.dry})
|
||||
# 空跑也要走完 open_position:数量取整、价位对齐 tick、请求体构造都在
|
||||
# 那里,跳过等于什么都没验。不下真单由 Bitget(dry=True) 负责
|
||||
await self.open_position(r, lg)
|
||||
|
||||
def qty_of(self, sym: str, entry: float) -> tuple[str, str]:
|
||||
"""入场量与半仓量,都对齐步长。
|
||||
|
||||
入场量取到**步长的偶数倍**,半仓才是精确一半。不这么做 SOL 的半仓会是
|
||||
全仓的 43%(步长 0.1 币 ≈ 10.7 USDT),而回测假设 50/50。
|
||||
"""
|
||||
r = self.rules.get(f"{sym}USDT")
|
||||
if not r:
|
||||
raise RuntimeError(f"{sym} 没有合约规则")
|
||||
step = Decimal(str(r["sizeMultiplier"]))
|
||||
px = Decimal(str(entry))
|
||||
grid = step * 2
|
||||
n = max(Decimal("1"),
|
||||
(Decimal(str(NOTIONAL)) / px / grid).quantize(Decimal("1")))
|
||||
qty = n * grid
|
||||
return str(qty), str(qty / 2)
|
||||
|
||||
def snap(self, sym: str, px: float) -> str:
|
||||
r = self.rules[f"{sym}USDT"]
|
||||
tick = Decimal(str(r["priceEndStep"])) * (
|
||||
Decimal(10) ** -int(r["pricePlace"]))
|
||||
q = (Decimal(str(px)) / tick).quantize(Decimal("1")) * tick
|
||||
return str(q)
|
||||
|
||||
async def open_position(self, r: dict, lg: list[dict]) -> None:
|
||||
"""两笔「市价入场 + 服务端止损」,再各挂一个 maker 止盈。
|
||||
|
||||
止损随入场单一起到交易所(presetStopLossPrice),所以不存在"已入场、
|
||||
止损未挂"的裸仓窗口——那是本地盯价方案最危险的一段。
|
||||
|
||||
止盈单独挂 post_only 限价:成本模型里止盈按 maker 计且不吃滑点,用
|
||||
preset(触发后市价)会让这部分变成 taker,预算就不成立了。
|
||||
"""
|
||||
sym, d = r["sym"], r["direction"]
|
||||
pair = f"{sym}USDT"
|
||||
entry = r["entry_px"]
|
||||
a = entry * r["atr_pct"]
|
||||
_, half = self.qty_of(sym, entry)
|
||||
side = "buy" if d > 0 else "sell"
|
||||
close_side = "sell" if d > 0 else "buy"
|
||||
hold = "long" if d > 0 else "short"
|
||||
stop_px = self.snap(sym, entry - d * SL_ATR * a)
|
||||
|
||||
opened, failed = [], []
|
||||
for x in lg:
|
||||
oid = oid_of(r["key"], x["tag"])
|
||||
try:
|
||||
await self.api.entry_with_stop(pair, side, half, stop_px, oid)
|
||||
except Exception as e:
|
||||
print(f" ⛔ {x['tag']} 入场失败 {e}", flush=True)
|
||||
log_trade({"ev": "entry_fail", "key": r["key"],
|
||||
"tag": x["tag"], "err": str(e)})
|
||||
failed.append(f"{x['tag']} 入场:{e}")
|
||||
continue
|
||||
tp_px = self.snap(sym, entry + d * x["atr"] * a)
|
||||
try:
|
||||
await self.api.tp_limit(pair, close_side, half, tp_px,
|
||||
oid + "_tp")
|
||||
except Exception as e:
|
||||
# 入场成了但止盈没挂上:仓位仍有服务端止损,不是裸仓。
|
||||
# 超时腿会兜住它,所以只告警不强平
|
||||
print(f" ⚠ {x['tag']} 止盈挂单失败 {e}"
|
||||
f"(仓位有服务端止损,超时腿会兜)", flush=True)
|
||||
log_trade({"ev": "tp_fail", "key": r["key"],
|
||||
"tag": x["tag"], "err": str(e)})
|
||||
failed.append(f"{x['tag']} 止盈:{e}")
|
||||
opened.append({"tag": x["tag"], "oid": oid, "size": half,
|
||||
"tp_px": tp_px, "stop_px": stop_px})
|
||||
print(f" {x['tag']:<7}{half} 币 · 止损 {stop_px} · "
|
||||
f"止盈 {tp_px}", flush=True)
|
||||
|
||||
log_trade({"ev": "opened", "key": r["key"], "legs": opened,
|
||||
"stop_px": stop_px})
|
||||
# 下单失败必须推。这类失败是**静默的经济损失**:日志里在报,但表现只是
|
||||
# "一直没开仓",看起来和"没信号"一样。实盘首日就因为这个白跑 5 小时——
|
||||
# 8 次下单全被 40774 拒掉而无人知道。所以推送不是可选的
|
||||
if failed:
|
||||
self.n_order_fail += 1
|
||||
if not opened:
|
||||
await tg.error(
|
||||
f"{r['key']} 建仓全部失败,这个信号丢了",
|
||||
"\n".join(failed) +
|
||||
f"\n\n累计失败 {self.n_order_fail} 次。"
|
||||
f"链路正常但下不了单——常见是下单体字段被拒"
|
||||
f"(40774 持仓模式不匹配)、保证金不足、或该币被限制交易")
|
||||
else:
|
||||
await tg.error(f"{r['key']} 部分腿失败,收益结构已偏离设计",
|
||||
"\n".join(failed))
|
||||
if opened:
|
||||
self.n_built += 1
|
||||
self.execs[r["key"]] = {
|
||||
"sym": sym, "pair": pair, "hold": hold,
|
||||
"deadline": time.time() + MAXB * 60, "legs": opened,
|
||||
# watch() 靠这个时间戳去 history-position 里认领对应的平仓记录
|
||||
"opened_ms": int(time.time() * 1000),
|
||||
"side": "LONG" if d > 0 else "SHORT"}
|
||||
await tg.opened(sym, self.execs[r["key"]]["side"], entry,
|
||||
r["atr_pct"], NOTIONAL, LEVERAGE, stop_px,
|
||||
opened, time.time() - r["emit_ms"] / 1000.0)
|
||||
|
||||
async def watch(self) -> None:
|
||||
"""盯交易所侧的出场,把已实现盈亏记回闸。
|
||||
|
||||
为什么必须有这个循环:止损与止盈都挂在交易所侧,成交时本进程收不到
|
||||
任何通知。缺了它有两个后果,都是静默的:
|
||||
|
||||
1. `Guard.realized()` 没人调用 → `pnl_day` 恒为 0 →
|
||||
**MAX_DAY_LOSS 这道闸完全不生效**。三条硬约束里最重要的一条。
|
||||
2. `self.execs` 的条目要挂到 48 分钟截止才清 → `MAX_OPEN` 把已经
|
||||
出场的仓位继续算在场 → 新信号被白挡掉。方向保守但不是本意。
|
||||
|
||||
盈亏取交易所的 `netProfit`(= pnl + 资金费 + 开平手续费),不自己按
|
||||
标记价估——估会漏掉费用,而且方向总是偏乐观。
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(WATCH_S)
|
||||
if self.dry or not self.execs:
|
||||
continue
|
||||
try:
|
||||
live = {p["symbol"] for p in await self.my_positions()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 盯仓读持仓失败 {type(e).__name__}: {e}", flush=True)
|
||||
continue
|
||||
gone = [k for k, st in self.execs.items()
|
||||
if st["pair"] not in live]
|
||||
if not gone:
|
||||
continue
|
||||
# 两个半仓在同一 symbol 上会被交易所净成一个仓位,所以一个 key
|
||||
# 对应一条历史记录。按最早的入场时间取一次历史,够覆盖全部
|
||||
since = min(self.execs[k]["opened_ms"] for k in gone) - 60_000
|
||||
try:
|
||||
hist = await self.api.history_positions(start_ms=since)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 读历史持仓失败 {type(e).__name__}: {e},"
|
||||
f"本轮不结算(下轮重试,不会漏)", flush=True)
|
||||
continue
|
||||
# 一条历史记录只能被一个 key 认领。同币并发已在 on_signal 拦住,
|
||||
# 但记账是花钱的路径:让这个不变量在本地成立,而不是依赖两百行外
|
||||
# 的另一处检查。重复认领会让 realized() 被调两次、pnl_day 翻倍
|
||||
claimed: set = set()
|
||||
for key in gone:
|
||||
st = self.execs[key]
|
||||
rec = self._match_hist(hist, st, claimed)
|
||||
if rec is None:
|
||||
# 常见于刚平掉、历史还没落库。留着下轮再试;真丢了也有
|
||||
# 48 分钟截止那条路兜住 execs 的清理
|
||||
print(f" … {key} 已出场但历史未就绪,下轮再结算",
|
||||
flush=True)
|
||||
continue
|
||||
pnl = float(rec.get("netProfit") or 0.0)
|
||||
self.guard.realized(pnl)
|
||||
self.guard.save()
|
||||
self.execs.pop(key, None)
|
||||
print(f" ◀ {key} 交易所侧出场 · 已实现 {pnl:+.2f} USDT "
|
||||
f"· 当日累计 {self.guard.pnl_day:+.2f}", flush=True)
|
||||
log_trade({"ev": "closed", "key": key, "net_profit": pnl,
|
||||
"open_px": rec.get("openAvgPrice"),
|
||||
"close_px": rec.get("closeAvgPrice")})
|
||||
await tg.closed(st["sym"], st["side"], pnl,
|
||||
float(rec.get("openAvgPrice") or 0),
|
||||
float(rec.get("closeAvgPrice") or 0),
|
||||
self.guard.pnl_day, self.guard.n_day)
|
||||
|
||||
@staticmethod
|
||||
def _match_hist(hist: list, st: dict,
|
||||
claimed: set | None = None) -> dict | None:
|
||||
"""在历史持仓里认领属于这一笔的记录。
|
||||
|
||||
按 symbol + holdSide 匹配,并要求收盘时间不早于入场时间(减 60s 容差,
|
||||
两边时钟与落库都有抖动)。同一 symbol 有多条时取最近的一条。
|
||||
|
||||
`claimed` 装已被别的 key 认走的 positionId,防止两个 key 认到同一条。
|
||||
"""
|
||||
best, best_t, best_id = None, -1.0, None
|
||||
for r in hist:
|
||||
if r.get("symbol") != st["pair"] or r.get("holdSide") != st["hold"]:
|
||||
continue
|
||||
pid = r.get("positionId")
|
||||
if claimed is not None and pid is not None and pid in claimed:
|
||||
continue
|
||||
t = float(r.get("utime") or r.get("uTime") or 0)
|
||||
if t < st["opened_ms"] - 60_000:
|
||||
continue
|
||||
if t > best_t:
|
||||
best, best_t, best_id = r, t, pid
|
||||
if best is not None and claimed is not None and best_id is not None:
|
||||
claimed.add(best_id)
|
||||
return best
|
||||
|
||||
async def sweep(self) -> None:
|
||||
"""超时腿:48 分钟到点市价平。
|
||||
|
||||
这是唯一必须靠本进程存活的出场腿。止损与止盈都在交易所侧,所以进程
|
||||
死掉只会让持仓超过 48 根,不会变成裸仓——退化是良性的。
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
now = time.time()
|
||||
for key, st in list(self.execs.items()):
|
||||
if now < st["deadline"]:
|
||||
continue
|
||||
try:
|
||||
pos = [p for p in await self.my_positions()
|
||||
if p["symbol"] == st["pair"]]
|
||||
if not pos:
|
||||
print(f" ◀ {key} 超时前已全部出场", flush=True)
|
||||
log_trade({"ev": "timeout_noop", "key": key})
|
||||
else:
|
||||
for p in pos:
|
||||
await self.api.cancel_all(st["pair"])
|
||||
await self.api.close_market(
|
||||
st["pair"], p["holdSide"], p["total"],
|
||||
oid_of(key, "timeout"))
|
||||
print(f" ◀ {key} 超时市价平 {pos[0]['total']} 币",
|
||||
flush=True)
|
||||
log_trade({"ev": "timeout_close", "key": key,
|
||||
"size": pos[0]["total"]})
|
||||
except Exception as e:
|
||||
print(f" ⛔ {key} 超时平仓失败 {type(e).__name__}: {e}",
|
||||
flush=True)
|
||||
continue
|
||||
self.execs.pop(key, None)
|
||||
|
||||
def ship_state(self) -> dict | None:
|
||||
"""读搬运器落的存活文件。读不到返回 None——那本身就是要报的事。"""
|
||||
try:
|
||||
p = self.bus.parent / "ship_alive.json"
|
||||
with open(p, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
async def heartbeat(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(300)
|
||||
if TG_HB_MIN and time.time() - self.tg_hb_at >= TG_HB_MIN * 60:
|
||||
self.tg_hb_at = time.time()
|
||||
await tg.alive(time.time() - self.t0, self.n_seen,
|
||||
self.n_built, self.n_took,
|
||||
sum(1 for v in self.execs.values() if v),
|
||||
MAX_OPEN, self.guard.n_day, MAX_DAY,
|
||||
self.guard.pnl_day, MAX_DAY_LOSS,
|
||||
self.n_order_fail, self.ship_state(), self.dry)
|
||||
# 跨日结算是 roll() 里同步留下的,在这里发出去
|
||||
self.guard.roll()
|
||||
if self.guard.pending_roll:
|
||||
await tg.day_rolled(*self.guard.pending_roll)
|
||||
self.guard.pending_roll = None
|
||||
n_open = sum(1 for v in self.execs.values() if v)
|
||||
# 「已做 N 但建仓 0」是明确的故障,不能只把数字并排列出来让人自己
|
||||
# 看。首日那 5 小时的心跳里 "已做 4 · 在场 0/3" 一直在打,但没有
|
||||
# 任何一处说这是异常
|
||||
bad = ""
|
||||
if self.n_took and not self.n_built:
|
||||
bad = f" ⛔ 做了 {self.n_took} 笔却一次都没建上,下单被拒"
|
||||
elif self.n_order_fail:
|
||||
bad = f" ⚠ 有 {self.n_order_fail} 次下单失败"
|
||||
print(f" [心跳] 见信号 {self.n_seen} · 已做 {self.n_took} · "
|
||||
f"建仓 {self.n_built} · 跳过 {self.n_skip} · "
|
||||
f"在场 {n_open}/{MAX_OPEN} · "
|
||||
f"当日 {self.guard.n_day}/{MAX_DAY} 笔 · "
|
||||
f"当日 PnL {self.guard.pnl_day:+.2f}/-{MAX_DAY_LOSS}{bad}",
|
||||
flush=True)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""收到停机信号:撤挂单 + 平掉在场仓位,然后退出。
|
||||
|
||||
为什么停机要平仓,而崩溃不需要:崩溃后 systemd/docker 会在几秒内重启,
|
||||
`reconcile` 接着就把遗留仓位清掉,空窗期有交易所侧止损兜着。而**主动
|
||||
停机后没人重启**,仓位会一直挂到止损或止盈——48 分钟超时腿丢了,跑的
|
||||
就不是回测那个出场结构了。
|
||||
|
||||
直接复用 reconcile:它做的正是"撤挂单 + 市价平掉一切"。
|
||||
"""
|
||||
print("\n 收到停机信号,撤挂单并平掉在场仓位", flush=True)
|
||||
await tg.stopping(len(self.execs))
|
||||
try:
|
||||
if self.dry:
|
||||
print(" 空跑模式,无仓位可平", flush=True)
|
||||
else:
|
||||
await asyncio.wait_for(self.reconcile(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
print(" ⛔ 平仓超过 60s 未完成。仓位仍有交易所侧止损,"
|
||||
"但超时腿已丢,去交易所确认", flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⛔ 停机平仓失败 {type(e).__name__}: {e},需人工介入",
|
||||
flush=True)
|
||||
finally:
|
||||
# 不关会话会在日志里留 "Unclosed client session",且反复重启
|
||||
# (Restart=always)会漏 socket
|
||||
try:
|
||||
await self.api.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def run(self) -> None:
|
||||
await self.start()
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
# 必须显式挂 SIGTERM:docker stop 与 systemd stop 默认发的都是它,
|
||||
# 而 Python 对 SIGTERM 不抛 KeyboardInterrupt,不挂就是直接消失、
|
||||
# 没有任何清理。SIGINT 一并挂上,省得依赖 KillSignal= 那种绕法
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
work = [asyncio.create_task(c)
|
||||
for c in (self.poll(), self.watch(), self.sweep(),
|
||||
self.heartbeat())]
|
||||
done, _ = await asyncio.wait(
|
||||
[*work, asyncio.create_task(stop.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED)
|
||||
for t in work:
|
||||
t.cancel()
|
||||
await asyncio.gather(*work, return_exceptions=True)
|
||||
# 任一主循环自己退出(异常)也走这里:仓位不能留给没人管的进程
|
||||
for t in done:
|
||||
if t in work and (exc := t.exception()) is not None:
|
||||
print(f" ⛔ 主循环异常退出 {type(exc).__name__}: {exc}",
|
||||
flush=True)
|
||||
await tg.error("主循环异常退出,正在平仓并退出",
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
await self.shutdown()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="不连交易所、不下单,只验总线与约束逻辑")
|
||||
ap.add_argument("--bus", default=str(signal_bus.BUS))
|
||||
a = ap.parse_args()
|
||||
|
||||
print(f"实盘执行器 · 名义 {NOTIONAL:.0f} USDT · {LEVERAGE}x · "
|
||||
f"并发≤{MAX_OPEN} · 日开仓≤{MAX_DAY} · 日亏损≤{MAX_DAY_LOSS}")
|
||||
asyncio.run(Exec(a.dry_run, Path(a.bus)).run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
# 生产执行器的全部依赖。**保持这个文件只有一行。**
|
||||
#
|
||||
# 每加一个包都要能回答"实盘进程崩在这个包里我怎么办"。numpy/pandas/pyarrow
|
||||
# 曾经因为读两个常量被拖进来(见 live/exit_params.py 的说明),已经切掉。
|
||||
#
|
||||
# 版本下限的理由:3.9 起 aiohttp 才在 Python 3.12+ 上稳定编译;不锁上限是
|
||||
# 因为这里只用 ClientSession.request 这一个最稳定的 API 面。
|
||||
aiohttp>=3.9
|
||||
@@ -0,0 +1,232 @@
|
||||
"""把产信号那台机的总线搬到本机(生产机)。跑在**消费侧**,即 AWS 上。
|
||||
|
||||
## 为什么要搬
|
||||
|
||||
Bitget 的 API key 绑了 IP 白名单,只能从 AWS 那台发单;而信号是新加坡那台
|
||||
采集器算出来的。执行器不自己算信号的理由见 `signal_bus.py` 顶部——最要紧的
|
||||
是「实盘交易的必须是影子测量的那一个信号」,各算一份会悄悄分叉。
|
||||
|
||||
## 为什么是拉而不是推
|
||||
|
||||
拉的一侧是生产机,它对自己的输入负责。推的话,研究机上一个脚本挂了就会静默
|
||||
断供,而生产机看不出区别(信号本来就 6.8 个/天,长时间没有是正常的)。
|
||||
|
||||
## 断线怎么自愈
|
||||
|
||||
每次重连都 `tail -c +0`,即从文件头重放全部内容,本地按 `key` 去重后只追加
|
||||
新的。所以断线期间产生的信号会在重连时补齐,不需要记录偏移量。
|
||||
|
||||
⚠️ 补齐**不等于**补做:重放上来的旧信号会被 `live_exec` 的 `LIVE_STALE_S`
|
||||
(默认 20s)挡掉。这是对的——参考成交价是次根开盘价,过了就不是回测那个价。
|
||||
所以断线超过 20s 就等于漏掉那些信号,这是可接受的退化,不是 bug。
|
||||
|
||||
## 为什么单独一个进程
|
||||
|
||||
搬运挂掉时,执行器要继续管在场仓位(48 分钟超时平仓在本进程里)。合成一个
|
||||
进程会让传输故障连坐到仓位管理。
|
||||
|
||||
python live/ship_signals.py --from sg-collector # 用 ~/.ssh/config 的别名
|
||||
python live/ship_signals.py --from user@1.2.3.4 --remote-bus /home/user/chan-live/state/signals_live.jsonl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
import signal_bus # noqa: E402
|
||||
|
||||
# ssh 参数的理由:
|
||||
# BatchMode 不要交互提示密码,否则进程会挂在那里等输入
|
||||
# ServerAliveInterval/CountMax 45s 内探测不到就断开重连。没有这两条,
|
||||
# NAT 静默丢弃连接后 tail 会永远挂着不返回,表现为
|
||||
# 「进程活着但再也收不到信号」——最难发现的那种故障
|
||||
# ExitOnForwardFailure/StrictHostKeyChecking 留默认,主机指纹要人工确认过
|
||||
SSH_OPTS = ["-T", "-o", "BatchMode=yes",
|
||||
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
|
||||
"-o", "ConnectTimeout=10"]
|
||||
|
||||
REMOTE_BUS = os.environ.get(
|
||||
"SHIP_REMOTE_BUS", "~/chan-live/state/signals_live.jsonl")
|
||||
BACKOFF_MAX = 60.0
|
||||
# 允许的负龄。1s 覆盖正常的 NTP 抖动与网络传输,超出就该当时钟问题查
|
||||
SKEW_TOL_S = 1.0
|
||||
|
||||
|
||||
class Shipper:
|
||||
def __init__(self, host: str, remote_bus: str, local_bus: Path) -> None:
|
||||
self.host = host
|
||||
self.remote_bus = remote_bus
|
||||
self.bus = local_bus
|
||||
self.seen: set[str] = set()
|
||||
self.n_new = 0
|
||||
self.n_dup = 0
|
||||
self.connected_at = 0.0
|
||||
self.last_signal_ts = 0.0
|
||||
self.n_reconnect = 0
|
||||
self.n_skew = 0
|
||||
self.ssh_up = False
|
||||
self.alive = local_bus.parent / "ship_alive.json"
|
||||
|
||||
def load_seen(self) -> None:
|
||||
"""本地已有的键先读进来,避免重启后把整个文件再追加一遍。"""
|
||||
self.bus.parent.mkdir(parents=True, exist_ok=True)
|
||||
for rec in signal_bus.read_all(self.bus):
|
||||
k = rec.get("key")
|
||||
if k:
|
||||
self.seen.add(k)
|
||||
print(f" 本地已有 {len(self.seen)} 条信号,按 key 去重", flush=True)
|
||||
|
||||
def absorb(self, line: str) -> None:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# 半行:tail 在写入中途读到。重连重放时会拿到完整的那一行
|
||||
print(f" ⚠ 跳过无法解析的一行({len(line)} 字节)", flush=True)
|
||||
return
|
||||
k = rec.get("key")
|
||||
if not k:
|
||||
print(f" ⚠ 跳过无 key 的记录:{line[:80]}", flush=True)
|
||||
return
|
||||
if k in self.seen:
|
||||
self.n_dup += 1
|
||||
return
|
||||
self.seen.add(k)
|
||||
self.n_new += 1
|
||||
self.last_signal_ts = time.time()
|
||||
# 原样追加,不重新序列化——保持与源文件逐字节一致,便于事后对账
|
||||
with self.bus.open("a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
age = time.time() - rec.get("kline_ts", 0) / 1000.0
|
||||
if age < -SKEW_TOL_S:
|
||||
# 负龄说明产信号那台机的时钟快于本机。这不是无害的:staleness 闸
|
||||
# 靠 age 判断,时钟快 5 分钟就等于把闸放宽 5 分钟,一个早已失效的
|
||||
# 参考价会被当成新鲜的照做。两台都必须挂 NTP(部署文档里是硬要求)
|
||||
self.n_skew += 1
|
||||
print(f" ⛔ {k} 时间倒流 {-age:.1f}s —— 两机时钟不同步,"
|
||||
f"staleness 闸已不可信。查 chronyd/systemd-timesyncd",
|
||||
flush=True)
|
||||
mark = "" if age <= 20 else " ⚠ 已超 20s,执行器会挡掉"
|
||||
print(f" ▶ 收到 {k} · 距参考价成立 {age:.1f}s{mark}", flush=True)
|
||||
|
||||
async def pump(self) -> None:
|
||||
"""连一次,读到断为止。返回即表示需要重连。"""
|
||||
cmd = ["ssh", *SSH_OPTS, self.host,
|
||||
f"tail -c +0 -F {self.remote_bus}"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE)
|
||||
self.connected_at = time.time()
|
||||
self.ssh_up = True
|
||||
self.touch_alive(0) # 立刻落盘,别等 5 分钟心跳——执行器第一轮
|
||||
# 整点推送会读这个文件,晚写就会误报上游断了
|
||||
print(f" ssh 已连上 {self.host}", flush=True)
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
async for raw in proc.stdout:
|
||||
self.absorb(raw.decode("utf-8", "replace"))
|
||||
finally:
|
||||
err = b""
|
||||
if proc.stderr is not None:
|
||||
try:
|
||||
err = await asyncio.wait_for(proc.stderr.read(), 2.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if proc.returncode is None:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
up = time.time() - self.connected_at
|
||||
self.ssh_up = False
|
||||
self.touch_alive(up) # 立刻标断开。只靠停更来发现的话,心跳还在
|
||||
# 刷 ts,执行器会以为管道还活着
|
||||
msg = err.decode("utf-8", "replace").strip()
|
||||
print(f" ssh 断开(在线 {up:.0f}s,退出码 {proc.returncode})"
|
||||
f"{':' + msg if msg else ''}", flush=True)
|
||||
|
||||
async def run(self) -> None:
|
||||
self.load_seen()
|
||||
asyncio.create_task(self.heartbeat())
|
||||
backoff = 1.0
|
||||
while True:
|
||||
try:
|
||||
await self.pump()
|
||||
backoff = 1.0 # 正常断开:立刻重连
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 搬运出错:{type(e).__name__}: {e}", flush=True)
|
||||
self.n_reconnect += 1
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, BACKOFF_MAX)
|
||||
|
||||
async def heartbeat(self) -> None:
|
||||
"""信号 6.8 个/天,所以「很久没收到」是正常的,不能当健康指标。
|
||||
|
||||
真正要报的是**连接**在不在:ssh 在线时长与重连次数。管道死了但进程
|
||||
活着是这里最危险的状态,ServerAliveInterval 负责让它变成一次断开。
|
||||
"""
|
||||
while True:
|
||||
await asyncio.sleep(300)
|
||||
up = time.time() - self.connected_at if self.connected_at else 0
|
||||
last = (f"{(time.time() - self.last_signal_ts) / 60:.0f} 分钟前"
|
||||
if self.last_signal_ts else "本次启动后还没有")
|
||||
skew = f" · ⛔ 时钟倒流 {self.n_skew} 次" if self.n_skew else ""
|
||||
print(f" [心跳] ssh 在线 {up / 60:.0f} 分钟 · 重连 "
|
||||
f"{self.n_reconnect} 次 · 新增 {self.n_new} 条"
|
||||
f"(重放去重 {self.n_dup})· 最近一条 {last}{skew}",
|
||||
flush=True)
|
||||
self.touch_alive(up)
|
||||
|
||||
def touch_alive(self, up: float) -> None:
|
||||
"""把连接状态落到文件,供执行器的整点推送读。
|
||||
|
||||
为什么要落盘:Telegram 推送在执行器那侧,而它看不到本进程的日志。
|
||||
「执行器活着」单独没有意义——搬运管道死掉时执行器一样心跳正常、一样
|
||||
什么都不做,那正是最危险的状态。所以推送里必须带上游的新鲜度,
|
||||
这个文件是唯一的传递途径。
|
||||
|
||||
写失败只打日志:搬运的正事是投信号,不能因为写不了状态文件而中断。
|
||||
"""
|
||||
try:
|
||||
self.alive.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.alive.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({
|
||||
"ts": time.time(), "up_s": round(up),
|
||||
"connected": self.ssh_up,
|
||||
"n_reconnect": self.n_reconnect, "n_new": self.n_new,
|
||||
"n_skew": self.n_skew,
|
||||
"last_signal_ts": self.last_signal_ts}), encoding="utf-8")
|
||||
tmp.replace(self.alive) # 原子替换,读侧不会看到半个文件
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" ⚠ 写存活文件失败 {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--from", dest="host", required=True,
|
||||
help="产信号那台机的 ssh 目标,如 sg-collector 或 user@ip")
|
||||
ap.add_argument("--remote-bus", default=REMOTE_BUS,
|
||||
help="对端总线路径(对端 shell 展开,可用 ~)")
|
||||
ap.add_argument("--bus", default=str(signal_bus.BUS),
|
||||
help="本机总线路径,执行器读同一个")
|
||||
a = ap.parse_args()
|
||||
s = Shipper(a.host, a.remote_bus, Path(a.bus))
|
||||
print(f"信号搬运:{a.host}:{a.remote_bus} → {a.bus}", flush=True)
|
||||
try:
|
||||
asyncio.run(s.run())
|
||||
except KeyboardInterrupt:
|
||||
print(" 停止", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""影子把过滤网的信号写到这里,实盘执行器读这里。
|
||||
|
||||
## 为什么不让实盘自己算信号
|
||||
|
||||
三个理由,第三个最要紧:
|
||||
|
||||
1. 2 核上再来一份十币计算,清空会从 247ms 推到 500ms+
|
||||
2. 实盘进程崩溃不该影响正在采的数据集
|
||||
3. **实盘交易的必须是影子测量的那一个信号。** 各算一份会让两边悄悄分叉,
|
||||
之后就没法把实盘的实际成交和影子测的滑点曲线对照——而那个对照是整件事
|
||||
的目的
|
||||
|
||||
## 为什么用 append-only 文件而不是队列
|
||||
|
||||
崩溃安全 + 留审计轨迹。实盘进程重启后能从文件里看到自己漏掉了哪些信号,
|
||||
而不是像内存队列那样直接消失。文件也让"影子在跑、实盘没在跑"这种状态成为
|
||||
可观测的(信号在攒着),而不是静默丢弃。
|
||||
|
||||
每行一个 JSON,字段见 `emit`。`key` 是幂等键,实盘按它去重——同一根被重复
|
||||
处理(补根、进程池重建后重放)不该开两次仓。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 默认不落在仓库里:git checkout/clean 会动仓库,而这里是跨进程(甚至跨机)
|
||||
# 的交接点,被清掉就等于信号静默丢失。产信号的一侧和消费的一侧各自指到
|
||||
# 自己的路径即可,同机时指到同一个文件
|
||||
BUS = Path(os.environ.get(
|
||||
"SIGNAL_BUS", Path.home() / "chan-live" / "state" / "signals_live.jsonl"))
|
||||
|
||||
|
||||
def key_of(sym: str, kline_ts: int, direction: int) -> str:
|
||||
return f"{sym}:{int(kline_ts)}:{int(direction):+d}"
|
||||
|
||||
|
||||
def emit(sym: str, kline_ts: int, direction: int, entry_px: float,
|
||||
atr_pct: float, lag_ms: float, path: Path | None = None) -> None:
|
||||
"""追写一条信号。任何失败只打日志——总线写不进去不能连坐采集。
|
||||
|
||||
`entry_px` 是次根开盘价,也就是回测口径的成交价。实盘据此算止损/止盈的
|
||||
绝对价位,**不要**用实盘自己看到的现价,否则价位会随执行延迟漂移,跑的
|
||||
就不是回测那个结构。
|
||||
"""
|
||||
p = path or BUS
|
||||
rec = {"key": key_of(sym, kline_ts, direction),
|
||||
"sym": sym, "kline_ts": int(kline_ts),
|
||||
"direction": int(direction),
|
||||
"entry_px": float(entry_px), "atr_pct": float(atr_pct),
|
||||
"lag_ms": float(lag_ms),
|
||||
"emit_ms": int(time.time() * 1000)}
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with p.open("a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
f.flush()
|
||||
# 实盘要在毫秒级看到,且进程被 SIGKILL 时不能丢——这两点都要求
|
||||
# 落到磁盘,不能只停在 libc 缓冲里
|
||||
os.fsync(f.fileno())
|
||||
except Exception as e:
|
||||
print(f" [bus] 写信号失败 {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
|
||||
def read_all(path: Path | None = None):
|
||||
"""读全部信号。坏行跳过——半行只可能出现在文件末尾的崩溃点。"""
|
||||
p = path or BUS
|
||||
if not p.exists():
|
||||
return
|
||||
for line in p.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""生产侧 Telegram 通知。只用标准库 + aiohttp。
|
||||
|
||||
## 推什么、不推什么
|
||||
|
||||
推送的价值在于**你会因此做点什么**。按这个标准筛:
|
||||
|
||||
推 开仓 能立刻眼看一遍方向/价位对不对
|
||||
推 平仓 带已实现盈亏(含手续费与资金费),这是唯一的真账
|
||||
推 闸拦截 仅 MAX_OPEN / MAX_DAY / MAX_DAY_LOSS —— 说明有 bug 或策略在流血
|
||||
推 报错、对账平仓、跨日结算
|
||||
推 整点在线 每 60 分钟一条,见下
|
||||
不推 信号过期跳过 这是常态(重连重放会带上旧信号),推了就淹掉真事
|
||||
不推 5 分钟心跳 日志里有,推了每天 288 条
|
||||
|
||||
量级:信号 6.8 个/天、日开仓上限 15、在线 24 条,所以最多约 55 条/天。
|
||||
|
||||
## 整点在线那条为什么不违反上面的标准
|
||||
|
||||
只说「我还活着」的推送看两天就会被忽略,那时它就成了噪声。所以这条必须带
|
||||
**能暴露问题的数字**,尤其是上游新鲜度——执行器活着不代表链路活着,搬运
|
||||
管道死掉时执行器一样心跳正常、一样什么都不做,那是最危险的状态。异常时这
|
||||
条会显式标出来,而不是把数字并排列出来让人自己看。
|
||||
|
||||
## 失败一律只打日志
|
||||
|
||||
推送挂了不能连坐交易。所有异常在这里吞掉——上层不该因为 Telegram 抽风而
|
||||
影响下单或平仓。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
TOKEN = os.environ.get("TG_TOKEN", "")
|
||||
CHAT = os.environ.get("TG_CHAT", "")
|
||||
ENABLED = bool(TOKEN and CHAT)
|
||||
# 生产机上给这条推送打个前缀,免得和采集机推的手工信号混在一个对话里分不清
|
||||
TAG = os.environ.get("TG_TAG", "实盘")
|
||||
|
||||
|
||||
async def send(text: str) -> None:
|
||||
"""推一条。任何失败都只打日志——推送挂了不能连坐交易。"""
|
||||
if not ENABLED:
|
||||
return
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"https://api.telegram.org/bot{TOKEN}/sendMessage"
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.post(url,
|
||||
json={"chat_id": CHAT, "text": text},
|
||||
timeout=aiohttp.ClientTimeout(total=10)) as r:
|
||||
if r.status != 200:
|
||||
body = (await r.text())[:200]
|
||||
print(f" [TG] 推送失败 HTTP {r.status} {body}", flush=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" [TG] 推送异常 {type(e).__name__}: {e}", flush=True)
|
||||
|
||||
|
||||
def _fmt(px: float) -> str:
|
||||
"""按量级选小数位。跨币种从 79000 到 0.087,固定位数会难读或丢精度。"""
|
||||
a = abs(px)
|
||||
if a >= 1000:
|
||||
return f"{px:.1f}"
|
||||
if a >= 10:
|
||||
return f"{px:.3f}"
|
||||
if a >= 1:
|
||||
return f"{px:.4f}"
|
||||
return f"{px:.6f}"
|
||||
|
||||
|
||||
async def opened(sym: str, side: str, entry: float, atr_pct: float,
|
||||
notional: float, leverage: int, stop_px: str,
|
||||
legs: list[dict], age_s: float) -> None:
|
||||
arrow = "多" if side == "LONG" else "空"
|
||||
lines = [f"[{TAG}] 开仓 {sym} {arrow}",
|
||||
f"参考价 {_fmt(entry)} · ATR {atr_pct * 1e4:.1f}bp",
|
||||
f"名义 {notional:.0f} USDT · {leverage}x · "
|
||||
f"保证金 {notional / leverage:.0f} USDT",
|
||||
f"止损 {stop_px}(两腿共用,不移动)"]
|
||||
for lg in legs:
|
||||
lines.append(f" {lg['tag']:<6} {lg['size']} 币 · 止盈 {lg['tp_px']}")
|
||||
lines.append(f"距参考价成立 {age_s:.1f}s")
|
||||
await send("\n".join(lines))
|
||||
|
||||
|
||||
async def closed(sym: str, side: str, pnl: float, open_px: float,
|
||||
close_px: float, day_pnl: float, day_n: int,
|
||||
reason: str = "") -> None:
|
||||
arrow = "多" if side == "LONG" else "空"
|
||||
mark = "盈" if pnl > 0 else ("亏" if pnl < 0 else "平")
|
||||
tail = f" · {reason}" if reason else ""
|
||||
await send(f"[{TAG}] 平仓 {sym} {arrow} {mark} {pnl:+.2f} USDT{tail}\n"
|
||||
f"开 {_fmt(open_px)} → 平 {_fmt(close_px)}\n"
|
||||
f"当日 {day_n} 笔 · 累计 {day_pnl:+.2f} USDT")
|
||||
|
||||
|
||||
async def blocked(key: str, why: str) -> None:
|
||||
"""只在被硬约束挡住时推。过期跳过属于常态,不走这里。"""
|
||||
await send(f"[{TAG}] ⛔ 信号被挡 {key}\n{why}")
|
||||
|
||||
|
||||
async def error(what: str, detail: str) -> None:
|
||||
await send(f"[{TAG}] ⛔ {what}\n{detail[:500]}")
|
||||
|
||||
|
||||
async def started(notional: float, leverage: int, syms: int,
|
||||
dry: bool) -> None:
|
||||
mode = "空跑(不下真单)" if dry else "真跑"
|
||||
await send(f"[{TAG}] 执行器启动 · {mode}\n"
|
||||
f"名义 {notional:.0f} USDT · {leverage}x · {syms} 币")
|
||||
|
||||
|
||||
async def day_rolled(day: str, n: int, pnl: float) -> None:
|
||||
await send(f"[{TAG}] {day} 结算 · {n} 笔 · {pnl:+.2f} USDT")
|
||||
|
||||
|
||||
def _dur(s: float) -> str:
|
||||
s = max(0, int(s))
|
||||
if s < 60:
|
||||
return f"{s}秒"
|
||||
if s < 3600:
|
||||
return f"{s // 60}分钟"
|
||||
if s < 86400:
|
||||
h, m = s // 3600, (s % 3600) // 60
|
||||
return f"{h}小时{m}分" if m else f"{h}小时"
|
||||
return f"{s / 86400:.1f}天"
|
||||
|
||||
|
||||
async def alive(up_s: float, seen: int, built: int, took: int, n_open: int,
|
||||
max_open: int, day_n: int, max_day: int, day_pnl: float,
|
||||
max_loss: float, order_fail: int, ship: dict | None,
|
||||
dry: bool) -> None:
|
||||
"""整点在线。异常在第一行,正常时才是「在线」。
|
||||
|
||||
`ship` 是搬运器落的 ship_alive.json 解出来的字典,None 表示读不到——
|
||||
那本身就是要报的事:搬运没在跑、或者跑的是没有这个文件的旧版本。
|
||||
"""
|
||||
warn = []
|
||||
if order_fail and not built:
|
||||
warn.append(f"⛔ 做了 {took} 笔却一次都没建上,下单全被拒")
|
||||
elif order_fail:
|
||||
warn.append(f"⚠ 累计 {order_fail} 次下单失败")
|
||||
if ship is None:
|
||||
warn.append("⛔ 读不到搬运状态,信号可能根本没在进来")
|
||||
else:
|
||||
gap = time.time() - ship.get("ts", 0)
|
||||
# 搬运连上/断开/每 5 分钟都会落一次。超过 12 分钟是进程自己死了
|
||||
if gap > 720:
|
||||
warn.append(f"⛔ 搬运状态已停更 {_dur(gap)},上游可能已断")
|
||||
elif ship.get("connected") is False:
|
||||
warn.append("⛔ 搬运 ssh 已断开,正在重连")
|
||||
if ship.get("n_skew"):
|
||||
warn.append(f"⛔ 搬运侧时钟倒流 {ship['n_skew']} 次")
|
||||
|
||||
head = warn[0] if warn else ("在线(空跑)" if dry else "在线")
|
||||
lines = [f"[{TAG}] {head} · 已跑 {_dur(up_s)}",
|
||||
f"信号 {seen} · 建仓 {built} · 在场 {n_open}/{max_open}",
|
||||
f"当日 {day_n}/{max_day} 笔 · 盈亏 {day_pnl:+.2f}/-{max_loss:.1f}"]
|
||||
if ship is not None:
|
||||
last = ship.get("last_signal_ts") or 0
|
||||
lines.append(
|
||||
f"搬运 ssh 在线 {_dur(ship.get('up_s', 0))} · "
|
||||
f"重连 {ship.get('n_reconnect', 0)} 次 · 最近信号 "
|
||||
+ (f"{_dur(time.time() - last)}前" if last else "启动后还没有"))
|
||||
lines += warn[1:]
|
||||
await send("\n".join(lines))
|
||||
|
||||
|
||||
async def stopping(n_open: int) -> None:
|
||||
await send(f"[{TAG}] 收到停机信号,平掉在场 {n_open} 笔后退出。"
|
||||
f"\n注意:停机后不再有超时平仓与新开仓。"
|
||||
f"发生时间 {time.strftime('%H:%M:%S')}")
|
||||
+1268
-23
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# 已 superseded
|
||||
|
||||
v0 改走免费交易所,由 **data_provider** 拉,chan 只从中转取。
|
||||
|
||||
见 [PROMOTE_deriv_relay.md](./PROMOTE_deriv_relay.md)。
|
||||
|
||||
CoinGlass 付费档等看完交易所效果再开,不要和本阶段混做。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 需求:资金面数据(data_provider → chan)
|
||||
|
||||
**提出方:** chan
|
||||
**执行方:** data_provider
|
||||
**阶段:** Paper。先看效果。不进 Live。
|
||||
|
||||
---
|
||||
|
||||
## 要什么
|
||||
|
||||
chan 要在缠论图上叠资金面,和现有 K 线对得上。
|
||||
|
||||
data_provider 对外提供资金面;chan **只从 data_provider 取**,不访问交易所,不访问 CoinGlass。
|
||||
|
||||
K 线路径不动(现有 `/api/candles` 与 K 线推送)。本次只加资金面。
|
||||
|
||||
---
|
||||
|
||||
## 数据
|
||||
|
||||
先三个币:**BTC、ETH、SOL**(USDT 永续,符号与现有 K 线相同,如 `BTC/USDT:USDT`)。
|
||||
|
||||
要两样:
|
||||
|
||||
1. **持仓量(OI)**
|
||||
- 要历史,能覆盖缠论常用周期:`15m`、`30m`、`4h`、`1d`(有 `1h`/`2h` 更好)。
|
||||
- 要当前最新值。
|
||||
- 历史长度至少约 30 天。
|
||||
|
||||
2. **资金费率(funding)**
|
||||
- 要当前值。
|
||||
- 要历史结算序列。
|
||||
- 对齐到各周期 K 线:结算点落到所在那根;非结算 bar 沿用上一次结算值,不要插值编造。
|
||||
|
||||
来源:交易所公开数据即可,本阶段不买 CoinGlass。OI 历史哪家所没有,用另一家所公开数据补,需标明来源。
|
||||
|
||||
**本阶段不要:** 清算、热力图、多空比、订单簿、CoinGlass。
|
||||
|
||||
---
|
||||
|
||||
## 给 chan 的接口
|
||||
|
||||
与 `/api/candles` 同一套约定:
|
||||
|
||||
- `symbol` 与蜡烛相同
|
||||
- 时间戳毫秒 UTC
|
||||
- 按周期 `tf` 取序列
|
||||
- 支持 `start` / `end` / `limit`(默认 `limit=500`)
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m&metrics=oi,funding
|
||||
```
|
||||
|
||||
每根:
|
||||
|
||||
| 字段 | 要求 |
|
||||
|---|---|
|
||||
| `timestamp` | 与同 `tf` 的 `/api/candles` **开盘时间**对齐;对不齐的不要 |
|
||||
| `oi` | 该 bar 持仓量;缺则 `null` |
|
||||
| `oi_src` | 该值来自哪家所 |
|
||||
| `funding` | 该 bar 资金费率;缺则 `null` |
|
||||
| `funding_src` | 该值来自哪家所 |
|
||||
|
||||
健康状态要能看出:资金面是否可用、各所是否通、上次成功时间。
|
||||
|
||||
---
|
||||
|
||||
## 约束
|
||||
|
||||
- 全程 HTTPS REST。本阶段不要求资金面 WebSocket。
|
||||
- chan、浏览器不得直连交易所。
|
||||
- 现有 K 线接口行为不变。
|
||||
- 一家所挂了:缺那家字段,另一家仍要能出;两边都没有且无可用数据时明确失败。
|
||||
- 上游限流或超时:不要拖垮 K 线。
|
||||
|
||||
---
|
||||
|
||||
## 不算本次
|
||||
|
||||
- CoinGlass / 付费数据
|
||||
- 清算、热力、多空
|
||||
- Live、下单
|
||||
- chan 叠图(等本接口可用再做)
|
||||
|
||||
---
|
||||
|
||||
## 怎样算齐
|
||||
|
||||
1. `GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m` 能拿到 `oi`、`funding`,时间能对上同参数的 `/api/candles`。
|
||||
2. chan / 浏览器零次访问交易所。
|
||||
3. 只挂一家所时,接口仍可用,只缺对应字段。
|
||||
4. K 线不受影响。
|
||||
@@ -0,0 +1,225 @@
|
||||
"""快速一类买卖点(fast B1/S1)——把引擎的 B1/S1 改写成当根可判的形式。
|
||||
|
||||
**动机**:step55 实测引擎 `find_all_bsp` 的 B1/S1 在 5m/15m 上是重亏的
|
||||
(PF 0.20~0.24、胜率 15~21%、t −17~−22),而且原因是几何性的:引擎在
|
||||
`leave_bi.sure_time` 才发信号,中位滞后 8~9 根,此时价格已朝反弹方向跑了
|
||||
1.68 ATR。逆势信号上,这个滞后是加倍惩罚——新入场价往下 2 ATR 的止损落在
|
||||
比原始低点还低 0.3 ATR 处,几乎贴着极值。
|
||||
|
||||
B3 当年也是同样的病(PF 0.66),解法不是调参而是**重新定义成实时判据**
|
||||
(B4,见 `chanlun/analysis/fast_bsp.py`)。本模块对一类做同样的事。
|
||||
|
||||
引擎 B1 是三个条件的合取(`bsp.py: find_all_bsp` + `check_bi_div`):
|
||||
|
||||
中枢已成 -> 一笔向下离开中枢 -> 该笔 MACD 面积 < 进入笔面积(底背驰)
|
||||
|
||||
三条都有当根可判的代理,滞后来源只有一处——`leave_bi.is_sure`:
|
||||
|
||||
中枢已成 zs.is_sure -> zones.available_ts(与 B4 同口径,已解决)
|
||||
向下离开 leave_bi 端点在 zd 下 -> **收盘** < zd,当根可判
|
||||
背驰 整笔面积对比 -> 进入笔面积在中枢确认时已是历史,
|
||||
离开段面积从突破根起逐根累加,当根可判
|
||||
触发 等笔确认(滞后之源) -> 收盘反向越过前一根极值(沿用 B4 的转强判据)
|
||||
|
||||
**与三类的方向关系相反**:三类顺着突破方向做,一类逆着做。中枢向下突破后,
|
||||
B4 会做空,而 fast B1 是在同一段下跌里找力竭然后**做多**。
|
||||
|
||||
⚠️ 先验不利,落地前请先看数:§3.391 实测 `mom60` 最高的四分位是最差的
|
||||
(余量只剩 1.44bp),而一类按定义就住在一段已走完的行情末端。本模块是用来
|
||||
证伪这个先验的,不是用来假定它不成立的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from chanlun.analysis.fast_bsp import _resolve_avail_bi, timestamps_ms
|
||||
|
||||
|
||||
def zones_with_enter_area(zs_list, src: pd.DataFrame,
|
||||
avail_bi: int | None = None) -> pd.DataFrame:
|
||||
"""区间表 + 进入笔的 MACD 面积与方向。
|
||||
|
||||
这两列**不引入滞后**:进入笔是中枢第一笔的前一笔,中枢确认时它早已收尾。
|
||||
背驰判据里唯一需要等待的是离开段,而那一段可以逐根累加。
|
||||
|
||||
面积口径必须和 `ChanBI.cal_macd_hist` 一致:只累加顺方向那一侧的 hist
|
||||
并取绝对值(上升笔累加正值,下降笔累加负值的绝对值),故恒非负。
|
||||
"""
|
||||
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
timestamps_ms(src)))
|
||||
i = _resolve_avail_bi(avail_bi)
|
||||
|
||||
rows = []
|
||||
for zs in zs_list:
|
||||
bis = getattr(zs, "bi_list", [])
|
||||
if not bis:
|
||||
continue
|
||||
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
|
||||
avail = (ts_of.get(str(getattr(key_bi, "sure_time", "") or ""))
|
||||
or ts_of.get(str(getattr(key_bi, "end_time", "") or "")))
|
||||
if avail is None:
|
||||
continue
|
||||
enter_bi = getattr(bis[0], "pre", None)
|
||||
if enter_bi is None:
|
||||
continue
|
||||
# dir 用 +1/−1 表示,避免把引擎枚举漏进研究侧
|
||||
e_dir = 1 if str(enter_bi.dir).endswith("UP") else -1
|
||||
rows.append({
|
||||
"zg": float(zs.zg), "zd": float(zs.zd),
|
||||
"start_ts": ts_of.get(str(bis[0].start_time), avail),
|
||||
"available_ts": int(avail),
|
||||
"enter_area": abs(float(enter_bi.macd_hist)),
|
||||
"enter_dir": e_dir,
|
||||
})
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return out.sort_values("available_ts").reset_index(drop=True)
|
||||
|
||||
|
||||
def find_fast_bsp1(
|
||||
df: pd.DataFrame,
|
||||
zones: pd.DataFrame,
|
||||
scan: int = 200,
|
||||
leave_win: int = 60,
|
||||
min_leave_bars: int = 3,
|
||||
max_div: float = 1.0,
|
||||
max_per_zone: int = 1,
|
||||
diag: dict | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""扫描每个中枢,找「离开中枢后力度背驰、随即转向」的入场点。
|
||||
|
||||
zones 需含 zg / zd / available_ts / enter_area / enter_dir,
|
||||
即 `zones_with_enter_area` 的输出。
|
||||
|
||||
`min_leave_bars` 要求离开段至少走这么多根才允许触发。没有它的话,转强判据
|
||||
会在突破后第一根小反弹就打进去,那时离开段的面积还没累起来,背驰条件几乎
|
||||
自动成立——会退化成「随便一个反弹就抄底」。
|
||||
|
||||
`max_div` 是背驰的松紧:离开段面积 / 进入段面积 < max_div 才算背驰。
|
||||
1.0 等于引擎的 `macdhist_div < 0`;调小则只认力度衰减更明显的。
|
||||
|
||||
返回列:
|
||||
entry_idx 实时可下单的K线
|
||||
direction +1 一买 / −1 一卖(**与突破方向相反**)
|
||||
bo_idx 离开中枢的突破根
|
||||
ext_idx 离开段的极值根
|
||||
lag entry_idx − ext_idx,即相对理想入场点的滞后
|
||||
div 离开段面积 / 进入段面积,越小背驰越强
|
||||
depth 离开段极值越过中枢边界的幅度(相对边界)
|
||||
"""
|
||||
need = {"zg", "zd", "available_ts", "enter_area", "enter_dir"}
|
||||
if zones.empty or not need <= set(zones.columns):
|
||||
return pd.DataFrame()
|
||||
|
||||
ts = df["timestamp"].to_numpy()
|
||||
close = df["close"].to_numpy(float)
|
||||
high = df["high"].to_numpy(float)
|
||||
low = df["low"].to_numpy(float)
|
||||
hist = df["macdhist"].to_numpy(float)
|
||||
atr = df["atr"].to_numpy(float)
|
||||
n = len(df)
|
||||
rows = []
|
||||
|
||||
def note(key: str) -> None:
|
||||
if diag is not None:
|
||||
diag[key] = diag.get(key, 0) + 1
|
||||
|
||||
for zone_i, (_, z) in enumerate(zones.iterrows()):
|
||||
note("中枢总数")
|
||||
zg, zd = float(z["zg"]), float(z["zd"])
|
||||
e_area, e_dir = float(z["enter_area"]), int(z["enter_dir"])
|
||||
if zg <= zd or not np.isfinite(e_area) or e_area <= 0:
|
||||
note("×无效中枢或进入段无面积")
|
||||
continue
|
||||
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
|
||||
if start >= n - 2:
|
||||
note("×中枢太靠后")
|
||||
continue
|
||||
scan_end = min(start + scan * max_per_zone, n)
|
||||
cursor = start
|
||||
|
||||
for occ in range(1, max_per_zone + 1):
|
||||
if cursor >= n - 2:
|
||||
break
|
||||
was_inside = False
|
||||
bo_idx, d = None, 0
|
||||
for j in range(cursor, scan_end):
|
||||
c = close[j]
|
||||
if zd <= c <= zg:
|
||||
was_inside = True
|
||||
continue
|
||||
if not was_inside:
|
||||
continue
|
||||
bo_idx, d = j, (1 if c > zg else -1)
|
||||
break
|
||||
if bo_idx is None:
|
||||
if occ == 1:
|
||||
note("×窗口内未离开中枢")
|
||||
break
|
||||
|
||||
# 引擎要求 enter_bi.dir == leave_bi.dir 才比面积,否则背驰无从谈起
|
||||
if e_dir != d:
|
||||
note("×进入段与离开段不同向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
|
||||
area = 0.0
|
||||
ext = low[bo_idx] if d == -1 else high[bo_idx]
|
||||
ext_idx = bo_idx
|
||||
entry_idx, div_at = None, np.nan
|
||||
for j in range(bo_idx, min(bo_idx + leave_win + 1, n)):
|
||||
h = hist[j]
|
||||
# 与 ChanBI.cal_macd_hist 同口径:只取顺方向一侧,取绝对值
|
||||
if d == -1 and h < 0:
|
||||
area -= h
|
||||
elif d == 1 and h > 0:
|
||||
area += h
|
||||
if (low[j] < ext) if d == -1 else (high[j] > ext):
|
||||
ext, ext_idx = (low[j] if d == -1 else high[j]), j
|
||||
if j < bo_idx + min_leave_bars:
|
||||
continue
|
||||
# 收盘回到中枢内 -> 这不是一次有效的离开,弃掉
|
||||
if zd <= close[j] <= zg:
|
||||
note("×离开段收盘回到中枢内")
|
||||
break
|
||||
if area >= e_area * max_div:
|
||||
continue # 力度未衰减,不是背驰
|
||||
go = close[j] > high[j - 1] if d == -1 else close[j] < low[j - 1]
|
||||
if go:
|
||||
entry_idx, div_at = j, area / e_area
|
||||
break
|
||||
if entry_idx is None:
|
||||
if occ == 1:
|
||||
note("×未在窗口内背驰转向")
|
||||
cursor = bo_idx + 1
|
||||
continue
|
||||
if occ == 1:
|
||||
note("√成交")
|
||||
|
||||
edge = zd if d == -1 else zg
|
||||
# 延伸度用 ATR 归一,才和 step62 的 ext_run 同口径 —— 那里实测它是
|
||||
# 唯一单调区分「趋势末端 vs 趋势中途」的特征(命中率 12%→44%)。
|
||||
# 用相对边界的百分比会混入价格水平,不同币之间不可比。
|
||||
a_ext = atr[ext_idx]
|
||||
rows.append({
|
||||
"entry_idx": entry_idx, "direction": -d,
|
||||
"bo_idx": bo_idx, "ext_idx": ext_idx,
|
||||
"lag": entry_idx - ext_idx,
|
||||
"div": div_at,
|
||||
"ext_atr": (abs(ext - edge) / a_ext
|
||||
if np.isfinite(a_ext) and a_ext > 0 else np.nan),
|
||||
"depth": abs(ext - edge) / edge,
|
||||
"zg": zg, "zd": zd,
|
||||
"width_pct": (zg - zd) / close[bo_idx],
|
||||
"occ": occ, "zone_i": zone_i,
|
||||
})
|
||||
cursor = entry_idx + 1
|
||||
|
||||
out = pd.DataFrame(rows)
|
||||
if out.empty:
|
||||
return out
|
||||
return (out.sort_values(["entry_idx", "occ", "zone_i"])
|
||||
.drop_duplicates("entry_idx", keep="first")
|
||||
.reset_index(drop=True))
|
||||
@@ -18,8 +18,16 @@ NAME="${NAME:-shadow}"
|
||||
# 设 0 可退回 full,用来复量两模式的耗时差。
|
||||
SHADOW_LEAN="${SHADOW_LEAN:-1}"
|
||||
# 币池。默认三个流动性最好的做滑点测量;十币池是实际要交易的那批(TRX 剔除,
|
||||
# ATR 门控几乎全刷掉)。币数直接决定排队:所有币同一秒收盘,2 核上 10 个币
|
||||
# 需要约 640ms 墙钟才算完,最后一个币的信号会落在 800ms 哨兵线之外。
|
||||
# ATR 门控几乎全刷掉)。
|
||||
#
|
||||
# 币数超过核数时排队会成为主项:所有币同一秒收盘。此时**加 worker 没用**
|
||||
# ——CPU 密集的活,worker 超过核数不增吞吐,只会把等待从 queue_ms 挪到
|
||||
# inner_ms。增量路径(SHADOW_INCR=1,默认开)已把清空压到 300~560ms。
|
||||
#
|
||||
# 再往下压的顺序见 HANDOFF §5.72(口径对齐后的实测):单币 inner 100ms ≈
|
||||
# 信号链 30ms + 追加 2.81 根 37ms + 2 核争抢 33ms。争抢只有 1.49x,所以
|
||||
# **加核收益有限**;最便宜的一刀是按币绑定 worker(现在 symbol 随机落
|
||||
# worker,每份缓存都漏掉对方处理过的根,于是人人要追 2.81 根而非 1 根)。
|
||||
SYMS="${SYMS:-BTC,ETH,SOL}"
|
||||
IMAGE="${SHADOW_IMAGE:-hummingbot/hummingbot:latest}"
|
||||
HOURS="${HOURS:-168}"
|
||||
@@ -28,6 +36,10 @@ MAX_OFFSET_MS="${MAX_OFFSET_MS:-10}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
OUT="$REPO_ROOT/research/out"
|
||||
# 信号总线的宿主机目录。**刻意放在仓库外**:这是交给实盘执行器(另一台机)的
|
||||
# 交接点,而仓库会被 git checkout/clean 动。执行器那台用 ssh tail 拉这个文件,
|
||||
# 所以它也不能在容器内部,必须挂出来
|
||||
BUS_DIR="${BUS_DIR:-$HOME/chan-live/state}"
|
||||
|
||||
die() { printf '\033[31m错误:%s\033[0m\n' "$*" >&2; exit 1; }
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
@@ -37,6 +49,12 @@ say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
|
||||
say "站点 $SHADOW_SITE"
|
||||
|
||||
mkdir -p "$BUS_DIR"
|
||||
# 容器内是 root,写出来的总线文件宿主机上归 root。执行器那台用普通用户
|
||||
# ssh 过来 tail,所以目录要可进入、文件要可读
|
||||
chmod 755 "$BUS_DIR" 2>/dev/null || true
|
||||
echo "信号总线:$BUS_DIR/signals_live.jsonl(容器内挂成 /bus)"
|
||||
|
||||
say "校验时钟同步"
|
||||
offset_ms=""
|
||||
if command -v chronyc >/dev/null 2>&1; then
|
||||
@@ -108,8 +126,16 @@ docker run -d --name "$NAME" -w /home/hummingbot \
|
||||
-e PYTHONPATH=/home/hummingbot:/repo/research:/repo/research/live:/repo \
|
||||
-e SHADOW_SITE="$SHADOW_SITE" \
|
||||
-e SHADOW_LEAN="$SHADOW_LEAN" \
|
||||
-e SHADOW_INCR="${SHADOW_INCR:-1}" \
|
||||
-e TG_TOKEN="${TG_TOKEN:-}" \
|
||||
-e TG_CHAT="${TG_CHAT:-}" \
|
||||
-e TG_NOTIONAL="${TG_NOTIONAL:-500}" \
|
||||
-e TG_LEVERAGE="${TG_LEVERAGE:-10}" \
|
||||
-e TG_STALE_S="${TG_STALE_S:-90}" \
|
||||
-e SIGNAL_BUS=/bus/signals_live.jsonl \
|
||||
-v "$REPO_ROOT:/repo:ro" \
|
||||
-v "$OUT:/out" \
|
||||
-v "$BUS_DIR:/bus" \
|
||||
--entrypoint /opt/conda/envs/hummingbot/bin/python \
|
||||
"$IMAGE" /repo/research/live/shadow_hb.py \
|
||||
--hours "$HOURS" --workers "$WORKERS" --syms "$SYMS" >/dev/null
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 复制成 tg.env 再填。tg.env 已在 .gitignore 里,不会被提交。
|
||||
#
|
||||
# 拿 token:Telegram 里找 @BotFather → /newbot → 按提示起名
|
||||
# 拿 chat id:给你的 bot 随便发一句,然后打开
|
||||
# https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||
# 返回的 result[0].message.chat.id 就是
|
||||
export TG_TOKEN=""
|
||||
export TG_CHAT=""
|
||||
# 每笔名义额(USDT)。杠杆**不改**手续费与滑点(都按名义额收),所以抬名义额
|
||||
# 是有真实成本的;抬它的唯一理由是压掉步长取整:实测最差币的偏差
|
||||
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%。
|
||||
export TG_NOTIONAL="500"
|
||||
# 杠杆只影响占用保证金,不影响名义敞口/手续费/滑点/盈亏绝对值。
|
||||
# 名义 500 在 10x 下占 50 USDT 保证金;止损在 2 ATR ≈ 0.2%,而 10x 强平约需
|
||||
# 逆向 10% = 100 个 ATR,差 50 倍,所以这里的杠杆几乎不引入强平风险。
|
||||
# 交易所侧记得设**逐仓**,让每笔最大损失被保证金封住。
|
||||
export TG_LEVERAGE="10"
|
||||
# 距「参考价成立」超过这么多秒就标为已失效。参考价是次根开盘价,
|
||||
# 过了就不是回测那个成交价了
|
||||
export TG_STALE_S="90"
|
||||
|
||||
# ── 自动化实盘(live_exec.py)──────────────────────────────────
|
||||
# 只读+交易权限,**不要开提币权限**
|
||||
export BITGET_API_KEY=""
|
||||
export BITGET_API_SECRET=""
|
||||
export BITGET_PASSPHRASE=""
|
||||
# 名义额与杠杆。理由同 TG_NOTIONAL:抬名义额是为了压步长取整,不是为了赚更多
|
||||
export LIVE_NOTIONAL="500"
|
||||
export LIVE_LEVERAGE="10"
|
||||
# 硬约束。这三条封住的是「代价不随仓位缩小」的那几类故障:
|
||||
# MAX_OPEN 失控下单(单笔小但笔数无界)
|
||||
# MAX_DAY 同上,日维度
|
||||
# MAX_DAY_LOSS 策略真的不行但没人盯着
|
||||
export LIVE_MAX_OPEN="3"
|
||||
export LIVE_MAX_DAY="15"
|
||||
export LIVE_MAX_DAY_LOSS="20"
|
||||
# 信号超过这么久就不做。参考成交价是次根开盘价,过期后跑的不是回测那个价
|
||||
export LIVE_STALE_S="20"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""100 USDT 的仓位在各币上能不能下出来——下单精度与最小量。
|
||||
|
||||
手工小额实盘的第一个坑不在策略,在交易规则。100 USDT 的仓位要拆成两半
|
||||
(3 ATR 减半 50 USDT、8 ATR 目标 50 USDT),任一半低于最小下单量就下不出去,
|
||||
或者被精度取整到与计划偏差很大的数量。
|
||||
|
||||
取整偏差会直接扭曲收益结构:若 50 USDT 被取整到 40,减半那一腿实际只出了
|
||||
40%,剩余 60% 暴露在 8 ATR 目标上。回测的收益结构假设是 50/50。
|
||||
|
||||
python research/live/probe_rules.py --notional 100
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
sys.path.insert(0, str(HERE.parents[1]))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
SYMS = os.environ.get(
|
||||
"SYMS", "BTC,ETH,SOL,BNB,XRP,DOGE,ADA,AVAX,LINK,LTC").split(",")
|
||||
|
||||
|
||||
async def run(notional: float) -> None:
|
||||
from hummingbot.connector.derivative.bitget_perpetual.bitget_perpetual_derivative import ( # noqa: E501
|
||||
BitgetPerpetualDerivative,
|
||||
)
|
||||
|
||||
pairs = [f"{s}-USDT" for s in SYMS]
|
||||
conn = BitgetPerpetualDerivative(
|
||||
bitget_perpetual_api_key="", bitget_perpetual_secret_key="",
|
||||
bitget_perpetual_passphrase="", trading_pairs=pairs,
|
||||
trading_required=False)
|
||||
await conn.start_network()
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(1)
|
||||
if conn.trading_rules and all(p in conn.trading_rules for p in pairs):
|
||||
break
|
||||
|
||||
print(f"仓位 {notional:.0f} USDT · 减半腿 {notional / 2:.0f} USDT\n")
|
||||
print(f" {'币':<6}{'现价':>11}{'最小量':>12}{'量步长':>12}"
|
||||
f"{'最小名义':>10} 减半腿可行性")
|
||||
bad = []
|
||||
for s in SYMS:
|
||||
p = f"{s}-USDT"
|
||||
r = conn.trading_rules.get(p)
|
||||
if r is None:
|
||||
print(f" {s:<6}{'取不到规则':>11}")
|
||||
continue
|
||||
ob = conn.get_order_book(p)
|
||||
px = float((ob.get_price(True) + ob.get_price(False)) / 2) if ob \
|
||||
else float("nan")
|
||||
min_amt = float(r.min_order_size)
|
||||
step = float(r.min_base_amount_increment)
|
||||
min_not = float(r.min_notional_size or 0)
|
||||
|
||||
half_base = (notional / 2) / px
|
||||
# 按步长向下取整——交易所就是这么处理的,向上取会下不出去
|
||||
q = Decimal(str(half_base)) // Decimal(str(step)) * Decimal(str(step))
|
||||
got = float(q)
|
||||
if got < min_amt or (min_not and got * px < min_not):
|
||||
verdict = f"⛔ 下不出(需 ≥ {max(min_amt, min_not / px):.6f})"
|
||||
bad.append(s)
|
||||
else:
|
||||
dev = abs(got * px - notional / 2) / (notional / 2) * 1e4
|
||||
verdict = f"✓ {got:.6f} 币,偏差 {dev:.0f}bp"
|
||||
if dev > 100:
|
||||
verdict += " ⚠ 取整偏差大"
|
||||
bad.append(s)
|
||||
print(f" {s:<6}{px:>11,.4f}{min_amt:>12.6f}{step:>12.6f}"
|
||||
f"{min_not:>10.1f} {verdict}")
|
||||
|
||||
print()
|
||||
if bad:
|
||||
print(f" ⛔ {notional:.0f} USDT 下这些币的减半腿有问题:{','.join(bad)}")
|
||||
print(f" 要么提高仓位,要么这些币不做减半、单腿到 8 ATR 出场——但后者")
|
||||
print(f" 改了回测的收益结构,不能直接套用原预算。")
|
||||
else:
|
||||
print(f" {notional:.0f} USDT 在全部 {len(SYMS)} 个币上都能拆成两半下出。")
|
||||
await conn.stop_network()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--notional", type=float, default=100.0)
|
||||
a = ap.parse_args()
|
||||
asyncio.run(run(a.notional))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+103
-13
@@ -60,6 +60,7 @@ import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
@@ -71,9 +72,22 @@ import pandas as pd
|
||||
|
||||
from lib.shadow_budget import LAG_ALARM_MS, LAG_WINDOW, lag_healthy
|
||||
|
||||
# 总线模块住在生产子树 live/ 下。方向是刻意的:**生产不 import 研究侧**,
|
||||
# 研究侧反过来读生产持有的契约。见 live/live_exec.py 文件头
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "live"))
|
||||
import signal_bus # noqa: E402
|
||||
import tg_notify # noqa: E402
|
||||
|
||||
# 站点标识。跨地对比时两台机器的 CSV 要能合起来读,没有这一列就分不清哪行
|
||||
# 来自哪台。默认取主机名,部署脚本会显式传 SHADOW_SITE(如 sg-hetzner)
|
||||
SITE = os.environ.get("SHADOW_SITE") or socket.gethostname()
|
||||
# 判「加 worker 有没有用」必须知道核数:CPU 密集的活,worker 超过核数不增吞吐
|
||||
CORES = os.cpu_count() or 1
|
||||
# 判定要知道增量开没开,否则增量已生效时还会继续推荐「走增量」
|
||||
INCR_ON = os.environ.get("SHADOW_INCR", "1") not in ("0", "", "false")
|
||||
# 少于这么多根就不送去算。缠论要先有分型再有笔再有中枢,几十根出不来中枢,
|
||||
# 送过去只会白占一个计算槽
|
||||
MIN_BARS = 200
|
||||
|
||||
SYMS = ("BTC", "ETH", "SOL")
|
||||
# 多存一根:deque 尾部是尚未收盘的当前根,剔除后正好剩 step39 定下的窗口
|
||||
@@ -324,6 +338,11 @@ class Shadow:
|
||||
# 排队 / 纯计算的滚动窗口,用来判断加核有没有用
|
||||
self.q_hist: deque = deque(maxlen=90)
|
||||
self.i_hist: deque = deque(maxlen=90)
|
||||
# 每个收盘时刻「清空所有币」耗时。这才是决定信号何时可下单的量:
|
||||
# 币同一秒收盘,币数超 worker 数时后面的币串行等待,而这笔代价不
|
||||
# 出现在任何单根的 queue_ms 或 inner_ms 里
|
||||
self.clear_hist: deque = deque(maxlen=60)
|
||||
self._clear_cur: dict[int, float] = {}
|
||||
# 成交监听:已挂上的币,以及必须持有的 forwarder 强引用
|
||||
# (PubSub 只存弱引用,不持有的话监听会被 GC 静默摘掉)
|
||||
self._hooked: set[str] = set()
|
||||
@@ -353,7 +372,8 @@ class Shadow:
|
||||
# compute_ms 含排队;queue_ms/inner_ms 把它拆开,用来判断加核有没有用
|
||||
"compute_ms", "queue_ms", "inner_ms",
|
||||
"n_bars", "n_hits",
|
||||
"n_pass", "atr_bp", "lag_med_ms", "lag_ok"])
|
||||
"n_pass", "atr_bp", "lag_med_ms", "lag_ok",
|
||||
"stream_bars"])
|
||||
# 无条件漂移:每根都记,用来和信号根上的条件漂移对照
|
||||
self.f_drf, self.w_drf = _writer(d / "shadow_drift.csv", [
|
||||
"site", "sym", "kline_ts", "delay_label", "delay_ms",
|
||||
@@ -548,13 +568,20 @@ class Shadow:
|
||||
# 末行是刚开始的那根,未收盘,必须剔除,否则等于用未来数据
|
||||
df_l = df_l[df_l["timestamp"] < kline_ts]
|
||||
df_h = df_h[df_h["timestamp"] < kline_ts]
|
||||
# WS 重连的瞬间 feed 的 deque 可能是空的。放行的话 worker 会抛
|
||||
# 「DataFrame for 1m is empty」,白占一个计算槽(币数超核数时这笔
|
||||
# 代价会推迟后面所有币),而报错文本还会让人以为是缺历史数据
|
||||
if len(df_l) < MIN_BARS or len(df_h) < MIN_BARS:
|
||||
print(f" [{sym}] 窗口过短(1m {len(df_l)} / 5m {len(df_h)} 根),"
|
||||
f"跳过本根。feed 大概在重连", flush=True)
|
||||
return
|
||||
baseline = self._new_bar_open(sym, kline_ts)
|
||||
|
||||
lag_med, lag_ok = self._probe_lag(sym, t_data - kline_ts)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
payload = (df_l[NUM_COLS].values.tolist(),
|
||||
df_h[NUM_COLS].values.tolist(), baseline, time.time())
|
||||
df_h[NUM_COLS].values.tolist(), baseline, time.time(), sym)
|
||||
loop = asyncio.get_running_loop()
|
||||
from shadow_signal import compute_packed
|
||||
try:
|
||||
@@ -570,6 +597,13 @@ class Shadow:
|
||||
self.q_hist.append(res["queue_ms"])
|
||||
if res.get("inner_ms") is not None:
|
||||
self.i_hist.append(res["inner_ms"])
|
||||
# 同一 kline_ts 上取各币最大值即该时刻的清空耗时;只保留最近几个
|
||||
# 时刻,否则这个 dict 会随运行时长无界增长
|
||||
cur = self._clear_cur
|
||||
cur[kline_ts] = max(cur.get(kline_ts, 0.0), float(compute_ms))
|
||||
if len(cur) > 3:
|
||||
done = min(cur)
|
||||
self.clear_hist.append(cur.pop(done))
|
||||
|
||||
hits = res.get("hits", [])
|
||||
atr_pct = res.get("atr_pct")
|
||||
@@ -586,7 +620,10 @@ class Shadow:
|
||||
"queue_ms": res.get("queue_ms"), "inner_ms": res.get("inner_ms"),
|
||||
"n_bars": res.get("n_bars", 0),
|
||||
"n_hits": len(hits), "n_pass": n_pass, "atr_bp": atr_bp,
|
||||
"lag_med_ms": lag_med, "lag_ok": int(lag_ok)})
|
||||
"lag_med_ms": lag_med, "lag_ok": int(lag_ok),
|
||||
# 增量流当前窗口。恒等于 2001 说明缺口判定在每根都
|
||||
# 回退重建,增量静默失效——只从耗时上看不出是哪一环
|
||||
"stream_bars": res.get("stream_bars")})
|
||||
self.f_lat.flush()
|
||||
|
||||
if baseline is not None and np.isfinite(baseline):
|
||||
@@ -616,6 +653,23 @@ class Shadow:
|
||||
self._record_later(sym, kline_ts, h, t_data, t_signal,
|
||||
baseline, atr_pct, lag_ok),
|
||||
f"record {sym}")
|
||||
# 手工执行的推送。只推过全部滤网的,且 lag 退化时不推——那与
|
||||
# 「停开新仓」是同一条规则,不能只在自动化里执行
|
||||
if h["pass_all"] and atr_pct:
|
||||
if not lag_ok:
|
||||
print(f" [TG] {sym} lag 退化,按停开新仓规则不推",
|
||||
flush=True)
|
||||
else:
|
||||
# 总线先写、推送后发。写盘是同步的且已 fsync,实盘据此
|
||||
# 下单;推送要走网络,不能让它的延迟挡在下单前面
|
||||
signal_bus.emit(sym, kline_ts, h["direction"],
|
||||
float(baseline), float(atr_pct),
|
||||
t_data - kline_ts)
|
||||
self._spawn(
|
||||
tg_notify.push_signal(
|
||||
sym, h["direction"], float(baseline),
|
||||
float(atr_pct), kline_ts, t_data - kline_ts),
|
||||
f"tg {sym}")
|
||||
|
||||
def _probe_lag(self, sym: str, lag_ms: int) -> tuple[float, bool]:
|
||||
"""记一根的到达延迟,返回 (滚动中位数, 该币是否健康)。
|
||||
@@ -779,16 +833,12 @@ class Shadow:
|
||||
if self.q_hist and self.i_hist:
|
||||
q, i = float(np.median(self.q_hist)), float(np.median(self.i_hist))
|
||||
# 建议要看绝对量级:lean + 新引擎后纯计算约 128ms,此时再提
|
||||
# 「改增量计算」是误导——尾部已由数据腿主导,压计算换不到东西
|
||||
if q > i:
|
||||
verdict = "排队为主 → 加 worker/加核直接见效"
|
||||
elif i > 400:
|
||||
verdict = "纯计算为主且偏高 → 加核帮不上,需改增量计算"
|
||||
else:
|
||||
verdict = "纯计算为主但量级已低 → 无需再优化"
|
||||
print(f" [计算] 排队中位 {q:.0f}ms · 纯计算中位 {i:.0f}ms"
|
||||
f" · {verdict}(worker {self.workers} 个 / 币 {len(SYMS)} 个)",
|
||||
flush=True)
|
||||
clear = float(np.median(self.clear_hist)) if self.clear_hist \
|
||||
else float("nan")
|
||||
print(f" [计算] 每币排队 {q:.0f}ms · 纯计算 {i:.0f}ms · "
|
||||
f"清空全部 {len(SYMS)} 币 {clear:.0f}ms"
|
||||
f"(worker {self.workers} / 核 {CORES})", flush=True)
|
||||
print(f" → {self._compute_verdict(q, i, clear)}", flush=True)
|
||||
# 五分钟一根都没进来,说明管道断了。不喊一声就只能靠人翻日志
|
||||
if self.n_bars == self._hb_last_bars:
|
||||
print(f" ⚠ [停滞] 距上次心跳未处理任何 K 线"
|
||||
@@ -796,6 +846,46 @@ class Shadow:
|
||||
flush=True)
|
||||
self._hb_last_bars = self.n_bars
|
||||
|
||||
def _compute_verdict(self, q: float, i: float, clear: float) -> str:
|
||||
"""给出唯一可行的出路,而不是「哪一项数字更大」。
|
||||
|
||||
旧版比逐根的 q 与 i,结构上错了两处:
|
||||
|
||||
1. 判据错。真正要紧的是**一个收盘时刻清空所有币要多久**(clear),
|
||||
不是单币的 q 或 i。所有币同一秒收盘,币数超过 worker 数时后面的
|
||||
币必然串行等待,而这笔代价不出现在任何单根的 q 或 i 里。
|
||||
2. 出路错。「排队为主 → 加核」只在还有空闲核时成立。worker 已等于
|
||||
核数时,加 worker 不会增加吞吐——CPU 密集的活变不出来,只会把
|
||||
等待从 queue_ms 挪到 inner_ms。十币实测正是如此:inner 被争抢从
|
||||
144ms 抬到 192ms,反而超过 queue 135ms,于是判定落到「量级已低、
|
||||
无需优化」,而此时最后一个币已经落在 1376ms。
|
||||
|
||||
所以币数超过核数时,加 worker 不增吞吐。出路有两级:先上增量把真实计算
|
||||
压下来;增量之后剩的是争抢放大(实测 3.3 倍,§5.72),那一级只能加核或
|
||||
减币,继续改算法收益有限。
|
||||
"""
|
||||
if not np.isfinite(clear):
|
||||
return "样本不足,暂不判定"
|
||||
if clear < 400:
|
||||
return f"清空 {clear:.0f}ms,宽裕,无需优化"
|
||||
if self.workers < CORES and q > i:
|
||||
return (f"排队为主且还有 {CORES - self.workers} 个空闲核 → "
|
||||
f"--workers 加到 {CORES}")
|
||||
if len(SYMS) <= CORES:
|
||||
return f"清空 {clear:.0f}ms 偏高,但币数未超核数,先查别的争抢"
|
||||
# 币数超核数:加 worker 不增吞吐,只能压单币耗时。但要看增量开没开,
|
||||
# 否则会在增量已生效时继续推荐「走增量」——上线后实测踩到过
|
||||
if not INCR_ON:
|
||||
return (f"币数 {len(SYMS)} > 核数 {CORES},加 worker 无用(CPU 密集)"
|
||||
f"。压单币耗时 → 开 SHADOW_INCR=1 走增量(实测 3.56x)")
|
||||
# 增量已生效时(§5.72 口径对齐后):单币 100ms ≈ 信号链 30ms + 追加
|
||||
# 2.81 根 37ms + 争抢 33ms。争抢只有 1.49x,加核收益有限;而追加那 37ms
|
||||
# 里约 22ms 纯属浪费——symbol 随机落 worker,各缓存都漏掉对方处理的根
|
||||
return (f"币数 {len(SYMS)} > 核数 {CORES},增量已生效,加 worker 无用"
|
||||
f"(worker 已等于核数)。单币 {i:.0f}ms 里争抢只占约 1.5x,"
|
||||
f"最便宜的一刀是按币绑定 worker(每次只追 1 根,省约三分之一),"
|
||||
f"其次是 add_indicators 增量化")
|
||||
|
||||
async def run(self) -> None:
|
||||
await self.start()
|
||||
tasks = [asyncio.create_task(self.sample_books()),
|
||||
|
||||
@@ -40,10 +40,64 @@ for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
|
||||
|
||||
|
||||
LEAN = os.environ.get("SHADOW_LEAN", "1") not in ("0", "", "false")
|
||||
INCR = os.environ.get("SHADOW_INCR", "1") not in ("0", "", "false")
|
||||
|
||||
# 增量流缓存。worker 进程被复用,所以这个 dict 跨根存活。
|
||||
# 键是 (symbol, timeframe)——2 个 worker 轮流拿 10 个币,每个 worker 最终会
|
||||
# 缓存全部 10 个币,共 20 条流。
|
||||
_STREAMS: dict[tuple, tuple] = {}
|
||||
|
||||
# 两次重建之间允许窗口长多少根。
|
||||
#
|
||||
# init_stream/append_bar **没有 trim**:dataframe 靠 pd.concat 无界增长。所以
|
||||
# 增量必然让窗口每根 +1,只能周期性 init_stream 拉回。取 500 的两个理由:
|
||||
# 1. append_bar 里 rebuild_bi_zs 要整表重扫笔,是 O(n)。窗口涨 25% 成本也涨
|
||||
# 约 25%,500/2001 正好把这个膨胀压在 25% 以内。
|
||||
# 2. 重建约 51ms、追加约 14ms,摊到 500 根上重建只加 0.07ms/根。
|
||||
# 前提「输出对窗口长度不敏感」由 verify_window_sens.py 验过(+200/+500/+1000
|
||||
# 全部逐字段一致),否则这个方案等于静默换掉一批信号。
|
||||
MAX_GROW = 500
|
||||
|
||||
|
||||
def _chan_for(key: tuple, df, tf: str, lean: bool):
|
||||
"""拿该窗口对应的 chan 对象,能增量就增量,否则重建。
|
||||
|
||||
三种情况必须回退到全量重建,否则会拿一个状态不对的流去出信号:
|
||||
|
||||
缓存没有 首次见到这个币
|
||||
窗口已长过阈值 见 MAX_GROW
|
||||
缓存末根不在新窗口 说明中间断了很多根(或时间戳回退),接不上
|
||||
|
||||
第三种是最要紧的。2 个 worker 轮流拿 10 个币,某个 worker 可能隔几根才再
|
||||
看到同一个币,那几根要补齐;但若缺口大到超出窗口,就没法补,只能重建。
|
||||
不检查而直接 append 会把不连续的 K 线接在一起,笔和中枢全错且不报错。
|
||||
"""
|
||||
from chanlun import TF_DF
|
||||
|
||||
ts = df["timestamp"].to_numpy("int64")
|
||||
st = _STREAMS.get(key)
|
||||
if st is not None:
|
||||
chan, last_ts, base_n = st
|
||||
if len(chan.dataframe) <= base_n + MAX_GROW and last_ts >= ts[0] \
|
||||
and last_ts <= ts[-1] and (ts == last_ts).any():
|
||||
for _, row in df[df["timestamp"] > last_ts].iterrows():
|
||||
chan.append_bar(row)
|
||||
_STREAMS[key] = (chan, int(ts[-1]), base_n)
|
||||
return chan
|
||||
|
||||
# 重建走**批量** init_TF_DF,不用 init_stream。init_stream 是逐行
|
||||
# `dataframe.iloc[idx]`,正是引擎提速刚修掉的反模式:实测 2001 根要
|
||||
# 238.5ms,而批量 lean 只要 74.3ms,慢 3.2 倍。
|
||||
# append_bar 能接在批量构建的对象上——_ensure_stream_state 会补出
|
||||
# _klc_feed_last_klu,其余列表 init_TF_DF 都建好了。
|
||||
chan = TF_DF(df.copy(), 1, tf, lean=lean)
|
||||
_STREAMS[key] = (chan, int(ts[-1]), len(df))
|
||||
return chan
|
||||
|
||||
|
||||
def compute(df_l, df_h, entry_px: float | None = None,
|
||||
lean: bool | None = None) -> dict:
|
||||
lean: bool | None = None, sym: str | None = None,
|
||||
incr: bool | None = None) -> dict:
|
||||
"""在 df_l 的最后一根上找信号。df_l/df_h 都只含已收盘 K 线。
|
||||
|
||||
entry_px 是次根开盘价(回测 entry_delay=1 的成交价),用作 atr_pct 的
|
||||
@@ -66,6 +120,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
|
||||
import pandas as pd
|
||||
|
||||
lean = LEAN if lean is None else lean
|
||||
# 没有 sym 就无法给流分键,只能走全量——对拍脚本会用这条路径当基准
|
||||
incr = (INCR if incr is None else incr) and sym is not None
|
||||
|
||||
try:
|
||||
from chanlun import TF_DF
|
||||
@@ -75,7 +131,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
|
||||
from lib.nested_level import build_htf_zones
|
||||
from lib.shadow_budget import ATR_GATE_BP
|
||||
|
||||
chan_l = TF_DF(df_l, 1, "1m", lean=lean)
|
||||
chan_l = _chan_for((sym, "1m"), df_l, "1m", lean) if incr \
|
||||
else TF_DF(df_l, 1, "1m", lean=lean)
|
||||
cdf = chan_l.dataframe
|
||||
last = len(cdf) - 1
|
||||
base = {"last_idx": last, "n_bars": int(len(df_l)), "hits": [],
|
||||
@@ -111,7 +168,8 @@ def compute(df_l, df_h, entry_px: float | None = None,
|
||||
|
||||
# 5m 同向。算不出时 h1_agree 记 0,该信号自然不会通过 pass_all
|
||||
if df_h is not None and len(df_h) > 0:
|
||||
chan_h = TF_DF(df_h, 1, "5m", lean=lean)
|
||||
chan_h = _chan_for((sym, "5m"), df_h, "5m", lean) if incr \
|
||||
else TF_DF(df_h, 1, "5m", lean=lean)
|
||||
hdf = chan_h.dataframe
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(chan_h, hdf)), hdf)
|
||||
@@ -180,11 +238,15 @@ def compute_packed(payload: tuple) -> dict:
|
||||
t_start = time.time()
|
||||
l_rows, h_rows, entry_px, *rest = payload
|
||||
t_submit = rest[0] if rest else None
|
||||
sym = rest[1] if len(rest) > 1 else None
|
||||
|
||||
t0 = time.perf_counter()
|
||||
df_l = _rebuild(l_rows)
|
||||
df_h = _rebuild(h_rows) if h_rows else None
|
||||
out = compute(df_l, df_h, entry_px)
|
||||
out = compute(df_l, df_h, entry_px, sym=sym)
|
||||
# 落盘这两个数才能在线看出增量是否在生效:走了重建的根 grown 会等于窗口
|
||||
st = _STREAMS.get((sym, "1m"))
|
||||
out["stream_bars"] = int(len(st[0].dataframe)) if st else None
|
||||
out["inner_ms"] = int((time.perf_counter() - t0) * 1000)
|
||||
# 同一台机器,父子进程时钟一致,可直接相减
|
||||
out["queue_ms"] = int((t_start - t_submit) * 1000) \
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""把过全部滤网的信号推到 Telegram,供手工执行。
|
||||
|
||||
## 为什么要这个
|
||||
|
||||
自动执行链一行都还没写(下单 / 持仓状态 / 跨重启持久化 / 对账 / 熔断),而
|
||||
过全部滤网的信号只有约 5.3 笔/天——低到人手能接。先手工跑一批,就能在写
|
||||
自动化**之前**拿到真实费率档、真实成交价、真实出场行为,让执行链的每个假设
|
||||
都有实测对照,而不是写完再发现出场模型不对。
|
||||
|
||||
## 时效是这条路最大的风险
|
||||
|
||||
回测的成交价是**信号根的次根开盘价**。信号在收盘瞬间产生,人看到推送、解锁
|
||||
手机、下单,几十秒就过去了,成交价已经不是那个开盘价。所以推送里必须带:
|
||||
|
||||
- 参考开盘价(回测口径的成交价)
|
||||
- 该币的滑点预算(还能容忍多少偏离)
|
||||
- 距信号产生已过多久
|
||||
|
||||
并且**超过 TG_STALE_S 就直接标记为已失效**,而不是让人自己判断。宁可漏做,
|
||||
不要在偏离预算之外入场——那等于在负期望上开仓。
|
||||
|
||||
## 环境变量
|
||||
|
||||
TG_TOKEN BotFather 给的 token(缺失则整个推送静默关闭)
|
||||
TG_CHAT chat id
|
||||
TG_NOTIONAL 每笔名义额,默认 200 USDT(小额实盘)
|
||||
TG_STALE_S 超过多少秒算失效,默认 90
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
TOKEN = os.environ.get("TG_TOKEN", "")
|
||||
CHAT = os.environ.get("TG_CHAT", "")
|
||||
# 名义额。杠杆不改手续费与滑点(都按名义额收),所以抬名义额是有真实成本的;
|
||||
# 抬它的理由只有一个:把步长取整压下去。实测最差币的偏差
|
||||
# 100U → 6.7%(SOL)、500U → 1.8%、1000U → 0.6%。
|
||||
NOTIONAL = float(os.environ.get("TG_NOTIONAL", "500"))
|
||||
# 杠杆只影响占用保证金,不影响名义敞口、手续费、滑点、盈亏绝对值。
|
||||
# 止损在 2 ATR ≈ 0.2%,而 10x 的强平约需逆向 10% = 100 个 ATR,差 50 倍,
|
||||
# 所以这里的杠杆几乎不引入强平风险。逐仓,让每笔的最大损失被保证金封住。
|
||||
LEVERAGE = float(os.environ.get("TG_LEVERAGE", "10"))
|
||||
STALE_S = float(os.environ.get("TG_STALE_S", "90"))
|
||||
ENABLED = bool(TOKEN and CHAT)
|
||||
|
||||
# 出场参数。必须与 step43_fill_aware_budget 的口径一致,否则推的价位和
|
||||
# 预算所依据的收益结构不是一回事
|
||||
SL_ATR, SCALE_ATR, RUNNER_ATR, MAXB = 2.0, 3.0, 8.0, 48
|
||||
|
||||
_sent: set = set()
|
||||
_rules: dict = {}
|
||||
|
||||
CONTRACTS = ("https://api.bitget.com/api/v2/mix/market/contracts"
|
||||
"?productType=usdt-futures")
|
||||
|
||||
|
||||
async def load_rules() -> dict:
|
||||
"""拉一次合约规则,缓存。拉不到就返回空——推送退化为不取整,不阻断。
|
||||
|
||||
要的是数量步长和价格 tick。缺了它们推出去的价位可能被交易所拒单
|
||||
(价格不在 tick 上),或者数量被取整到与计划差很多。
|
||||
"""
|
||||
if _rules:
|
||||
return _rules
|
||||
try:
|
||||
import aiohttp
|
||||
async with aiohttp.ClientSession() as s:
|
||||
async with s.get(CONTRACTS,
|
||||
timeout=aiohttp.ClientTimeout(total=15)) as r:
|
||||
d = await r.json()
|
||||
for c in d.get("data") or []:
|
||||
sym = c["symbol"]
|
||||
if not sym.endswith("USDT"):
|
||||
continue
|
||||
_rules[sym[:-4]] = {
|
||||
"step": float(c["sizeMultiplier"]),
|
||||
"min_qty": float(c["minTradeNum"]),
|
||||
"min_usdt": float(c["minTradeUSDT"]),
|
||||
# priceEndStep 是 tick 的整数倍数,pricePlace 是小数位
|
||||
"tick": float(c["priceEndStep"]) * 10 ** -int(c["pricePlace"]),
|
||||
}
|
||||
print(f" [TG] 已载入 {len(_rules)} 个合约的下单规则", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [TG] 拉合约规则失败 {type(e).__name__}: {e},推送不做取整",
|
||||
flush=True)
|
||||
return _rules
|
||||
|
||||
|
||||
def quantize(notional: float, px: float, r: dict) -> tuple[float, float]:
|
||||
"""算入场数量与减半腿,返回 (入场量, 减半量)。
|
||||
|
||||
入场量取到**步长的偶数倍**,这样一半天然落在步长上。不这么做的话,
|
||||
SOL 步长 0.1 币 ≈ 10.7 USDT,100 USDT 的仓位一半是 0.45 币、不可表示,
|
||||
只能取 0.4——减半腿变成全仓的 43% 而不是 50%,而回测的收益结构假设
|
||||
50/50。名义额因此会在目标值上下浮动(SOL 约 85~107),小额实盘无所谓。
|
||||
"""
|
||||
step = r["step"]
|
||||
if step <= 0:
|
||||
return notional / px, notional / px / 2
|
||||
tgt = notional / px
|
||||
# 以 2×step 为格点取最近的一格,至少一格
|
||||
grid = step * 2
|
||||
n = max(1.0, round(tgt / grid))
|
||||
qty = n * grid
|
||||
return qty, qty / 2.0
|
||||
|
||||
|
||||
def snap_px(px: float, tick: float) -> float:
|
||||
"""把价位对齐到 tick,否则限价单会被拒。"""
|
||||
if tick <= 0:
|
||||
return px
|
||||
return round(px / tick) * tick
|
||||
|
||||
|
||||
def levels(entry: float, atr: float, direction: int) -> dict:
|
||||
"""按 2/3/8 ATR 算出绝对价位。
|
||||
|
||||
direction=+1 做多、-1 做空。剩余半仓的止损**保持在 2ATR**、不移到成本,
|
||||
这是回测参数(RUNNER_STOP=2.0),移了就不是同一个收益结构。
|
||||
"""
|
||||
s = 1.0 if direction > 0 else -1.0
|
||||
return {"entry": entry,
|
||||
"stop": entry - s * SL_ATR * atr,
|
||||
"scale": entry + s * SCALE_ATR * atr,
|
||||
"runner": entry + s * RUNNER_ATR * atr}
|
||||
|
||||
|
||||
def _fmt(px: float) -> str:
|
||||
# 币价跨度从 DOGE 的 0.2 到 BTC 的 10 万,固定小数位会把小价币截成 0
|
||||
if px >= 1000:
|
||||
return f"{px:,.1f}"
|
||||
if px >= 10:
|
||||
return f"{px:,.3f}"
|
||||
return f"{px:.6f}"
|
||||
|
||||
|
||||
def build(sym: str, direction: int, entry: float, atr_pct: float,
|
||||
kline_ts: int, lag_ms: float, budget_bp: float,
|
||||
age_s: float, rule: dict | None = None) -> str:
|
||||
atr = entry * atr_pct
|
||||
lv = levels(entry, atr, direction)
|
||||
side = "做多 LONG" if direction > 0 else "做空 SHORT"
|
||||
stale = age_s > STALE_S
|
||||
|
||||
if rule:
|
||||
qty, half = quantize(NOTIONAL, entry, rule)
|
||||
tick = rule["tick"]
|
||||
lv = {k: snap_px(v, tick) for k, v in lv.items()}
|
||||
notional = qty * entry
|
||||
qty_line = (f"入场 {qty:.6g} 币 ≈ {notional:,.1f} USDT"
|
||||
f" · 减半腿 {half:.6g} 币(正好一半)")
|
||||
else:
|
||||
qty = NOTIONAL / entry
|
||||
notional = NOTIONAL
|
||||
qty_line = f"入场 {qty:.6g} 币 ≈ {NOTIONAL:,.0f} USDT(未取整)"
|
||||
margin = notional / LEVERAGE if LEVERAGE > 0 else notional
|
||||
# 止损距入场 2 ATR,换成保证金的百分比才是「这笔最多亏多少本金」
|
||||
loss_pct = SL_ATR * atr_pct * LEVERAGE * 100
|
||||
|
||||
head = f"⛔ 已失效({age_s:.0f}s > {STALE_S:.0f}s)· 不要入场" if stale \
|
||||
else f"✅ {side} {sym}"
|
||||
lines = [
|
||||
head,
|
||||
"",
|
||||
f"参考成交价 {_fmt(lv['entry'])} ← 回测口径(次根开盘)",
|
||||
qty_line,
|
||||
f"{LEVERAGE:.0f}x 逐仓 → 占用保证金 {margin:,.1f} USDT"
|
||||
f" · 触止损亏 {notional * SL_ATR * atr_pct:,.2f} USDT"
|
||||
f"(保证金的 {loss_pct:.1f}%)",
|
||||
f"距参考价成立 {age_s:.1f}s(含数据延迟 {lag_ms:.0f}ms,不可压缩)",
|
||||
"",
|
||||
f"止损 {_fmt(lv['stop'])} (2 ATR,stop-market,全仓)",
|
||||
f"减半 {_fmt(lv['scale'])} (3 ATR,限价 maker)",
|
||||
f"目标 {_fmt(lv['runner'])} (8 ATR,限价 maker,剩余半仓)",
|
||||
f"超时 {MAXB} 分钟后市价平(剩余半仓止损仍在 2 ATR,不移成本)",
|
||||
"",
|
||||
f"ATR {atr_pct * 1e4:.1f}bp · 滑点预算 {budget_bp:.1f}bp",
|
||||
f"→ 实际成交偏离参考价超过 {budget_bp:.1f}bp 就不值得做",
|
||||
]
|
||||
if stale:
|
||||
lines.append("")
|
||||
lines.append("时效已过:成交价已不是回测那个价,宁可漏做。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send(text: str) -> None:
|
||||
"""推一条。传输层复用生产侧的 `live/tg.py`,这里不再维护第二份。
|
||||
|
||||
方向与 `signal_bus` 一致:**生产持有实现,研究侧反过来 import**。反过来
|
||||
写成生产 import 研究侧,就等于把研究侧的依赖树绑到实盘进程上。
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
p = str(Path(__file__).resolve().parents[2] / "live")
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
import tg
|
||||
await tg.send(text)
|
||||
|
||||
|
||||
async def push_signal(sym: str, direction: int, entry: float, atr_pct: float,
|
||||
kline_ts: int, lag_ms: float) -> None:
|
||||
"""去重后推一条信号。
|
||||
|
||||
去重键取 (币, K线时刻, 方向):同一根被重复处理(补根、池重建后重放)不该
|
||||
推两次,否则人会开两次仓。
|
||||
|
||||
时效的起点是 `kline_ts` 而不是信号产生时刻——参考成交价(次根开盘)就是
|
||||
在 kline_ts 那一刻存在的。从信号时刻起算会漏掉数据延迟加计算那 0.5~1.5s,
|
||||
而那段是无法压缩的固定成本,必须计入。
|
||||
"""
|
||||
if not ENABLED:
|
||||
return
|
||||
key = (sym, int(kline_ts), int(direction))
|
||||
if key in _sent:
|
||||
return
|
||||
_sent.add(key)
|
||||
if len(_sent) > 5000:
|
||||
_sent.clear()
|
||||
|
||||
from lib.shadow_budget import budget_of
|
||||
b = budget_of(sym)
|
||||
if not (b == b): # nan:该币当前环境不可做(如 TRX)
|
||||
print(f" [TG] {sym} 无预算(当前环境不可做),不推", flush=True)
|
||||
return
|
||||
age = time.time() - kline_ts / 1000.0
|
||||
rule = (await load_rules()).get(sym.upper())
|
||||
await send(build(sym, direction, entry, atr_pct, kline_ts, lag_ms, b, age,
|
||||
rule))
|
||||
@@ -0,0 +1,222 @@
|
||||
"""逐根对拍「全量重算」与「增量追加」,并量提速。
|
||||
|
||||
## 为什么必须逐根对拍,不能引用 HANDOFF §5.5
|
||||
|
||||
§5.5 验的是 step46 那批用例(走 bsp_list 那条链),且是「追加 150~200 根 vs
|
||||
全量重建」的整体哈希。影子路径不同:
|
||||
|
||||
- 走 find_fast_bsp3 + build_htf_zones + htf_fx_timeline + attach_htf_context
|
||||
- 流式对象**跨根复用**,而 worker 轮流拿多个币,同一条流可能隔几根才被
|
||||
再次追加。状态污染只会让信号悄悄换一批,不报错、不崩
|
||||
|
||||
而且代码阅读已经暴露一处偏差:`init_stream/append_bar` 从不调 `cal_trend`
|
||||
(它只在 `get_klc_list` 里),所以增量路径下 `klc.trend` 恒为 UNKNOWN。
|
||||
HANDOFF 说「笔的计算依赖 klc.trend」——若为真,增量的笔就和全量不同。
|
||||
那句话所引的 bi.py:221 其实在 `cal_trend` 自己的循环里,不是 `cal_bi_list`
|
||||
的依赖。**这条只能由对拍来定论**,不能靠读代码。
|
||||
|
||||
## 判据
|
||||
|
||||
逐字段相同,排除 last_idx/n_bars(随窗口长度必然变,见 verify_window_sens)
|
||||
与计时字段。数量相同而标志不同一样算失败。
|
||||
|
||||
模拟真实调用模式:连续推进,且每根都按「全量」和「增量」各算一次,增量那侧
|
||||
复用同一条流。
|
||||
|
||||
python research/live/verify_incr_parity.py --syms BTC,ETH,SOL --n 300
|
||||
"""
|
||||
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))
|
||||
|
||||
BASE_L, BASE_H = 2001, 801
|
||||
|
||||
|
||||
def run_one(sym: str, cache: Path, n: int, start_at: int | None) -> dict:
|
||||
import shadow_signal as ss
|
||||
from verify_lean_parity import SKIP, WINDOW_KEYS, canon, load, signal_bars
|
||||
|
||||
skip = SKIP + WINDOW_KEYS + ("stream_bars",)
|
||||
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
|
||||
l_ts = l_all["timestamp"].to_numpy("int64")
|
||||
h_ts = h_all["timestamp"].to_numpy("int64")
|
||||
|
||||
# 从最后一个信号根往前 n 根开始,保证这段里一定有信号分支被执行
|
||||
if start_at is None:
|
||||
try:
|
||||
sb = signal_bars(sym, cache)
|
||||
sb = sb[(sb > BASE_L + n) & (sb < len(l_all) - 1)]
|
||||
start_at = int(sb[-1]) - n + 5 if len(sb) else BASE_L + 10
|
||||
except Exception:
|
||||
start_at = BASE_L + 10
|
||||
ends = [e for e in range(start_at, start_at + n) if e < len(l_all) - 1]
|
||||
if not ends:
|
||||
raise RuntimeError("窗口不足")
|
||||
|
||||
ss._STREAMS.clear()
|
||||
same = diff = 0
|
||||
t_full = t_incr = 0.0
|
||||
n_hits = 0
|
||||
first = None
|
||||
rebuilds = 0
|
||||
prev_grown = 0
|
||||
|
||||
for e in ends:
|
||||
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
|
||||
df_l = l_all.iloc[e - BASE_L + 1:e + 1]
|
||||
df_h = h_all.iloc[max(0, hi - BASE_H):hi]
|
||||
entry = float(l_all["open"].to_numpy(float)[e + 1])
|
||||
|
||||
t0 = time.perf_counter()
|
||||
rf = ss.compute(df_l.copy(), df_h.copy(), entry, incr=False)
|
||||
t_full += time.perf_counter() - t0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
ri = ss.compute(df_l.copy(), df_h.copy(), entry, sym=sym, incr=True)
|
||||
t_incr += time.perf_counter() - t0
|
||||
|
||||
grown = len(ss._STREAMS[(sym, "1m")][0].dataframe)
|
||||
if grown <= prev_grown:
|
||||
rebuilds += 1
|
||||
prev_grown = grown
|
||||
|
||||
n_hits += len(rf.get("hits") or [])
|
||||
if canon(rf, skip) == canon(ri, skip):
|
||||
same += 1
|
||||
else:
|
||||
diff += 1
|
||||
if first is None:
|
||||
first = (e, canon(rf, skip), canon(ri, skip))
|
||||
|
||||
k = len(ends)
|
||||
print(f" {k} 根 · 一致 {same} · 不一致 {diff} · 命中 {n_hits} 个 · "
|
||||
f"重建 {rebuilds} 次 · 末窗 {prev_grown} 根")
|
||||
print(f" 单根 全量 {t_full / k * 1000:.1f}ms → "
|
||||
f"增量 {t_incr / k * 1000:.1f}ms "
|
||||
f"({t_full / max(t_incr, 1e-9):.2f}x)")
|
||||
if first:
|
||||
e, a, b = first
|
||||
print(f" ⚠ 首个分歧 idx={e}\n 全量: {a[:300]}\n 增量: {b[:300]}")
|
||||
ss._STREAMS.clear()
|
||||
del l_all, h_all
|
||||
return {"sym": sym, "n": k, "same": same, "diff": diff, "hits": n_hits,
|
||||
"full_ms": t_full / k * 1000, "incr_ms": t_incr / k * 1000}
|
||||
|
||||
|
||||
def interleave(sym: str, cache: Path, n: int, nw: int) -> dict:
|
||||
"""模拟多 worker 交错:nw 份独立缓存轮流接同一个币。
|
||||
|
||||
这是单进程对拍覆盖不到的路径。`ProcessPoolExecutor` 不保证同一个币落到
|
||||
同一个 worker,所以每个 worker 只能隔 nw 根才再见到这个币,一次要补 nw
|
||||
根。补根走的是 `for row in df[ts > last_ts]` 那个循环——逻辑上等价于连续
|
||||
追加 nw 次,但「等价」是推理,没实测过。
|
||||
|
||||
缓存是 worker 进程内的 dict、键含 symbol,所以不存在「worker A 的状态被
|
||||
worker B 读到」或「拿到别的币的状态」。亲和性影响的是内存(每个 worker
|
||||
最终缓存全部币)与补根次数,不影响正确性——本函数就是来证这一点的。
|
||||
"""
|
||||
import shadow_signal as ss
|
||||
from verify_lean_parity import SKIP, WINDOW_KEYS, canon, load, signal_bars
|
||||
|
||||
skip = SKIP + WINDOW_KEYS + ("stream_bars",)
|
||||
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
|
||||
l_ts = l_all["timestamp"].to_numpy("int64")
|
||||
h_ts = h_all["timestamp"].to_numpy("int64")
|
||||
try:
|
||||
sb = signal_bars(sym, cache)
|
||||
sb = sb[(sb > BASE_L + n) & (sb < len(l_all) - 1)]
|
||||
start = int(sb[-1]) - n + 5 if len(sb) else BASE_L + 10
|
||||
except Exception:
|
||||
start = BASE_L + 10
|
||||
ends = [e for e in range(start, start + n) if e < len(l_all) - 1]
|
||||
|
||||
caches: list[dict] = [{} for _ in range(nw)]
|
||||
same = diff = n_hits = 0
|
||||
first = None
|
||||
for j, e in enumerate(ends):
|
||||
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
|
||||
df_l = l_all.iloc[e - BASE_L + 1:e + 1]
|
||||
df_h = h_all.iloc[max(0, hi - BASE_H):hi]
|
||||
entry = float(l_all["open"].to_numpy(float)[e + 1])
|
||||
|
||||
rf = ss.compute(df_l.copy(), df_h.copy(), entry, incr=False)
|
||||
# 轮流换缓存 = 轮流换 worker
|
||||
ss._STREAMS = caches[j % nw]
|
||||
ri = ss.compute(df_l.copy(), df_h.copy(), entry, sym=sym, incr=True)
|
||||
|
||||
n_hits += len(rf.get("hits") or [])
|
||||
if canon(rf, skip) == canon(ri, skip):
|
||||
same += 1
|
||||
else:
|
||||
diff += 1
|
||||
if first is None:
|
||||
first = (e, canon(rf, skip), canon(ri, skip))
|
||||
|
||||
grown = [len(c[(sym, "1m")][0].dataframe) for c in caches
|
||||
if (sym, "1m") in c]
|
||||
print(f" {len(ends)} 根 · {nw} 份缓存轮流 · 一致 {same} · 不一致 {diff}"
|
||||
f" · 命中 {n_hits} 个 · 各缓存末窗 {grown}")
|
||||
if first:
|
||||
e, a, b = first
|
||||
print(f" ⚠ 首个分歧 idx={e}\n 全量: {a[:300]}\n 增量: {b[:300]}")
|
||||
ss._STREAMS = {}
|
||||
del l_all, h_all
|
||||
return {"sym": sym, "n": len(ends), "same": same, "diff": diff,
|
||||
"hits": n_hits, "full_ms": 0.0, "incr_ms": 0.0}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--interleave", type=int, default=0,
|
||||
help="模拟这么多个 worker 轮流接同一个币(一次补多根)")
|
||||
ap.add_argument("--syms", default="BTC,ETH,SOL")
|
||||
ap.add_argument("--cache", default="research/live/cache")
|
||||
ap.add_argument("--n", type=int, default=300)
|
||||
ap.add_argument("--start", type=int, default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = []
|
||||
for sym in a.syms.split(","):
|
||||
print(f"\n{'=' * 70}\n{sym}")
|
||||
try:
|
||||
if a.interleave:
|
||||
rows.append(interleave(sym, Path(a.cache), a.n, a.interleave))
|
||||
else:
|
||||
rows.append(run_one(sym, Path(a.cache), a.n, a.start))
|
||||
except Exception as e:
|
||||
print(f" 跳过:{e!r}")
|
||||
if not rows:
|
||||
return
|
||||
d = pd.DataFrame(rows)
|
||||
print(f"\n\n{'=' * 70}\n汇总\n")
|
||||
print(f" 对拍 {int(d['n'].sum()):,} 根 · 不一致 {int(d['diff'].sum())} · "
|
||||
f"命中 {int(d['hits'].sum())} 个")
|
||||
if d["incr_ms"].sum() > 0:
|
||||
print(f" 单根 全量 {d['full_ms'].mean():.1f}ms → "
|
||||
f"增量 {d['incr_ms'].mean():.1f}ms "
|
||||
f"({d['full_ms'].sum() / max(d['incr_ms'].sum(), 1e-9):.2f}x)")
|
||||
if int(d["diff"].sum()) == 0:
|
||||
print("\n 逐字段一致,增量可以上线。")
|
||||
else:
|
||||
print("\n ⛔ 有分歧,不要上线。增量流的状态与全量重建不等价。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -50,7 +50,12 @@ def load(sym: str, tf: str, cache: Path) -> pd.DataFrame:
|
||||
return pd.read_feather(c[0])
|
||||
|
||||
|
||||
def canon(d: dict) -> str:
|
||||
# 随窗口长度必然改变的记账字段。比「窗口长度会不会改信号」时要排除它们,
|
||||
# 否则一定 0/12 不一致,而那是记账字段在变,不是信号在变
|
||||
WINDOW_KEYS = ("last_idx", "n_bars")
|
||||
|
||||
|
||||
def canon(d: dict, skip: tuple = SKIP) -> str:
|
||||
"""把返回值规范化成可比较的字符串。
|
||||
|
||||
浮点直接比会被末位差异误判,但 lean 走的是同一段算术、不该有任何差异,
|
||||
@@ -68,7 +73,7 @@ def canon(d: dict) -> str:
|
||||
return norm(v.item())
|
||||
return v
|
||||
return json.dumps({k: norm(v) for k, v in sorted(d.items())
|
||||
if k not in SKIP}, sort_keys=True, ensure_ascii=False)
|
||||
if k not in skip}, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def signal_bars(sym: str, cache: Path) -> np.ndarray:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""compute() 的输出对窗口长度是否不变——增量路径的前提。
|
||||
|
||||
## 为什么这是增量路径的前提
|
||||
|
||||
`init_stream/append_bar` 没有 trim:`dataframe` 靠 `pd.concat` 无界增长。所以
|
||||
增量方案必然意味着**窗口会长大**(每根 +1),只能靠周期性 `init_stream` 重建
|
||||
拉回。于是在两次重建之间,实际窗口是 [W, W+slack] 而不是恒定 W。
|
||||
|
||||
这就把一个问题摆在前面:如果 `compute()` 的输出随窗口长度变化,增量路径等于
|
||||
静默把信号换了一批——不报错、不崩,只是测的不再是回测那批信号。
|
||||
|
||||
step39 的结论是「命中率在 2000 根饱和」。**饱和不等于不变**:再加根数不再提高
|
||||
命中率,与「结果逐字段相同」是两回事。所以要单独验。
|
||||
|
||||
判据是逐字段相同,不是命中数相同。数量相同而方向或滤网标志不同,会让影子测
|
||||
的是另一批信号。
|
||||
|
||||
python research/live/verify_window_sens.py --syms BTC,ETH,SOL
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
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))
|
||||
|
||||
BASE_L, BASE_H = 2001, 801
|
||||
# 增量在两次重建之间会长这么多。取 500 是因为它对应约 8 小时,
|
||||
# 重建摊薄后单根成本仍接近纯追加
|
||||
GROW = (0, 200, 500, 1000)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--syms", default="BTC,ETH,SOL")
|
||||
ap.add_argument("--cache", default="research/live/cache")
|
||||
ap.add_argument("--n", type=int, default=40)
|
||||
a = ap.parse_args()
|
||||
|
||||
from shadow_signal import compute
|
||||
from verify_lean_parity import (SKIP, WINDOW_KEYS, canon, load,
|
||||
signal_bars)
|
||||
|
||||
# last_idx/n_bars 必然随窗口长度变。不排除的话结果一定是「全不一致」,
|
||||
# 而那说明的是记账字段在变,不是信号在变
|
||||
skip = SKIP + WINDOW_KEYS
|
||||
|
||||
cache = Path(a.cache)
|
||||
print("同一根上,只改窗口长度,比对 compute() 的全部返回字段")
|
||||
print(f"基准窗口 1m×{BASE_L} + 5m×{BASE_H};增量会让它长大,故试 "
|
||||
f"+{GROW[1:]}\n")
|
||||
|
||||
tot = {g: [0, 0] for g in GROW[1:]} # [相同, 不同]
|
||||
for sym in a.syms.split(","):
|
||||
l_all, h_all = load(sym, "1m", cache), load(sym, "5m", cache)
|
||||
l_ts = l_all["timestamp"].to_numpy("int64")
|
||||
h_ts = h_all["timestamp"].to_numpy("int64")
|
||||
try:
|
||||
sb = signal_bars(sym, cache)
|
||||
except Exception as e:
|
||||
print(f"{sym} 取信号根失败:{e!r}")
|
||||
continue
|
||||
need = BASE_L + max(GROW)
|
||||
sb = sb[(sb > need) & (sb < len(l_all) - 1)][-a.n:]
|
||||
if len(sb) == 0:
|
||||
print(f"{sym} 可用信号根不足")
|
||||
continue
|
||||
|
||||
res: dict[int, list[str]] = {g: [] for g in GROW}
|
||||
for e in sb:
|
||||
hi = int(np.searchsorted(h_ts, l_ts[e], side="right"))
|
||||
entry = float(l_all["open"].to_numpy(float)[e + 1])
|
||||
for g in GROW:
|
||||
df_l = l_all.iloc[e - (BASE_L + g) + 1:e + 1]
|
||||
df_h = h_all.iloc[max(0, hi - (BASE_H + g // 5)):hi]
|
||||
res[g].append(canon(compute(df_l.copy(), df_h.copy(), entry),
|
||||
skip=skip))
|
||||
|
||||
print(f"{sym} {len(sb)} 根信号窗口")
|
||||
for g in GROW[1:]:
|
||||
same = sum(1 for x, y in zip(res[0], res[g]) if x == y)
|
||||
tot[g][0] += same
|
||||
tot[g][1] += len(sb) - same
|
||||
print(f" +{g:>4} 根 → 一致 {same}/{len(sb)}"
|
||||
+ ("" if same == len(sb) else " ⚠ 有分歧"))
|
||||
del l_all, h_all
|
||||
|
||||
print(f"\n{'=' * 66}\n汇总\n")
|
||||
for g in GROW[1:]:
|
||||
s, d = tot[g]
|
||||
print(f" 窗口 +{g:>4} 根:一致 {s} · 不一致 {d}")
|
||||
worst = max(GROW[1:], key=lambda g: tot[g][1])
|
||||
if tot[worst][1] == 0:
|
||||
print("\n 窗口长度不影响输出,增量路径的前提成立。")
|
||||
print(" 可以按「长到 +N 根再 init_stream 重建」摊薄成本。")
|
||||
else:
|
||||
print("\n ⛔ 窗口长度会改变输出,增量路径会静默换掉一批信号。")
|
||||
print(" 此时要么每根都重建(等于没有增量),要么先把窗口效应本身收口。")
|
||||
|
||||
|
||||
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)
|
||||
atr = cdf["atr"].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({
|
||||
"sym": sym,
|
||||
"date": cdf["date"].to_numpy()[idx],
|
||||
@@ -91,8 +111,15 @@ def collect(sym: str, rows: int) -> pd.DataFrame | None:
|
||||
# 信号根的相对成交量。当根已收盘,开仓时可知,是合规的可交易信息。
|
||||
# vr10 是引擎自带口径(前 10 根均量),vr60 换个基准做稳健性对照。
|
||||
"vr10": cdf["volume_ratio"].to_numpy(float)[idx],
|
||||
"vr60": (cdf["volume"] / cdf["volume"].rolling(60, min_periods=10).mean()
|
||||
).to_numpy(float)[idx],
|
||||
"vr60": vr60[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:
|
||||
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()
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Step 54:开仓时刻落在哪个时段——亚盘 / 欧盘 / 美盘谁更赚。
|
||||
|
||||
用户问的是时段,但这个问题有两个必须先堵的坑,否则很容易得出一个假的结论:
|
||||
|
||||
① ATR 混淆。亚盘波动天然低,而低 ATR 的信号因固定成本吃亏是已知结论
|
||||
(HANDOFF §3.5:余量 = 净收益 / taker名义额,ATR 越小分母越小)。
|
||||
所以"亚盘差"完全可能只是"亚盘 ATR 低"换个说法。必须在 ATR 分层内部再看。
|
||||
② mom60 混淆。step53 刚定论 mom60 是强因子(Q4 最差),而美盘开盘那几个
|
||||
小时最容易出现已经走完一大段的行情。不控的话时段会借它的力。
|
||||
|
||||
所以本步的顺序是:先逐小时看(不设边界,边界是人定的、最容易带出想要的结论),
|
||||
再聚合成时段,最后在 ATR 与 mom60 的分层内部复核。
|
||||
|
||||
样本内外沿用 step53 的切法(IS_START),任何只在一边成立的都不认。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
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().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
|
||||
SRC = HERE / "out" / "step53_pre_entry.feather"
|
||||
IS_START = pd.Timestamp("2026-01-30", tz="Asia/Shanghai")
|
||||
|
||||
# 时段用 UTC 定义。三段等分是加密市场的通行切法,且不重叠——重叠定义会让
|
||||
# 同一笔进两个桶,比较就没有意义了。真实的开盘时刻(伦敦 08:00、纽约 13:30)
|
||||
# 落在段内而非段首,所以另有一张逐小时表兜底,防止边界把结论切出来
|
||||
SESSIONS = [("亚盘", 0, 8), ("欧盘", 8, 16), ("美盘", 16, 24)]
|
||||
# 稳健性对照:按真实开盘时刻切,并把欧美重叠那段单列
|
||||
SESSIONS_ALT = [("亚盘", 0, 7), ("欧盘", 7, 13), ("欧美重叠", 13, 17),
|
||||
("美盘", 17, 21), ("淡时段", 21, 24)]
|
||||
|
||||
|
||||
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),
|
||||
"中位ATRbp": round(g.atr_bp.median(), 1)}
|
||||
|
||||
|
||||
def label_session(h: pd.Series, table) -> pd.Series:
|
||||
out = pd.Series("?", index=h.index, dtype=object)
|
||||
for name, a, b in table:
|
||||
out[(h >= a) & (h < b)] = name
|
||||
return out
|
||||
|
||||
|
||||
def hourly(d: pd.DataFrame) -> None:
|
||||
"""逐小时。先看这个再谈时段——时段边界是人定的,逐小时不是。"""
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 一、逐小时(UTC),不设时段边界 ##########")
|
||||
rows = []
|
||||
for h, g in d.groupby("utc_h"):
|
||||
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
|
||||
rows.append({
|
||||
"UTC时": h, "北京时": (h + 8) % 24, "笔数": len(g),
|
||||
"毛R": round(g.gR.mean(), 3),
|
||||
"净均R": round(g.R.mean(), 3),
|
||||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||||
"中位ATRbp": round(g.atr_bp.median(), 1),
|
||||
"样本外毛R": round(oos.gR.mean(), 3) if len(oos) >= 30 else None,
|
||||
"发现期毛R": round(ins.gR.mean(), 3) if len(ins) >= 30 else None,
|
||||
})
|
||||
t = pd.DataFrame(rows)
|
||||
print(t.to_string(index=False))
|
||||
print(f"\n每小时平均只有 {len(d)/24:.0f} 笔,单个小时的数是噪声,"
|
||||
f"看形状不要看单点。")
|
||||
|
||||
|
||||
def sessions(d: pd.DataFrame, table, title: str) -> None:
|
||||
print("\n" + "=" * 100)
|
||||
print(f"########## {title} ##########")
|
||||
d = d.copy()
|
||||
d["seg"] = label_session(d.utc_h, table)
|
||||
order = [n for n, _, _ in table]
|
||||
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
|
||||
("发现期", d[d.date >= IS_START])):
|
||||
rows = [stat(part[part.seg == n], n, len(part)) for n in order]
|
||||
print(f"\n--- {lab}({len(part)} 笔)---")
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def control(d: pd.DataFrame, col: str, name: str, table) -> None:
|
||||
"""在混淆变量的高/低两半内部各看一次时段。
|
||||
|
||||
时段若只是 ATR(或 mom60)的代理,分层后段间差异会塌掉。
|
||||
"""
|
||||
x = d[d[col].notna() & np.isfinite(d[col])].copy()
|
||||
x["seg"] = label_session(x.utc_h, table)
|
||||
x["层"] = np.where(x[col] >= x[col].median(), f"{name}高", f"{name}低")
|
||||
order = [n for n, _, _ in table]
|
||||
rows = []
|
||||
for lay, g in x.groupby("层"):
|
||||
r = {"控制层": lay, "笔数": len(g)}
|
||||
for n in order:
|
||||
s = g[g.seg == n]
|
||||
r[n] = round(s.gR.mean(), 3) if len(s) >= 40 else None
|
||||
vals = [r[n] for n in order if r[n] is not None]
|
||||
r["极差"] = round(max(vals) - min(vals), 3) if len(vals) > 1 else None
|
||||
rows.append(r)
|
||||
print(f"\n--- 控 {name}({col})后的段间毛R ---")
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def permutation(d: pd.DataFrame, table, n_iter: int = 20_000,
|
||||
seed: int = 0) -> None:
|
||||
"""段间极差有没有超出随机分组的水平。
|
||||
|
||||
24 个小时聚成 3 段,本来就会因为噪声产生一定的段间差异。不做这一步就没法
|
||||
区分"时段有效"与"任意切三份都能切出这么大的差"。
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
seg = label_session(d.utc_h, table).to_numpy()
|
||||
g = d.gR.to_numpy()
|
||||
order = [n for n, _, _ in table]
|
||||
obs_means = np.array([g[seg == n].mean() for n in order])
|
||||
obs = obs_means.max() - obs_means.min()
|
||||
cnt = 0
|
||||
for _ in range(n_iter):
|
||||
p = rng.permutation(seg)
|
||||
m = np.array([g[p == n].mean() for n in order])
|
||||
if m.max() - m.min() >= obs:
|
||||
cnt += 1
|
||||
print(f"\n置换检验:实测段间毛R极差 {obs:.3f},"
|
||||
f"随机打乱 {n_iter} 次里有 {cnt/n_iter*100:.2f}% 达到或超过它 "
|
||||
f"→ p = {cnt/n_iter:.4f}")
|
||||
|
||||
|
||||
NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
||||
|
||||
def weekday(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 四、星期几(按 UTC)##########")
|
||||
rows = []
|
||||
for k, g in d.groupby("utc_dow"):
|
||||
oos, ins = g[g.date < IS_START], g[g.date >= IS_START]
|
||||
r = stat(g, NAMES[k], len(d))
|
||||
# 样本内外必须并排看。时段那一节正是靠这一列拆穿"美盘最好"的
|
||||
r["样本外毛R"] = round(oos.gR.mean(), 3) if len(oos) >= 30 else None
|
||||
r["发现期毛R"] = round(ins.gR.mean(), 3) if len(ins) >= 30 else None
|
||||
rows.append(r)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n--- 工作日 vs 周末 ---")
|
||||
for lab, part in (("全样本", d), ("样本外", d[d.date < IS_START]),
|
||||
("发现期", d[d.date >= IS_START])):
|
||||
we = part.utc_dow >= 5
|
||||
t = pd.DataFrame([stat(part[~we], "工作日", len(part)),
|
||||
stat(part[we], "周末", len(part))])
|
||||
print(f"\n{lab}({len(part)} 笔)")
|
||||
print(t.to_string(index=False))
|
||||
|
||||
|
||||
def weekend_controls(d: pd.DataFrame) -> None:
|
||||
"""周末效应是不是 ATR / mom60 / 币种 的代理。
|
||||
|
||||
周末 ATR 中位比工作日低 1bp,而低 ATR 吃亏是已知的,所以必须分层复核。
|
||||
"""
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 五、周末效应的混淆检查 ##########")
|
||||
x = d.copy()
|
||||
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
|
||||
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
|
||||
("vpre10", "前10根量")):
|
||||
g = x[x[col].notna() & np.isfinite(x[col])].copy()
|
||||
g["层"] = np.where(g[col] >= g[col].median(), f"{name}高", f"{name}低")
|
||||
rows = []
|
||||
for lay, s in g.groupby("层"):
|
||||
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
|
||||
if min(len(wd), len(we)) < 40:
|
||||
continue
|
||||
rows.append({"控制层": lay, "工作日毛R": round(wd.gR.mean(), 3),
|
||||
"周末毛R": round(we.gR.mean(), 3),
|
||||
"差": round(we.gR.mean() - wd.gR.mean(), 3),
|
||||
"工作日余量": round(wd.net.mean()/wd.tn.mean()*1e4, 2),
|
||||
"周末余量": round(we.net.mean()/we.tn.mean()*1e4, 2)})
|
||||
print(f"\n--- 控 {name} ---")
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n--- 逐币:周末差是普遍的还是少数币带的 ---")
|
||||
rows = []
|
||||
for sym, s in x.groupby("sym"):
|
||||
wd, we = s[s.周末 == "工作日"], s[s.周末 == "周末"]
|
||||
if min(len(wd), len(we)) < 25:
|
||||
continue
|
||||
rows.append({"币": sym, "工作日笔数": len(wd), "周末笔数": len(we),
|
||||
"工作日毛R": round(wd.gR.mean(), 3),
|
||||
"周末毛R": round(we.gR.mean(), 3),
|
||||
"差": round(we.gR.mean() - wd.gR.mean(), 3)})
|
||||
t = pd.DataFrame(rows).sort_values("差")
|
||||
print(t.to_string(index=False))
|
||||
neg = (t["差"] < 0).sum()
|
||||
print(f"\n{neg}/{len(t)} 个币周末更差")
|
||||
|
||||
|
||||
def overlap_with_mom60(d: pd.DataFrame) -> None:
|
||||
"""周末的劣势是不是已经被 step53 的 `mom60≥7` 过滤吃掉了。
|
||||
|
||||
这是决定"要不要再加一条时间过滤"的关键:两个过滤若砍的是同一批单子,
|
||||
叠加只会白丢笔数。控制表已有暗示——mom60 低的那层周末差只有 -0.069,
|
||||
高的那层 -0.169。
|
||||
"""
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 六、周末效应与 mom60 过滤的重叠 ##########")
|
||||
x = d.copy()
|
||||
x["周末"] = np.where(x.utc_dow >= 5, "周末", "工作日")
|
||||
x["周日"] = np.where(x.utc_dow == 6, "周日", "其余")
|
||||
|
||||
print("\n--- 高动量(mom60≥7,step53 要砍的那批)在各组里的占比 ---")
|
||||
for col in ("周末", "周日"):
|
||||
t = x.groupby(col).apply(
|
||||
lambda g: pd.Series({
|
||||
"笔数": len(g),
|
||||
"mom60≥7占比": f"{(g.mom60 >= 7).mean()*100:.1f}%",
|
||||
"中位mom60": round(g.mom60.median(), 2)}))
|
||||
print(t.to_string())
|
||||
|
||||
print("\n--- 施加 mom60<7 之后,周末差还剩多少 ---")
|
||||
rows = []
|
||||
for lab, sub in (("过滤前(全部)", x),
|
||||
("过滤后(mom60<7)", x[x.mom60 < 7])):
|
||||
for col in ("周末", "周日"):
|
||||
a = sub[sub[col] == sub[col].unique()[0]]
|
||||
hi = sub[sub[col].isin(["周末", "周日"])]
|
||||
lo = sub[sub[col].isin(["工作日", "其余"])]
|
||||
if min(len(hi), len(lo)) < 40:
|
||||
continue
|
||||
rows.append({
|
||||
"口径": lab, "对比": f"{col} vs 其余",
|
||||
"差组笔数": len(hi), "差组毛R": round(hi.gR.mean(), 3),
|
||||
"对照毛R": round(lo.gR.mean(), 3),
|
||||
"毛R差": round(hi.gR.mean() - lo.gR.mean(), 3),
|
||||
"差组余量": round(hi.net.mean()/hi.tn.mean()*1e4, 2),
|
||||
"对照余量": round(lo.net.mean()/lo.tn.mean()*1e4, 2)})
|
||||
print(pd.DataFrame(rows).drop_duplicates().to_string(index=False))
|
||||
|
||||
|
||||
def perm_binary(d: pd.DataFrame, mask: np.ndarray, lab: str,
|
||||
n_iter: int = 20_000, seed: int = 0) -> None:
|
||||
"""两组均值差的置换检验。"""
|
||||
rng = np.random.default_rng(seed)
|
||||
g = d.gR.to_numpy()
|
||||
obs = g[mask].mean() - g[~mask].mean()
|
||||
n = int(mask.sum())
|
||||
cnt = 0
|
||||
for _ in range(n_iter):
|
||||
idx = rng.permutation(len(g))[:n]
|
||||
m = np.zeros(len(g), bool)
|
||||
m[idx] = True
|
||||
if abs(g[m].mean() - g[~m].mean()) >= abs(obs):
|
||||
cnt += 1
|
||||
print(f"\n置换检验({lab}):实测毛R差 {obs:+.3f},"
|
||||
f"随机分组 {n_iter} 次里 {cnt/n_iter*100:.2f}% 达到或超过 "
|
||||
f"→ p = {cnt/n_iter:.4f}")
|
||||
|
||||
|
||||
def decision(d: pd.DataFrame, cuts: dict) -> None:
|
||||
"""若砍掉某些时段,在不同真实滑点下的总R。
|
||||
|
||||
沿用 step53 的决策表口径:砍掉一批信号既省成本也丢收益,哪边大取决于
|
||||
真实滑点,所以必须按滑点扫一遍,不能只报一个数。
|
||||
"""
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 七、把时段做成过滤的决策表(发现期总R)##########")
|
||||
ins = d[d.date >= IS_START]
|
||||
slips = [0, 5, 8, 10, 12, 15, 18, 20]
|
||||
rows = []
|
||||
for lab, mask in cuts.items():
|
||||
m = mask(ins)
|
||||
sub = ins[m]
|
||||
r = {"方案": lab, "保留": f"{m.mean()*100:.0f}%"}
|
||||
for s in slips:
|
||||
# 滑点只打在 taker 腿上,口径与 lib.exit_model 一致
|
||||
net = sub.net - sub.tn * s / 1e4
|
||||
r[f"{s}bp"] = round((net / (SL * sub.atr_pct)).sum(), 0)
|
||||
rows.append(r)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
SRC41 = HERE / "out" / "step41_exit_tp.feather"
|
||||
# step41 的分批出场配置名,与实盘完全一致:SL 2 / 减半 3(scale_at 默认)/
|
||||
# runner 8 / runner 止损 2 / 48 根。见 lib.exit_model.cfg_name
|
||||
CFG41 = "s2_so8_k2_m48"
|
||||
|
||||
|
||||
def long_history() -> None:
|
||||
"""用 5m/15m/30m 的 7 年数据复核时段与星期几。
|
||||
|
||||
为什么要这一步:1m 只能追到 18.5 个月(3941 笔),而时段结论是"没效果",
|
||||
**没效果最怕的就是样本不够**。step41 那份跨 2019-09 → 2026-08,5m 单周期
|
||||
就有 5434 笔,且过滤口径相同(`h1_agree==1 & push` 就是深色),出场配置
|
||||
也能对上实盘那套。若长样本上仍然测不出时段效应,"无效"才站得住。
|
||||
|
||||
⚠️ 口径差一处:这里没加 ATR≥8bp 门控。§3.5 实测该门控在这些周期上几乎
|
||||
不触发(30m 0%、15m 0.16%、5m 2.6%),所以影响可忽略,但要记着。
|
||||
"""
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
d = pd.read_feather(SRC41)
|
||||
d["date"] = pd.to_datetime(d["date"])
|
||||
g, r, c = (d[f"{CFG41}_{k}"].to_numpy() for k in ("g", "r", "c"))
|
||||
d["net"] = g - fee_of(r, c)
|
||||
d["gR"] = g / (SL * d.atr_pct.values)
|
||||
d["R"] = d.net / (SL * d.atr_pct.values)
|
||||
d["tn"] = taker_notional(r, c)
|
||||
d["atr_bp"] = d.atr_pct * 1e4
|
||||
utc = d.date.dt.tz_convert("UTC")
|
||||
d["utc_h"], d["utc_dow"] = utc.dt.hour, utc.dt.dayofweek
|
||||
|
||||
print("\n" + "#" * 100)
|
||||
print("########## 长样本复核:5m/15m/30m × 7 年 ##########")
|
||||
print(f"{len(d)} 笔 · {d.symbol.nunique()} 币 · "
|
||||
f"{d.date.min():%Y-%m} → {d.date.max():%Y-%m}"
|
||||
f"({(d.date.max()-d.date.min()).days} 天)")
|
||||
print(f"出场配置 {CFG41}(= 实盘的 2/3/8/2/48)· 深色已过滤 · 无 ATR 门控")
|
||||
|
||||
for tf in ("5m", "15m", "30m"):
|
||||
x = d[d.ltf == tf].copy()
|
||||
if len(x) < 300:
|
||||
continue
|
||||
x["seg"] = label_session(x.utc_h, SESSIONS)
|
||||
print(f"\n--- {tf}({len(x)} 笔)时段 ---")
|
||||
rows = []
|
||||
for n, _, _ in SESSIONS:
|
||||
s = x[x.seg == n]
|
||||
ins, oos = s[s.group == "样本内"], s[s.group == "样本外"]
|
||||
row = stat(s, n, len(x))
|
||||
row["样本内毛R"] = round(ins.gR.mean(), 3) if len(ins) >= 30 else None
|
||||
row["样本外毛R"] = round(oos.gR.mean(), 3) if len(oos) >= 30 else None
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
permutation(x, SESSIONS, n_iter=10_000)
|
||||
|
||||
we = (x.utc_dow >= 5).to_numpy()
|
||||
print(f"\n--- {tf} 工作日 vs 周末 ---")
|
||||
print(pd.DataFrame([stat(x[~we], "工作日", len(x)),
|
||||
stat(x[we], "周末", len(x))]).to_string(index=False))
|
||||
perm_binary(x, we, f"{tf} 周末 vs 工作日", n_iter=10_000)
|
||||
|
||||
print("\n--- 三周期合并(每笔等权)---")
|
||||
d["seg"] = label_session(d.utc_h, SESSIONS)
|
||||
print(pd.DataFrame([stat(d[d.seg == n], n, len(d))
|
||||
for n, _, _ in SESSIONS]).to_string(index=False))
|
||||
permutation(d, SESSIONS, n_iter=10_000)
|
||||
we = (d.utc_dow >= 5).to_numpy()
|
||||
print(pd.DataFrame([stat(d[~we], "工作日", len(d)),
|
||||
stat(d[we], "周末", len(d))]).to_string(index=False))
|
||||
perm_binary(d, we, "合并 周末 vs 工作日", n_iter=10_000)
|
||||
perm_binary(d, (d.utc_dow == 6).to_numpy(), "合并 周日 vs 其余",
|
||||
n_iter=10_000)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--src", default=str(SRC))
|
||||
ap.add_argument("--long", action="store_true",
|
||||
help="只跑 5m/15m/30m 的 7 年长样本复核")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.long:
|
||||
long_history()
|
||||
return
|
||||
|
||||
from step50_volume import prep
|
||||
|
||||
d = pd.read_feather(args.src)
|
||||
d["date"] = pd.to_datetime(d["date"])
|
||||
d = prep(d[(d.htf == 1.0) & d.lad & (d.atr_bp >= GATE_BP)].copy())
|
||||
utc = d.date.dt.tz_convert("UTC")
|
||||
d["utc_h"] = utc.dt.hour
|
||||
d["utc_dow"] = utc.dt.dayofweek
|
||||
|
||||
span = (d.date.max() - d.date.min()).days
|
||||
print(f"实盘口径 {len(d)} 笔 · {d.sym.nunique()} 币 · 跨 {span} 天 "
|
||||
f"({d.date.min():%Y-%m-%d} → {d.date.max():%Y-%m-%d})")
|
||||
print(f"样本外 {(d.date < IS_START).sum()} · "
|
||||
f"发现期 {(d.date >= IS_START).sum()}")
|
||||
print("⚠️ 持仓最长 48 分钟,跨段的笔按**入场时刻**归属——那是唯一可操作的口径")
|
||||
|
||||
hourly(d)
|
||||
sessions(d, SESSIONS, "二、三段等分(UTC 0/8/16)")
|
||||
permutation(d, SESSIONS)
|
||||
sessions(d, SESSIONS_ALT, "三、稳健性对照:按真实开盘时刻切五段")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 混淆检查:时段是不是 ATR / mom60 的代理 ##########")
|
||||
for col, name in (("atr_bp", "ATR"), ("mom60", "前60根动量"),
|
||||
("vpre10", "前10根量")):
|
||||
control(d, col, name, SESSIONS)
|
||||
|
||||
weekday(d)
|
||||
perm_binary(d, (d.utc_dow >= 5).to_numpy(), "周末 vs 工作日")
|
||||
perm_binary(d, (d.utc_dow == 6).to_numpy(), "周日 vs 其余")
|
||||
weekend_controls(d)
|
||||
overlap_with_mom60(d)
|
||||
|
||||
decision(d, {
|
||||
"等权(现状)": lambda x: np.ones(len(x), bool),
|
||||
"砍周日": lambda x: (x.utc_dow != 6).to_numpy(),
|
||||
"砍周末": lambda x: (x.utc_dow < 5).to_numpy(),
|
||||
"砍欧盘": lambda x: ((x.utc_h < 8) | (x.utc_h >= 16)).to_numpy(),
|
||||
"砍 mom60≥7(step53 基准)": lambda x: (x.mom60 < 7).to_numpy(),
|
||||
"砍 mom60≥7 + 周日": lambda x: ((x.mom60 < 7) &
|
||||
(x.utc_dow != 6)).to_numpy(),
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Step 55:一/二类买卖点的可行性探针——先量滞后,不急着跑收益。
|
||||
|
||||
用户提出把 B4 那套研究思路搬到一二类上。搬之前必须先过一道闸,理由写在
|
||||
`Chan_BSP_TYPE` 的枚举注释里:**B4 存在的全部意义就是"几何位置同 B3/S3,
|
||||
但不等笔确认"**。也就是说这个项目早就付过"等笔确认"的学费。
|
||||
|
||||
而一类买卖点是最滞后的一种构造:它要中枢 `is_sure`、要离开笔 `is_sure`、
|
||||
还要背驰判定(`check_bi_div` 读 `macd_hist`)。二类更靠后,要在一类之后再走
|
||||
两笔。所以真正的问题不是"一二类赚不赚钱",而是:
|
||||
|
||||
你能在什么时候知道它,那时候价格还在不在。
|
||||
|
||||
`ChanBSP` 给了两个时刻,差值就是答案:
|
||||
klc.end_time = leave_bi.end_klc 的收盘时刻,**极值所在**,理想入场点
|
||||
sure_time = 笔被确认的时刻,**你最早能动手的时刻**
|
||||
|
||||
本步只回答三件事,跑得快、结论硬:
|
||||
1. 一二类各有多少笔(对比 B4 的 5.3 笔/天 / 10 币)
|
||||
2. 滞后多少根
|
||||
3. 这段等待里价格跑掉多少个 ATR —— 这是"入场价漂移",直接从余量里扣
|
||||
|
||||
⚠️ 不在本步做收益回测。滞后若不可接受,收益怎么算都是假的:`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)
|
||||
|
||||
OUT = HERE / "out" / "step55_bsp12.feather"
|
||||
# B4 的对照基线,来自 §3.31 / step48:10 币 1m 实盘口径
|
||||
B4_PER_DAY_10SYM = 5.3
|
||||
# 出场结构与实盘完全一致,见 live/exit_params.py
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str = "1m") -> pd.DataFrame | None:
|
||||
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.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
# 必须走 full:check_bi_div 读 macd_hist,那只在 full 下算。
|
||||
# 而 TF_DF 自己**不填** bsp_list——web 的 analyze.py 是显式调
|
||||
# find_all_bsp 的,这里照抄那条链,口径才对得上
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bi_zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bi_zs:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bi_zs)
|
||||
if not bsp:
|
||||
return None
|
||||
|
||||
# 索引映射有两个坑,都会静默给出错的下标:
|
||||
# ① cdf.date 带时区(Asia/Shanghai),而 KLC 上的时间是**字符串**且
|
||||
# 不带时区。它们本就是同一个时钟的墙上时间,所以去 tz 而不是硬贴。
|
||||
# ② cdf.date 的单位是 datetime64[ms],`astype("int64")` 给的是毫秒,
|
||||
# 而 `Timestamp.value` 是纳秒,差 1e6 倍——手工转整数会让
|
||||
# searchsorted 全部落到末尾,且不报错。交给 DatetimeIndex 自己比。
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
if t.tz is not None:
|
||||
t = t.tz_localize(None)
|
||||
return int(didx.searchsorted(t))
|
||||
|
||||
op = cdf["open"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
# 极值根:笔末 KLC 的收盘时刻。KLC 是合并后的,取它覆盖的最后一根
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
# 理想价:极值根收盘。可执行价:确认根的**次根开盘**——与
|
||||
# walk_exits / 实盘的口径一致(信号根收盘后才下单)
|
||||
px_ideal = cl[i_ext]
|
||||
j = min(i_sure + 1, n - 1)
|
||||
px_real = op[j]
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"date_ext": dser.iloc[i_ext], "date_sure": dser.iloc[i_sure],
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"atr_bp": a / px_ideal * 1e4,
|
||||
# 结构位:笔末 KLC 的实际极值,不是收盘。止损要挂在它外面
|
||||
# 才谈得上「守住结构」,用收盘价会把插针那一段漏掉
|
||||
"ext_px": float(b.klc.low if d == 1 else b.klc.high),
|
||||
"entry_px": px_real,
|
||||
# 等待期间价格顺着信号方向跑掉了多少(正 = 你追高/追空,吃亏)
|
||||
"drift_atr": (px_real - px_ideal) * d / a,
|
||||
"drift_bp": (px_real - px_ideal) * d / px_ideal * 1e4,
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场口径与实盘一致:确认根收盘后下单,walk_exits 用 entry_idx+1 的
|
||||
# 开盘价,ATR 取 entry_idx 那根。所以 entry_idx = i_sure,**不是**
|
||||
# i_ext——用极值根等于假设你能买在笔的低点,那是未来函数
|
||||
r = r[(r.i_sure < len(cdf) - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
sig = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": r.dir.values})
|
||||
res = walk_exits(cdf, sig, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
print(f" {sym} {tf} 长度不齐 {len(res)} vs {len(r)},跳过",
|
||||
flush=True)
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
# 反向对照:胜率远低于随机说明信号带信息但可能指反了。出场是路径依赖的,
|
||||
# 不能把 g 取负号了事,必须拿相反方向重跑一遍
|
||||
flip = pd.DataFrame({"entry_idx": r.i_sure.values,
|
||||
"direction": -r.dir.values})
|
||||
rf = walk_exits(cdf, flip, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(rf) == len(r):
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r["f_" + k] = rf[f"{cfg}_{k}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
# 原始 ATR,供结构止损换算用。atr_pct 除过价格,换不回来
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e:
|
||||
print(f" {sym} 失败: {e!r}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, span_days: float, n_sym: int) -> None:
|
||||
print("\n" + "=" * 100)
|
||||
print(f"########## 一、笔数:够不够做 ##########")
|
||||
t = d.groupby("type").agg(笔数=("lag_bars", "size"))
|
||||
t[f"{n_sym}币每天"] = (t["笔数"] / span_days).round(2)
|
||||
# 必须按币归一再比。B4 那个 5.3 是 10 币的合计,直接拿 5 币的数去除
|
||||
# 等于凭空把速率打对折
|
||||
t["每币每天"] = (t["笔数"] / span_days / n_sym).round(3)
|
||||
t["对B4倍数"] = (t["笔数"] / span_days / n_sym /
|
||||
(B4_PER_DAY_10SYM / 10)).round(2)
|
||||
print(t.to_string())
|
||||
print(f"\n对照基线:B4 在 1m 上 10 币 {B4_PER_DAY_10SYM} 笔/天 = "
|
||||
f"**{B4_PER_DAY_10SYM/10:.3f} 笔/币/天**。"
|
||||
f"本表 {n_sym} 币 / 跨 {span_days:.0f} 天。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 二、滞后:从极值到可动手,差多少根 ##########")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.lag_bars.quantile([0.25, 0.5, 0.75, 0.9]).round(1)
|
||||
rows.append({"类型": name, "笔数": len(g),
|
||||
"滞后中位": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"P90": q[0.9], "均值": round(g.lag_bars.mean(), 1),
|
||||
"最大": int(g.lag_bars.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n1m 上 1 根 = 1 分钟。B4 的滞后是 0 根——它在信号根收盘即可下单。")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 三、等待的代价:价格跑掉了多少 ##########")
|
||||
print("drift 为正 = 等待期间价格顺着信号方向走了,你只能追;这一段直接从"
|
||||
"余量里扣")
|
||||
print("⚠️ 「追不上占比」必然是 100%,那是定义决定的不是实测发现:笔之所以"
|
||||
"在那里结束,\n 正是因为价格从那个极值反向走了——`bi.end_klc` 是极值"
|
||||
"点,之后必然朝信号方向偏离。\n **有信息量的是幅度,不是符号。**")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
q = g.drift_atr.quantile([0.25, 0.5, 0.75]).round(2)
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"漂移中位(ATR)": q[0.5], "P25": q[0.25], "P75": q[0.75],
|
||||
"漂移均值(ATR)": round(g.drift_atr.mean(), 2),
|
||||
"漂移均值(bp)": round(g.drift_bp.mean(), 1),
|
||||
"中位ATR(bp)": round(g.atr_bp.median(), 1),
|
||||
"追不上占比": f"{(g.drift_atr > 0).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 四、和止损宽度比:漂移吃掉多少风险预算 ##########")
|
||||
print("实盘止损是 2 ATR。若漂移中位已经是 1 ATR,等于你的止损只剩一半,"
|
||||
"而目标位还在原处——盈亏比被腰斩。")
|
||||
rows = []
|
||||
for name, g in d.groupby("type"):
|
||||
rows.append({
|
||||
"类型": name,
|
||||
"漂移/止损(2ATR)": f"{g.drift_atr.median()/2*100:.0f}%",
|
||||
"漂移超过 1ATR 占比": f"{(g.drift_atr > 1).mean()*100:.0f}%",
|
||||
"漂移超过 2ATR(已穿止损)": f"{(g.drift_atr > 2).mean()*100:.0f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def profit(d: pd.DataFrame) -> None:
|
||||
"""各类买卖点按实盘出场结构的实际表现。
|
||||
|
||||
B3 是校验锚:HANDOFF §4 已记引擎 `find_all_bsp` 的 B3/S3 是系统性亏损
|
||||
(PF 0.66、胜率 27.4%、t −18.76)。若这里 B3 跑出个漂亮数字,说明口径接错了,
|
||||
先别信 B1/B2 的结果。
|
||||
"""
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
if "g" not in d.columns:
|
||||
print("\n(本次数据无回测列,跳过收益段;删掉 out/step55_bsp12.feather "
|
||||
"重跑可得)")
|
||||
return
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
x["net"] = x.g.values - fee_of(x.r.values, x.c.values)
|
||||
x["gR"] = x.g.values / (SL * x.atr_pct.values)
|
||||
x["R"] = x.net.values / (SL * x.atr_pct.values)
|
||||
x["tn"] = taker_notional(x.r.values, x.c.values)
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 五、按实盘出场结构(2/3/8/2/48)的实际表现 ##########")
|
||||
rows = []
|
||||
for name, g in x.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.net[g.net > 0].sum(), -g.net[g.net <= 0].sum()
|
||||
rows.append({
|
||||
"类型": name, "笔数": len(g),
|
||||
"胜率": 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,
|
||||
"R夏普": round(g.R.mean() / g.R.std(ddof=1), 3),
|
||||
"余量bp": round(g.net.mean() / g.tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(g.b.median()),
|
||||
"t值": round(g.gR.mean() / (g.gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n⚠️ B3 是口径校验锚:HANDOFF §4 记的是 PF 0.66 / 胜率 27.4%。"
|
||||
"这里若明显更好,先怀疑接错了再信 B1/B2。")
|
||||
|
||||
if "f_g" not in x.columns:
|
||||
return
|
||||
print("\n" + "=" * 100)
|
||||
print("########## 六、反向对照:信号是「指反了」还是「没有边」 ##########")
|
||||
print("胜率 18.6% 远低于 SL2/TP3 的随机基准约 40%,说明样本带信息。若只是"
|
||||
"方向标反了,\n反手做应显著为正;若反手也不赚,那就是入场时点本身"
|
||||
"已经过期,两边都吃滑点。")
|
||||
y = x.copy()
|
||||
y["fnet"] = y.f_g.values - fee_of(y.f_r.values, y.f_c.values)
|
||||
y["fgR"] = y.f_g.values / (SL * y.atr_pct.values)
|
||||
rows = []
|
||||
for name, g in y.groupby("type"):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
w, o = g.fnet[g.fnet > 0].sum(), -g.fnet[g.fnet <= 0].sum()
|
||||
rows.append({
|
||||
"类型(反手)": name, "笔数": len(g),
|
||||
"胜率": f"{(g.fnet > 0).mean()*100:.1f}%",
|
||||
"毛R": round(g.fgR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"t值": round(g.fgR.mean() / (g.fgR.std(ddof=1) / np.sqrt(len(g))),
|
||||
2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m",
|
||||
help="要测的周期。1m 上滞后的绝对根数与长周期相同,"
|
||||
"但每根值的钱不同,所以周期是关键变量")
|
||||
ap.add_argument("--rows", type=int, default=200_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(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[一二类可行性探针] {len(syms)} 币 × {tfs} × {args.rows} 根"
|
||||
f"(full 模式,find_all_bsp 要 macd_hist)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
|
||||
d["date_ext"] = pd.to_datetime(d["date_ext"])
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (x.date_ext.max() - x.date_ext.min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 100)
|
||||
print(f"########## {tf} —— {len(x)} 个买卖点 · "
|
||||
f"{x.sym.nunique()} 币 · 跨 {span:.0f} 天 ##########")
|
||||
report(x, span, x.sym.nunique())
|
||||
profit(x)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""实时版一类买卖点(fast B1/S1)能否翻正。
|
||||
|
||||
step55 已判定引擎的 B1/S1 不可用(5m PF 0.24/0.20、胜率 18.6%/14.8%、
|
||||
t −17/−22),且病根是滞后 8~9 根带来的几何劣势,不是参数。B3 当年同病
|
||||
(PF 0.66),靠重新定义成实时判据翻到 1.59(B4)。本脚本对一类做同样的尝试。
|
||||
|
||||
对照组三条,缺一不可:
|
||||
引擎 B1/S1 step55 的数,说明「不改判据」是什么下场
|
||||
fast B1/S1 本次
|
||||
fast B3/S3 同一份数据、同一套出场跑 B4,确认管线本身能跑出正数
|
||||
—— 少了它,fast B1 若为负就分不清是判据不行还是管线接错了
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step56_fast_bsp1.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
|
||||
|
||||
def collect(sym: str, rows: int, tf: str) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
from lib.fast_bsp1 import find_fast_bsp1, zones_with_enter_area
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(chan.dataframe)
|
||||
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zs_list:
|
||||
return None
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
parts = []
|
||||
|
||||
d1 = {}
|
||||
s1 = find_fast_bsp1(cdf, zones_with_enter_area(zs_list, cdf), diag=d1)
|
||||
if not s1.empty:
|
||||
s1["kind"] = "fastB1"
|
||||
parts.append(s1)
|
||||
# 同数据同出场跑一遍 B4,作为「管线能出正数」的存在性证明
|
||||
s3 = find_fast_bsp3(cdf, zones_from_zs_list(zs_list, cdf))
|
||||
if not s3.empty:
|
||||
s3["kind"] = "fastB3"
|
||||
parts.append(s3)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
r = pd.concat(parts, ignore_index=True)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, r[["entry_idx", "direction"]], [SL], [RUNNER],
|
||||
[MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for k in ("g", "r", "c", "b"):
|
||||
r[k] = res[f"{cfg}_{k}"].to_numpy()
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["date"] = cdf["date"].to_numpy()[r.entry_idx.values]
|
||||
r["diag"] = str(d1)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"R夏普": round(R.mean() / R.std(ddof=1), 3),
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"中位持仓": int(np.median(g.b.values)),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
span = (pd.to_datetime(x.date).max()
|
||||
- pd.to_datetime(x.date).min()).total_seconds() / 86400
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · {x.sym.nunique()} 币 · 跨 {span:.0f} 天"
|
||||
f" ##########")
|
||||
rows = []
|
||||
for (kind, dirn), g in x.groupby(["kind", "direction"]):
|
||||
if len(g) < 40:
|
||||
continue
|
||||
nm = {"fastB1": ("一买", "一卖"), "fastB3": ("三买", "三卖")}[kind]
|
||||
rows.append({"信号": f"{kind} {nm[0 if dirn == 1 else 1]}",
|
||||
"每币每天": round(len(g) / span / x.sym.nunique(), 3),
|
||||
**stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · step55 引擎原生(同周期同出场):")
|
||||
print(" 5m B1 PF 0.24 胜率 18.6% t −17.3 | S1 PF 0.20 胜率 14.8% "
|
||||
"t −22.3")
|
||||
print(" 15m B1 PF 0.21 t −18.9 | S1 PF 0.23 t −15.9")
|
||||
|
||||
f1 = x[x.kind == "fastB1"]
|
||||
if len(f1) >= 120 and "ext_atr" in f1.columns:
|
||||
y = f1.dropna(subset=["ext_atr"])
|
||||
print("\n延伸度分档(ext_atr,step62 在滞后版上实测的唯一单调特征:"
|
||||
"\n命中线段顶点的比例 12%→44%,PF 0.12→0.46):")
|
||||
q = pd.qcut(y["ext_atr"], 4,
|
||||
labels=["Q1最短", "Q2", "Q3", "Q4最延伸"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in y.groupby(q, observed=True)
|
||||
if len(v) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n⭐ 组合:低滞后(本模块)+ 延伸过滤(step62)—— "
|
||||
"唯一同时处理两个约束的路径")
|
||||
p50, p75 = y["ext_atr"].quantile(.50), y["ext_atr"].quantile(.75)
|
||||
dm = y["div"].median()
|
||||
rows = [{"过滤器": "无", **stats(y)}]
|
||||
for nm, g in [
|
||||
(f"ext≥P50({p50:.1f})", y[y["ext_atr"] >= p50]),
|
||||
(f"ext≥P75({p75:.1f})", y[y["ext_atr"] >= p75]),
|
||||
(f"ext≥P75 且 div≥中位", y[(y["ext_atr"] >= p75)
|
||||
& (y["div"] >= dm)]),
|
||||
(f"ext≥P50 且 div≥中位", y[(y["ext_atr"] >= p50)
|
||||
& (y["div"] >= dm)]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **stats(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("对照 · 同过滤器在滞后版(step62,5m):无 PF 0.22 → "
|
||||
"ext≥P75且div≥中位 PF 0.54")
|
||||
|
||||
if len(f1) >= 60:
|
||||
print("\n背驰强度分档(div = 离开段面积/进入段面积,越小背驰越强):")
|
||||
q = pd.qcut(f1["div"], 4, labels=["Q1最强", "Q2", "Q3", "Q4最弱"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(q, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
print("\n滞后分档(lag = 入场根 − 离开段极值根):")
|
||||
b = pd.cut(f1["lag"], [-1, 1, 3, 6, 12, 1e9],
|
||||
labels=["≤1根", "2-3根", "4-6根", "7-12根", ">12根"])
|
||||
print(pd.DataFrame([{"档": k, **stats(v)}
|
||||
for k, v in f1.groupby(b, observed=True)
|
||||
if len(v) >= 20]).to_string(index=False))
|
||||
|
||||
dg = d[d.kind == "fastB1"].diag.dropna()
|
||||
if len(dg):
|
||||
print("\n" + "=" * 92)
|
||||
print("fast B1 漏斗(首个中枢样本):", dg.iloc[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
print(f"[实时版一类] {len(syms)} 币 × {tfs} × {args.rows} 根\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, args.rows, t): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""一类反手(fade B1/S1)是不是真的——逐币、分时段、与 B4 的重叠。
|
||||
|
||||
step55 的反向对照给出 5m/15m 上 PF 2.35~3.03、t +10~+13,且多空两边都正。
|
||||
数字太好,先按 §3.392 的教训做证伪:那次「周末效应」在 1m 上显著,换到
|
||||
7 年 5m/15m 样本就消失且符号反转。任何单一样本上的漂亮结果都要先过三关。
|
||||
|
||||
逐币 5 个币是不是都成立,还是被某一个币带起来的
|
||||
分时段 前后半段样本各自是否成立(时间外样本)
|
||||
与 B4 重叠 如果它只是换个名字的 B4,那就没有增量价值
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SL = 2.0
|
||||
|
||||
|
||||
def stats(g: pd.DataFrame) -> dict:
|
||||
"""反手口径的统计。f_* 是 step55 里用相反方向重跑出场得到的。"""
|
||||
net = g.f_g.values - fee_of(g.f_r.values, g.f_c.values)
|
||||
gR = g.f_g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.f_r.values, g.f_c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
d = pd.read_feather(SRC)
|
||||
if "f_g" not in d.columns:
|
||||
print("step55 数据里没有反手列,先重跑 step55")
|
||||
return
|
||||
d["date_ext"] = pd.to_datetime(d["date_ext"])
|
||||
one = d[d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
for tf, x in one.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 一类反手 ##########")
|
||||
|
||||
print("\n【逐币】任何一个币独自撑起来的结果都不能要")
|
||||
print(pd.DataFrame([{"币": s, **stats(g)}
|
||||
for s, g in x.groupby("sym")]).to_string(index=False))
|
||||
|
||||
print("\n【分时段】按信号时间中位数切两半,后半段是时间外样本")
|
||||
cut = x.date_ext.median()
|
||||
for nm, g in (("前半", x[x.date_ext <= cut]), ("后半", x[x.date_ext > cut])):
|
||||
r = stats(g)
|
||||
print(f" {nm} {g.date_ext.min():%Y-%m-%d}~{g.date_ext.max():%Y-%m-%d}"
|
||||
f" {r}")
|
||||
|
||||
print("\n【分方向】B1反手=做空,S1反手=做多。只有一边成立就是单边行情")
|
||||
print(pd.DataFrame([{"原类型": t, **stats(g)}
|
||||
for t, g in x.groupby("type")]).to_string(index=False))
|
||||
|
||||
print("\n【逐年】")
|
||||
rows = []
|
||||
for y, g in x.groupby(x.date_ext.dt.year):
|
||||
if len(g) >= 40:
|
||||
rows.append({"年": y, **stats(g)})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""结构止损:一类做多是被「止损太紧」打死的,还是趋势真的继续?
|
||||
|
||||
用户的观察:B1 做多的入场价 P 在结构低点 L 上方约 1.68 ATR,而固定止损是
|
||||
P − 2 ATR —— **只在 L 下方 0.32 ATR**。缠论里 B2 正是「回踩不破 L」的那次
|
||||
机会,属于预期之内的正常回抽。止损只留 0.32 ATR,一根插针就打掉,而那恰恰
|
||||
是该加仓的位置。
|
||||
|
||||
这与「趋势继续向下」是两个不同的失败模式,且**预测相反**:
|
||||
|
||||
止损太紧 把止损放到 L 下方足够远 -> 多头应被救活
|
||||
趋势继续 放宽止损只是亏得更多,且反手做空应持续为正
|
||||
|
||||
本脚本用逐笔的结构止损(挂在 L 外侧 margin 个 ATR)重跑,直接区分这两者。
|
||||
`walk_exits` 只支持全局固定止损,故这里自带模拟器;出场结构与实盘一致:
|
||||
减半于 SCALE_AT、剩余半仓目标 RUNNER、剩余半仓止损回到初始止损、MAXB 超时。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from lib.data import fetch_ohlcv # noqa: E402
|
||||
from lib.exit_model import fee_of, taker_notional # noqa: E402
|
||||
|
||||
SRC = HERE / "out" / "step55_bsp12.feather"
|
||||
SCALE_AT, RUNNER, MAXB = 3.0, 8.0, 48
|
||||
|
||||
|
||||
def simulate(cdf: pd.DataFrame, trades: pd.DataFrame,
|
||||
stop_atr: np.ndarray, r_scale: bool = True) -> pd.DataFrame:
|
||||
"""逐笔止损宽度的出场模拟。stop_atr 是每笔各自的初始止损(ATR 倍数)。
|
||||
|
||||
与 `lib.exit_model.walk_exits` 的结构对齐:先减半仓于 SCALE_AT,剩余半仓
|
||||
看 RUNNER 或回到初始止损;未减仓则整仓在止损/超时了结。同根内止损优先于
|
||||
获利目标 —— 分辨不了根内先后时,按不利的一侧算,不给回测送分。
|
||||
|
||||
`r_scale=True` 时目标按 **R 倍数**而非 ATR 固定值放置:实盘的 2/3/8 等于
|
||||
在 1.5R 减半、4R 收尾,放宽止损却不放大目标会把盈亏比压到 1 以下,
|
||||
那样测出来的「放宽无效」是自证的。默认按 R 等比放大才是公平对照。
|
||||
"""
|
||||
k_scale = SCALE_AT / 2.0 if r_scale else None
|
||||
k_run = RUNNER / 2.0 if r_scale else None
|
||||
high = cdf["high"].to_numpy(float)
|
||||
low = cdf["low"].to_numpy(float)
|
||||
open_ = cdf["open"].to_numpy(float)
|
||||
close = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
out = []
|
||||
ent = trades.entry_idx.astype(int).to_numpy()
|
||||
dirs = trades.direction.astype(int).to_numpy()
|
||||
atrs = trades.atr_at_entry.to_numpy(float)
|
||||
for k in range(len(trades)):
|
||||
s, d, a, sl = ent[k], dirs[k], atrs[k], stop_atr[k]
|
||||
e = s + 1
|
||||
if e >= n - 1 or not np.isfinite(sl) or sl <= 0 or not np.isfinite(a):
|
||||
out.append((np.nan, "skip", 0, 0))
|
||||
continue
|
||||
tgt = sl * k_scale if r_scale else SCALE_AT
|
||||
run = sl * k_run if r_scale else RUNNER
|
||||
entry = open_[e]
|
||||
end = min(e + MAXB, n - 1)
|
||||
half_done = False
|
||||
g, why, bars = None, "timeout", end - e
|
||||
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: # 同根内止损优先
|
||||
g = ((-sl * a) / entry) * (0.5 if half_done else 1.0)
|
||||
if half_done:
|
||||
g += (tgt * a / entry) * 0.5
|
||||
why, bars = "stop", j - e
|
||||
break
|
||||
if not half_done and adv >= tgt:
|
||||
half_done = True
|
||||
if half_done and adv >= run:
|
||||
g = (tgt * 0.5 + run * 0.5) * a / entry
|
||||
why, bars = "target", j - e
|
||||
break
|
||||
if g is None:
|
||||
px = close[end]
|
||||
r = (px - entry) * d / entry
|
||||
g = (tgt * a / entry) * 0.5 + r * 0.5 if half_done else r
|
||||
why, bars = ("timeout_half" if half_done else "timeout"), end - e
|
||||
out.append((g, why, int(half_done), bars))
|
||||
return pd.DataFrame(out, columns=["g", "r", "c", "b"])
|
||||
|
||||
|
||||
def stats(g: pd.Series, r: pd.Series, c: pd.Series, atr_pct: np.ndarray,
|
||||
sl_used: np.ndarray) -> dict:
|
||||
net = g.values - fee_of(r.values, c.values)
|
||||
# R 用每笔自己的止损宽度归一,否则宽止损会被系统性低估风险
|
||||
denom = sl_used * atr_pct
|
||||
R = net / denom
|
||||
tn = taker_notional(r.values, c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"净均R": round(np.nanmean(R), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(np.nanmean(R) / (np.nanstd(R, ddof=1)
|
||||
/ np.sqrt(len(R))), 2),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
# step65 发现线段中位幅度只有 11.4 ATR,而 r_scale 会把 3.9 ATR 的结构止损
|
||||
# 对应的 runner 目标推到 15.6 ATR —— 比整段行情还长,永远打不到。
|
||||
# 本脚本首轮全程用了 r_scale=True,等于「放宽止损」和「目标够不着」同时生效,
|
||||
# 两个效应互相抵消,那轮的结论无效。绝对目标才是与 11.4 ATR 相容的口径。
|
||||
ap.add_argument("--abs-targets", action="store_true",
|
||||
help="目标位用绝对 ATR(3/8)而非按止损等比放大")
|
||||
args = ap.parse_args()
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
if "ext_px" not in d.columns:
|
||||
print("step55 数据缺 ext_px/entry_px,先重跑 step55")
|
||||
return
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
|
||||
print(f"[结构止损] {args.tf} · 一类 {len(d)} 笔\n")
|
||||
print("=" * 92)
|
||||
print("########## 一、几何:固定 2ATR 止损离结构位有多远 ##########")
|
||||
# 入场价到结构极值的距离,用入场根的 ATR 归一(与 walk_exits 同口径)
|
||||
d["to_ext"] = (d.entry_px - d.ext_px) * d.dir / d.atr_at_entry
|
||||
d["margin_2atr"] = 2.0 - d.to_ext
|
||||
print(f"入场价到结构极值 (ATR) 中位 {d.to_ext.median():.2f} "
|
||||
f"P25 {d.to_ext.quantile(.25):.2f} P75 {d.to_ext.quantile(.75):.2f}")
|
||||
print(f"2ATR 止损在结构位外侧留的余地 (ATR) 中位 "
|
||||
f"{d.margin_2atr.median():.2f}")
|
||||
inside = (d.margin_2atr <= 0).mean()
|
||||
print(f"**止损落在结构位以内(还没到极值就被打掉)的占比:{inside*100:.1f}%**")
|
||||
print(" —— 这部分交易,价格连回踩到前低都不用,就已经出局了。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、把止损挂到结构位外侧,多头能不能救活 ##########")
|
||||
print("margin = 止损挂在结构极值外侧几个 ATR。对照组是现行的固定 2 ATR。")
|
||||
from chanlun import TF_DF
|
||||
rows = []
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[(sym, args.tf)] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print(f"目标位口径:{'绝对 3/8 ATR(装得进 11.4 ATR 的线段)' if args.abs_targets else '随止损等比放大(恒定 1.5R/4R)'}")
|
||||
for flip in (False, True):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.25", 0.25),
|
||||
("结构+0.5", 0.5), ("结构+1.0", 1.0),
|
||||
("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in d.groupby("sym"):
|
||||
cdf = cache[(sym, args.tf)]
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({
|
||||
"entry_idx": x.i_sure.values,
|
||||
"direction": (-x.dir.values if flip else x.dir.values),
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
# 反手时结构位在**盈利**方向,不能拿它当止损;止损改挂在
|
||||
# 入场价另一侧同样宽度处,否则两组比的不是同一个东西
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cdf, t, sl, r_scale=not args.abs_targets)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({"方向": "反手" if flip else "原方向", "止损": label,
|
||||
"中位宽度ATR": round(float(np.median(
|
||||
np.concatenate(sl_))), 2),
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「止损太紧」是主因,放宽到结构位外侧应让原方向 PF 越过 1。"
|
||||
"\n若原方向放宽后仍在 1 以下、而反手始终显著为正,"
|
||||
"说明趋势确实在继续,\n那么该做的是反手,加宽止损只是少亏一点。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""因果性回放:一类反手的信号在当时真的发得出来吗?
|
||||
|
||||
§3.395 的 PF 2.3~3.0 建立在全量数据一次算完的 `bsp_list` 上。但增量模块的
|
||||
文件头自己写着「笔必须整表重扫:**最后一笔 is_sure 允许收回**」,§5.41 也记了
|
||||
中枢右边缘会重画。若信号是事后才浮现的,那个 PF 就是幻觉。
|
||||
|
||||
**做法**:用 `init_stream` 预热,随后逐根 `append_bar`,每根之后重算
|
||||
`cal_bi_zs_list_pure` + `find_all_bsp`,记录每个信号**第一次出现**在哪一根。
|
||||
入场用那一根(的次根开盘),而不是事后的 `sure_time` —— 实盘只能这样。
|
||||
|
||||
必须逐根,不能分段重建:在第 t 根用 `data[0:t+S]` 重算等于多给了 S 根的信息,
|
||||
测出来的因果性是假的。
|
||||
|
||||
**三个要看的量**
|
||||
召回 全量算出的信号,有多少在回放中真的出现过(没出现的是事后才浮现)
|
||||
幻影 回放中出现、但全量里没有的(当时发了、后来被重画掉)
|
||||
代价 回放首现根 vs 全量 sure_time 的滞后;以及按首现根入场的实际 PF
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step59_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WANT = {"B1": 1, "S1": -1}
|
||||
|
||||
|
||||
def sig_key(b) -> tuple | None:
|
||||
"""信号身份用「类型 + 极值 KLC 的结束时刻」。
|
||||
|
||||
不能用 sure_time 当身份:它正是会被重画的字段,用它做键会把同一个信号
|
||||
在不同根上算成两个。极值点稳定得多。
|
||||
"""
|
||||
t = getattr(b.type, "name", str(b.type))
|
||||
if t not in WANT:
|
||||
return None
|
||||
return (t, str(b.klc.end_time))
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
print(f" {sym} {tf} 数据不足 {0 if df is None else len(df)}", flush=True)
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:一次算完,作为对照 ----
|
||||
full = TF_DF(df, 1, tf, lean=False)
|
||||
fz = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
full_sig = {}
|
||||
for b in (full.find_all_bsp(full.bi_list, fz) or []):
|
||||
k = sig_key(b)
|
||||
if k and b.sure_time is not None:
|
||||
full_sig[k] = str(b.sure_time)
|
||||
|
||||
# ---- 回放口径:逐根追加,记录首现根 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf, lean=False)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zs = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
bsp = chan.find_all_bsp(chan.bi_list, zs) if zs else []
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for b in (bsp or []):
|
||||
k = sig_key(b)
|
||||
if k and k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
dser = pd.to_datetime(full.dataframe["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for k in set(full_sig) | set(first_seen):
|
||||
t, ext_t = k
|
||||
i_seen = first_seen.get(k)
|
||||
# 只统计回放窗口内的:预热段的信号本来就不在考察范围
|
||||
i_ext = to_i(ext_t)
|
||||
if i_ext < warm - 200:
|
||||
continue
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": t, "dir": WANT[t],
|
||||
"in_full": k in full_sig, "in_replay": i_seen is not None,
|
||||
"i_ext": i_ext,
|
||||
"i_seen": -1 if i_seen is None else i_seen,
|
||||
"i_sure_full": to_i(full_sig[k]) if k in full_sig else -1,
|
||||
})
|
||||
r = pd.DataFrame(rec)
|
||||
if r.empty:
|
||||
return None
|
||||
|
||||
# 按回放首现根入场,跑与实盘一致的出场
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
cdf = full.dataframe
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
live = r[r.in_replay & (r.i_seen < len(cdf) - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
# 反手:§3.395 判定该反着做
|
||||
t_ = pd.DataFrame({"entry_idx": live.i_seen.values,
|
||||
"direction": -live.dir.values})
|
||||
res = walk_exits(cdf, t_, [SL], [RUNNER], [MAXB], scale_at=SCALE_AT,
|
||||
runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = atr[live.i_seen.values] / cl[live.i_seen.values]
|
||||
r = r.merge(live[["i_ext", "type", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "type"], how="left")
|
||||
return r
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、召回与幻影 ##########")
|
||||
rows = []
|
||||
for (tf, t), x in d.groupby(["tf", "type"]):
|
||||
full = x[x.in_full]
|
||||
rep = x[x.in_replay]
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf, "类型": t,
|
||||
"全量信号": len(full), "回放信号": len(rep),
|
||||
"召回": f"{len(both)/max(len(full),1)*100:.1f}%",
|
||||
"事后才浮现": len(full) - len(both),
|
||||
"幻影(被重画掉)": len(rep) - len(both),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n召回 = 全量算出的信号里,回放中真的出现过的比例。"
|
||||
"\n幻影 = 回放中发过、全量里却没有的 —— 实盘会照做,回测却看不见它。")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、时点代价:回放首现 vs 全量 sure_time ##########")
|
||||
b = d[d.in_full & d.in_replay].copy()
|
||||
b["delay"] = b.i_seen - b.i_sure_full
|
||||
for tf, x in b.groupby("tf"):
|
||||
q = x.delay.quantile([.25, .5, .75, .9])
|
||||
print(f" {tf} 中位 {q[.5]:+.0f} 根 P25 {q[.25]:+.0f} "
|
||||
f"P75 {q[.75]:+.0f} P90 {q[.9]:+.0f} "
|
||||
f"| 早于或等于全量的占比 {(x.delay <= 0).mean()*100:.0f}%")
|
||||
print(" 正值 = 回放比全量晚知道,实盘要在更差的价位入场。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 三、真正能落地的收益:按回放首现根入场(反手)##########")
|
||||
x = d.dropna(subset=["g"]).copy()
|
||||
rows = []
|
||||
for (tf, t), g in x.groupby(["tf", "type"]):
|
||||
if len(g) < 30:
|
||||
continue
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"tf": tf, "类型(反手)": t, "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
})
|
||||
if rows:
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n对照 · §3.395 全量口径 5m:B1反手 PF 2.35 / S1反手 2.75")
|
||||
print("若这里明显掉下来,说明那个 PF 吃了右边缘重画的红利,不可落地。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=60_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=10_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [x.strip() for x in args.symbols.split(",")]
|
||||
print(f"[因果回放] {len(syms)} 币 × {args.steps} 根逐根重放"
|
||||
f"(每根都要重算笔中枢与 bsp,慢是必然的)\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""一类买卖点到底有没有找到真正的趋势反转点?用线段顶点当标准答案。
|
||||
|
||||
用户提出:线段的终点就是该级别的趋势反转点,虽然它是未来函数,但可以拿来当
|
||||
**标签**验证检测器,而不是拿来交易。这能把两件事分开:
|
||||
|
||||
检测器对不对 B1 是否真的落在趋势反转底上
|
||||
能不能交易 step59 已证否(实盘首现比 sure_time 晚中位 15 根,PF 塌到 0.92)
|
||||
|
||||
若检测器对而只是慢,那问题是延迟,还有救;若检测器本身就没找到反转点,
|
||||
这条线整个是死的。
|
||||
|
||||
⚠️ **对照组是这个测试的全部意义**。B1 按构造就长在笔的低点上,而笔低点本来
|
||||
就有一定概率撞上线段底。所以要问的不是「B1 命中率多少」,而是
|
||||
**「在所有同向笔端点里,B1 这个标签把命中率提高了多少倍」**。
|
||||
没有这个基准,任何绝对数字都可以随便解读。
|
||||
(同样的坑 fast_bsp 文档头踩过:回抽极值命中笔端点 19.3%,看着不低,
|
||||
但随机基准是 22%,其实是负贡献。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step60_seg_truth.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
TOL = [0, 1, 2, 3, 5]
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
# ---- 标准答案:线段终点。下降线段终点=真底,上升线段终点=真顶 ----
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int, tol: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
for j in (k - 1, k):
|
||||
if 0 <= j < len(a) and abs(int(a[j]) - i) <= tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
rec = []
|
||||
# ---- 对照组:所有笔端点。B1 本就长在笔低点上,基准必须同源 ----
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
d = 1 if str(bi.dir).endswith("DOWN") else -1 # 下降笔终点=低点
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n):
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": d, "i_ext": i, "i_sure": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
for tol in TOL:
|
||||
r[f"hit{tol}"] = [near(i, d, tol)
|
||||
for i, d in zip(r.i_ext, r.dir)]
|
||||
|
||||
# 命中线段顶点的那批一类,按原方向(抄底)做能不能赚
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
sig = r[(r.kind.isin(["B1", "S1"])) & (r.i_sure >= 0)
|
||||
& (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "atr_pct"]],
|
||||
on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 92)
|
||||
print(f"########## {tf} · 线段顶点作为标准答案 ##########")
|
||||
print("\n【命中率】i_ext 落在同向线段终点 ±tol 根内的比例")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind, "样本": len(g)}
|
||||
for tol in TOL:
|
||||
row[f"±{tol}根"] = f"{g[f'hit{tol}'].mean()*100:.1f}%"
|
||||
rows.append(row)
|
||||
t = pd.DataFrame(rows)
|
||||
print(t.to_string(index=False))
|
||||
|
||||
base = x[x.kind == "笔端点"]
|
||||
print("\n【提升倍数】相对「所有同向笔端点」这个基准。"
|
||||
"≈1 就是没有信息量")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
row = {"信号": kind}
|
||||
for tol in TOL:
|
||||
b = base[base.dir.isin(g.dir.unique())][f"hit{tol}"].mean()
|
||||
row[f"±{tol}根"] = (round(g[f"hit{tol}"].mean() / b, 2)
|
||||
if b > 0 else np.nan)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【命中 vs 未命中】一类按原方向(抄底/摸顶)做的表现,"
|
||||
"tol=±2 根")
|
||||
y = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"]).copy()
|
||||
if len(y) < 60:
|
||||
continue
|
||||
rows = []
|
||||
for hit, g in y.groupby(y.hit2):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"命中线段顶点": "是" if hit else "否", "笔数": len(g),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1)
|
||||
/ np.sqrt(len(g))), 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「命中」那组按原方向做显著为正,说明检测器是对的、"
|
||||
"只是掺了太多噪声,\n值得找实时可判的过滤器;若两组都为负,"
|
||||
"说明即使真站在线段底上,\n这个入场时点也已经太晚了。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""极值点的波动率:它是不是既拖慢了确认,又让目标够不着?
|
||||
|
||||
用户的观察:一二类买卖点长在极值点上,那个区域波动天然很大,这可能正是确认
|
||||
慢的原因。
|
||||
|
||||
这个假设若成立会同时解释两件事,而且机制不同:
|
||||
|
||||
确认慢 波动大 -> 分型/笔要更多根才稳定下来 -> sure_time 更晚
|
||||
赚不到 ATR 被造成极值的那根插针抬高 -> 止损 2×虚高ATR 其实宽松,
|
||||
但目标 3/8 ATR 变得够不着,因为入场后波动率会均值回复下来
|
||||
|
||||
第二条尤其要紧:它意味着交易不是「被打掉」,而是「永远走不到目标」,
|
||||
与 §3.397 判定的「趋势继续」是**不同的失败模式**,应对办法也不同
|
||||
(该换波动率口径,而不是换方向)。
|
||||
|
||||
三个量:
|
||||
atr_z 极值处 ATR / 该点之前 200 根的中位 ATR —— 是否真的偏高
|
||||
atr_fwd 入场后 48 根的平均 ATR / 入场时 ATR —— 是否均值回复
|
||||
与滞后相关 atr_z 高的信号,lag_bars 是否更长
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step61_vol.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN = 200
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
# 基准 ATR 只用**该点之前**的窗口,含当根会把要检验的那根插针算进去
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
rec = []
|
||||
# 对照组:所有笔端点,与 §3.397 同源
|
||||
for bi in chan.bi_list:
|
||||
if not getattr(bi, "is_sure", False) or bi.end_klc is None:
|
||||
continue
|
||||
i = to_i(bi.end_klc.end_time)
|
||||
if not (0 <= i < n) or not np.isfinite(base[i]) or base[i] <= 0:
|
||||
continue
|
||||
rec.append({"kind": "笔端点", "dir": 0, "i_ext": i, "i_sure": -1,
|
||||
"atr_z": atr[i] / base[i], "lag_bars": -1})
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1),
|
||||
Chan_BSP_TYPE.B3: ("B3", 1), Chan_BSP_TYPE.S3: ("S3", -1)}
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
if not np.isfinite(base[i_ext]) or base[i_ext] <= 0:
|
||||
continue
|
||||
rec.append({"kind": name, "dir": d, "i_ext": i_ext,
|
||||
"i_sure": i_sure, "atr_z": atr[i_ext] / base[i_ext],
|
||||
"lag_bars": i_sure - i_ext})
|
||||
|
||||
r = pd.DataFrame(rec)
|
||||
# 入场后的实现波动率:目标够不够得着,取决于入场**之后**的 ATR
|
||||
ok = r.i_sure >= 0
|
||||
fwd = np.full(len(r), np.nan)
|
||||
for k, i in zip(np.where(ok)[0], r.i_sure[ok].values):
|
||||
j = min(int(i) + 1 + MAXB, n)
|
||||
if j > int(i) + 1 and atr[int(i)] > 0:
|
||||
fwd[k] = np.nanmean(atr[int(i) + 1:j]) / atr[int(i)]
|
||||
r["atr_fwd"] = fwd
|
||||
|
||||
sig = r[ok & (r.i_sure < n - 2)].copy()
|
||||
sig = sig[np.isfinite(atr[sig.i_sure.values])
|
||||
& (atr[sig.i_sure.values] > 0)]
|
||||
if not sig.empty:
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": sig.i_sure.values,
|
||||
"direction": sig.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(sig):
|
||||
for c in ("g", "r", "c", "b"):
|
||||
sig[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
sig["atr_pct"] = (atr[sig.i_sure.values]
|
||||
/ cl[sig.i_sure.values])
|
||||
r = r.merge(sig[["i_ext", "kind", "g", "r", "c", "b",
|
||||
"atr_pct"]], on=["i_ext", "kind"], how="left")
|
||||
r["sym"], r["tf"] = sym, tf
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · 极值点的波动率 ##########")
|
||||
|
||||
print("\n【一】极值处 ATR 是不是真的偏高(atr_z = 当点ATR / 前200根中位ATR)")
|
||||
rows = []
|
||||
for kind in ["笔端点", "B1", "S1", "B2", "S2", "B3", "S3"]:
|
||||
g = x[x.kind == kind]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"P75": round(g.atr_z.quantile(.75), 3),
|
||||
"P90": round(g.atr_z.quantile(.90), 3),
|
||||
"高于基准占比": f"{(g.atr_z > 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】波动大是不是确认更慢(按 atr_z 四分位看 lag_bars)")
|
||||
y = x[x.kind.isin(["B1", "S1"]) & (x.lag_bars >= 0)].copy()
|
||||
if len(y) >= 100:
|
||||
q = pd.qcut(y.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
print(pd.DataFrame([{
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"滞后中位": round(g.lag_bars.median(), 1),
|
||||
"滞后均值": round(g.lag_bars.mean(), 1),
|
||||
} for k, g in y.groupby(q, observed=True)]).to_string(index=False))
|
||||
c = np.corrcoef(y.atr_z, y.lag_bars)[0, 1]
|
||||
print(f" 相关系数 corr(atr_z, lag_bars) = {c:+.3f}")
|
||||
print(" 正相关支持「波动大 -> 确认慢」;接近 0 则该假设不成立。")
|
||||
|
||||
print("\n【三】入场后波动率是否回落(atr_fwd = 后48根平均ATR / 入场ATR)")
|
||||
rows = []
|
||||
for kind in ["B1", "S1", "B3", "S3"]:
|
||||
g = x[(x.kind == kind)].dropna(subset=["atr_fwd"])
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"信号": kind, "样本": len(g),
|
||||
"atr_fwd中位": round(g.atr_fwd.median(), 3),
|
||||
"回落占比(<1)": f"{(g.atr_fwd < 1).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(" <1 = 入场后波动率比入场那刻低,则按入场ATR定的 3/8 ATR 目标"
|
||||
"\n 在绝对价格上被高估,会系统性够不着。")
|
||||
|
||||
if "g" not in x.columns:
|
||||
continue
|
||||
print("\n【四】目标够不着的证据:出场原因分布(一类,按原方向)")
|
||||
z = x[x.kind.isin(["B1", "S1"])].dropna(subset=["g"])
|
||||
if len(z) >= 60:
|
||||
print((z.r.value_counts(normalize=True) * 100).round(1)
|
||||
.to_frame("占比%").to_string())
|
||||
print(f" 中位持仓 {z.b.median():.0f} 根 / 上限 {MAXB} 根")
|
||||
|
||||
print("\n【五】按 atr_z 分档看一类表现(原方向)")
|
||||
q = pd.qcut(z.atr_z, 4, labels=["Q1低", "Q2", "Q3", "Q4高"],
|
||||
duplicates="drop")
|
||||
rows = []
|
||||
for k, g in z.groupby(q, observed=True):
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
rows.append({
|
||||
"atr_z档": k, "笔数": len(g),
|
||||
"atr_z中位": round(g.atr_z.median(), 2),
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""怎么把「趋势末端的一类」从「趋势中途的一类」里挑出来。
|
||||
|
||||
§3.397 的关键数字:一类里命中线段顶点(真反转)的只有 30%,那 30% 即使带着
|
||||
8~9 根滞后也有 PF 0.70~0.83;**没命中的 70% 是 PF 0.08**。
|
||||
所以亏损几乎全部来自被误识别在趋势中途的那批 —— 用户的判断。
|
||||
|
||||
于是问题变成:有没有**实时可算**的特征能把两批分开。
|
||||
|
||||
**首要候选来自缠论本身**:一类买点要求的是**趋势背驰**,而趋势的定义是
|
||||
「至少两个同向连续的中枢」。引擎的 `find_all_bsp` 对**任意**中枢都发信号,
|
||||
完全没查这个前提 —— 单个盘整中枢上的「背驰」只是盘整背驰,本就不该当一类用。
|
||||
`fast_bsp.add_zone_ladder` 早就实现了这个判定(B4 上把 PF 2.72 提到 3.41),
|
||||
一类这边却没接。
|
||||
|
||||
测的特征全部只用信号时刻及之前的数据:
|
||||
|
||||
ladder 本中枢相对前一中枢是否同向推进(下降趋势要求 zg < 前一个 zd)
|
||||
zs_count 该中枢在本段里的序号,越大趋势越成熟
|
||||
div 离开段 MACD 面积 / 进入段面积,越小背驰越强
|
||||
ext_run 极值越过中枢边界多少个 ATR,越大越延伸
|
||||
atr_z 极值处波动率(§3.398)
|
||||
|
||||
评判分两层:**能否提高命中线段顶点的概率**(检测器精度),
|
||||
以及**能否提高实际收益**(可交易性)。前者好后者不好也没用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step62_trend_end.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
# 标准答案:线段终点(未来函数,只当标签用,不进入任何过滤器)
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 中枢阶梯:按可用顺序排好,才谈得上「相对前一个」
|
||||
zs_seq = sorted(bz, key=lambda z: to_i(z.bi_list[0].start_time))
|
||||
pos = {id(z): k for k, z in enumerate(zs_seq)}
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None or b.zs is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
zs = b.zs
|
||||
k = pos.get(id(zs))
|
||||
# 趋势成熟度:本中枢往前数,连续同向推进的中枢有几个。
|
||||
# 单看「相对前一个是否同向」没有区分力 —— B1 要求
|
||||
# enter_bi.dir == leave_bi.dir == DOWN,即中枢向下进、向下出,
|
||||
# 这本身就定义了它嵌在下跌趋势里,连续纯中枢自然逐级下移,
|
||||
# 实测该条件在 2504 笔上恒为 True。**引擎已隐含强制了「趋势」前提。**
|
||||
# 有区分力的是「连了几级」,那才是趋势成熟度。
|
||||
ladder_n = 0
|
||||
if k is not None:
|
||||
j = k
|
||||
while j > 0:
|
||||
cur, prv = zs_seq[j], zs_seq[j - 1]
|
||||
ok = (float(cur.zg) < float(prv.zd) if d == 1
|
||||
else float(cur.zd) > float(prv.zg))
|
||||
if not ok:
|
||||
break
|
||||
ladder_n += 1
|
||||
j -= 1
|
||||
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
|
||||
ea = abs(float(enter_bi.macd_hist)) if enter_bi is not None else np.nan
|
||||
la = abs(float(b.bi.macd_hist))
|
||||
edge = float(zs.zd) if d == 1 else float(zs.zg)
|
||||
ext = float(b.klc.low if d == 1 else b.klc.high)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"ladder_n": int(ladder_n),
|
||||
"div": la / ea if (ea and np.isfinite(ea) and ea > 0) else np.nan,
|
||||
"ext_run": abs(ext - edge) / a,
|
||||
"atr_z": a / base[i_ext],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values, "direction": r.dir.values}),
|
||||
[SL], [RUNNER], [MAXB], scale_at=SCALE_AT, runners=(RUNNER,),
|
||||
runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "命中率": f"{g.hit.mean()*100:.1f}%",
|
||||
"胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} · {len(x)} 笔一类 ##########")
|
||||
|
||||
print("\n【一】单特征对「命中线段顶点」的区分力(命中率基准 "
|
||||
f"{x.hit.mean()*100:.1f}%)")
|
||||
rows = []
|
||||
for nm, col, qs in [("背驰div", "div", 4), ("延伸ext_run", "ext_run", 4),
|
||||
("波动atr_z", "atr_z", 4), ("趋势级数ladder_n", "", 0)]:
|
||||
if not col:
|
||||
q = pd.cut(x["ladder_n"], [-1, 1, 2, 3, 999],
|
||||
labels=["级数≤1", "=2", "=3", "≥4"])
|
||||
for k, g in x.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
continue
|
||||
y = x.dropna(subset=[col])
|
||||
if len(y) < 100:
|
||||
continue
|
||||
q = pd.qcut(y[col], qs,
|
||||
labels=[f"{nm}Q{i+1}" for i in range(qs)],
|
||||
duplicates="drop")
|
||||
for k, g in y.groupby(q, observed=True):
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": str(k), **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n【二】叠加过滤:延伸是唯一单调的特征,看叠加还能不能推上去")
|
||||
rows = [{"过滤器": "无(现状)", **perf(x)}]
|
||||
e75 = x["ext_run"].quantile(.75)
|
||||
e50 = x["ext_run"].quantile(.50)
|
||||
for nm, g in [
|
||||
(f"ext_run≥P50({e50:.1f})", x[x["ext_run"] >= e50]),
|
||||
(f"ext_run≥P75({e75:.1f})", x[x["ext_run"] >= e75]),
|
||||
(f"ext_run≥P75 且 级数≥3",
|
||||
x[(x["ext_run"] >= e75) & (x["ladder_n"] >= 3)]),
|
||||
(f"ext_run≥P75 且 div≥中位",
|
||||
x[(x["ext_run"] >= e75) & (x["div"] >= x["div"].median())]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"过滤器": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:命中率若被显著抬高,说明特征确实在区分「趋势末端 vs 中途」。"
|
||||
"\n但 PF 才是能不能做的判据 —— 命中率上去而 PF 不过 1,"
|
||||
"说明滞后仍然吃掉了全部。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_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():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""B4 主线的因果回放:实盘信号的时点是不是干净的。
|
||||
|
||||
step59 在一类上抓到一个会骗人的坑:`sure_time` 是引擎**事后**标注的确认时刻,
|
||||
不等于可执行时刻,用它当 entry 的回测 PF 虚高一倍以上。B4 正在跑真钱,
|
||||
必须过同一关。
|
||||
|
||||
**先验比一类好,但方向要说清**(§5.41 的代码审计):
|
||||
|
||||
一类 `sure_time` 直接当**入场时刻** -> 早了就是虚高,回测被高估
|
||||
B4 `available_ts` 只是**扫描起点** -> 晚了只会漏信号,回测偏保守
|
||||
|
||||
§5.41 实测全量的 `available_ts` 系统性**更晚**(`bis[-1]` 取的是中枢结束而非
|
||||
形成,中位晚 62 分钟)。所以预期是「回测保守」而非「回测虚高」。但那是 300
|
||||
时点抽样 + 代码审计,不是逐根验证,而且留了个未知:
|
||||
**实盘会产出更多、更早的信号,那部分的质量不在回测统计里。**
|
||||
|
||||
本脚本逐根重放,同时量两边:
|
||||
|
||||
准时率 全量信号在其 entry_idx 当根就能算出来的比例
|
||||
迟到 首现晚于 entry_idx 的,实盘只能在更差的价位追
|
||||
额外信号 回放发得出、全量却没有的 —— §5.41 预言存在,但没人统计过它们赚不赚
|
||||
|
||||
⚠️ 性能:每根扫全部中枢跑不完。一个中枢只能在其 available_ts 之后 scan 根内
|
||||
出信号,所以每根只需把窗口内的中枢喂给 `find_fast_bsp3`。这是等价裁剪,
|
||||
不改变结果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step63_b4_replay.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
SCAN = 200
|
||||
|
||||
|
||||
def replay(sym: str, tf: str, rows: int, warm: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < warm + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(warm + steps):].reset_index(drop=True)
|
||||
|
||||
# ---- 全量口径:回测就是这么算的 ----
|
||||
full = TF_DF(df, 1, tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# ---- 回放口径:逐根重算 zones 再扫 ----
|
||||
chan = TF_DF(df.iloc[:warm].copy(), 1, tf)
|
||||
chan.init_stream(df.iloc[:warm].copy(), 1, tf)
|
||||
first_seen: dict[tuple, int] = {}
|
||||
for i in range(warm, len(df)):
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z.empty:
|
||||
continue
|
||||
# 等价裁剪:available_ts 早于 scan 根之前的中枢,其扫描窗口
|
||||
# 已经过去,不可能在本根产出新信号
|
||||
lo = sub["timestamp"].to_numpy()[max(0, len(sub) - SCAN - 2)]
|
||||
z = z[z.available_ts >= lo]
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z, scan=SCAN)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (int(r.entry_idx), int(r.direction))
|
||||
if k not in first_seen:
|
||||
first_seen[k] = i
|
||||
|
||||
rec = []
|
||||
for k in set(full_keys) | set(first_seen):
|
||||
e, d = k
|
||||
if e < warm: # 预热段不计入
|
||||
continue
|
||||
seen = first_seen.get(k)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "entry_idx": e, "direction": d,
|
||||
"in_full": k in full_keys, "in_replay": seen is not None,
|
||||
"i_seen": -1 if seen is None else seen,
|
||||
"late": (np.nan if seen is None else seen - e),
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
|
||||
# 实盘真正会做的:首现根入场(首现==entry_idx 即准时)
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
live = r[r.in_replay & (r.i_seen < n - 2)].copy()
|
||||
live = live[np.isfinite(atr[live.i_seen.values])
|
||||
& (atr[live.i_seen.values] > 0)]
|
||||
if not live.empty:
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": live.i_seen.values,
|
||||
"direction": live.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) == len(live):
|
||||
for c in ("g", "r", "c"):
|
||||
live[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
live["atr_pct"] = (atr[live.i_seen.values]
|
||||
/ cl[live.i_seen.values])
|
||||
r = r.merge(live[["entry_idx", "direction", "g", "r", "c",
|
||||
"atr_pct"]],
|
||||
on=["entry_idx", "direction"], how="left")
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
R = net / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3), "净均R": round(R.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 一、准时率与额外信号 ##########")
|
||||
rows = []
|
||||
for tf, x in d.groupby("tf"):
|
||||
both = x[x.in_full & x.in_replay]
|
||||
rows.append({
|
||||
"tf": tf,
|
||||
"全量信号": int(x.in_full.sum()),
|
||||
"回放信号": int(x.in_replay.sum()),
|
||||
"召回": f"{len(both)/max(int(x.in_full.sum()),1)*100:.1f}%",
|
||||
"准时(首现==entry)": f"{(both.late == 0).mean()*100:.1f}%",
|
||||
"迟到中位": (f"{both.late[both.late > 0].median():.0f} 根"
|
||||
if (both.late > 0).any() else "—"),
|
||||
"额外信号": int((x.in_replay & ~x.in_full).sum()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n额外信号 = 回放发得出、全量没有的。§5.41 预言它们存在"
|
||||
"(实盘 available_ts 更早 -> 信号更多更早),本表给出数量。")
|
||||
|
||||
if "g" not in d.columns:
|
||||
return
|
||||
print("\n" + "=" * 92)
|
||||
print("########## 二、分组收益:回测口径 vs 实盘口径 ##########")
|
||||
for tf, x in d.groupby("tf"):
|
||||
y = x.dropna(subset=["g"])
|
||||
if len(y) < 30:
|
||||
continue
|
||||
print(f"\n--- {tf} ---")
|
||||
rows = []
|
||||
for nm, g in [
|
||||
("全部回放信号(=实盘会做的)", y[y.in_replay]),
|
||||
("其中 准时的", y[y.in_replay & (y.late == 0)]),
|
||||
("其中 迟到的", y[y.in_replay & (y.late > 0)]),
|
||||
("其中 额外的(全量没有)", y[y.in_replay & ~y.in_full]),
|
||||
("回测口径(全量∩回放)", y[y.in_full & y.in_replay]),
|
||||
]:
|
||||
if len(g) >= 30:
|
||||
rows.append({"分组": nm, **perf(g)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n判读:若「全部回放信号」的 PF 不低于「回测口径」,说明 B4 的时点"
|
||||
"是干净的,\n且 §5.41 说的『回测偏保守』成立 —— 实盘拿到的反而更多。"
|
||||
"\n若额外信号那组显著更差,那就是回测没统计到的隐性成本。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--warm", type=int, default=20_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[B4 因果回放] {len(syms)} 币 × {args.steps} 根逐根重放\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.tf, args.rows, args.warm,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""二类买卖点:它只是一类的延迟版,还是一类里被确认过的那个子集?
|
||||
|
||||
用户提出的悖论:二类按定义是「一买之后回调、再继续趋势」,可一类已经证否了,
|
||||
二类凭什么会好?
|
||||
|
||||
代码上悖论成立——`find_all_bsp` 里 B2 确实被 B1 门控(`if first_bsp_bi_div`)。
|
||||
但 B2 比 B1 多要求两件事:价格**确实反弹了**(bounce_bi 向上),
|
||||
且回踩**守住了** B1 的低点(`second_bsp_bi.end_klc.low > leave_bi.end_klc.low`)。
|
||||
**这两条正是「那个底是真的」的事后确认。**
|
||||
|
||||
而 §3.397 的诊断恰恰是:一类只有 30% 落在真反转上,那 30% 的 PF 是 0.70~0.83,
|
||||
另外 70% 是 0.08。所以「按确认筛掉假底」正是一类缺的东西。
|
||||
|
||||
于是悖论变成一个可测的问题:
|
||||
|
||||
B2 命中线段顶点的比例,是否显著高于 B1 的 30%?
|
||||
|
||||
是 -> B2 是 B1 的**已验证子集**,悖论解除,值得继续查
|
||||
否 -> B2 只是 B1 的延迟版,用户的悖论成立,直接关掉
|
||||
|
||||
同时量三件与一类可比的东西(口径完全对齐,才能横向比):
|
||||
几何 入场价到结构止损位有多远(一类是 2.90 ATR,止损落在结构内侧 88.6%)
|
||||
波动 atr_z(一类 1.22 偏高,二类 0.94 偏低,画像相反)
|
||||
反手 一类反手在全量口径上曾看似很好,二类是否也有这个现象
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step64_b2.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
BASE_WIN, TOL = 200, 2
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 5_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
bz = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not bz:
|
||||
return None
|
||||
bsp = chan.find_all_bsp(chan.bi_list, bz) or []
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
base = (pd.Series(atr).rolling(BASE_WIN, min_periods=50)
|
||||
.median().shift(1).to_numpy())
|
||||
|
||||
seg_bot, seg_top = [], []
|
||||
for sg in getattr(chan, "seg_list", []) or []:
|
||||
if sg.end_time is None:
|
||||
continue
|
||||
i = to_i(sg.end_time)
|
||||
if 0 <= i < n:
|
||||
(seg_bot if sg.dir == Chan_SEG_DIR.DOWN
|
||||
else seg_top).append(i)
|
||||
if not seg_bot or not seg_top:
|
||||
return None
|
||||
truth = {1: np.array(sorted(seg_bot)), -1: np.array(sorted(seg_top))}
|
||||
|
||||
def near(i: int, d: int) -> bool:
|
||||
a = truth[d]
|
||||
k = int(np.searchsorted(a, i))
|
||||
return any(0 <= j < len(a) and abs(int(a[j]) - i) <= TOL
|
||||
for j in (k - 1, k))
|
||||
|
||||
# 一类按中枢建索引,好给二类找到它自己那个一类的低点 ——
|
||||
# 二类的天然止损位是**一类的极值**,不是它自己的极值
|
||||
one_ext: dict[int, float] = {}
|
||||
for b in bsp:
|
||||
if b.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1) and b.zs:
|
||||
d = 1 if b.type == Chan_BSP_TYPE.B1 else -1
|
||||
one_ext[id(b.zs)] = float(b.klc.low if d == 1 else b.klc.high)
|
||||
|
||||
want = {Chan_BSP_TYPE.B1: ("B1", 1), Chan_BSP_TYPE.S1: ("S1", -1),
|
||||
Chan_BSP_TYPE.B2: ("B2", 1), Chan_BSP_TYPE.S2: ("S2", -1)}
|
||||
rec = []
|
||||
for b in bsp:
|
||||
tag = want.get(b.type)
|
||||
if tag is None or b.sure_time is None:
|
||||
continue
|
||||
name, d = tag
|
||||
i_ext, i_sure = to_i(b.klc.end_time), to_i(b.sure_time)
|
||||
if not (0 <= i_ext < n and 0 <= i_sure < n):
|
||||
continue
|
||||
a = atr[i_ext]
|
||||
if not np.isfinite(a) or a <= 0 or not np.isfinite(base[i_ext]):
|
||||
continue
|
||||
own = float(b.klc.low if d == 1 else b.klc.high)
|
||||
# 二类的结构止损用一类的极值(回踩不破的就是那个点);
|
||||
# 一类用自己的极值。这样两者的「入场离结构位多远」才可比
|
||||
struct = own
|
||||
if name in ("B2", "S2") and b.zs is not None:
|
||||
struct = one_ext.get(id(b.zs), own)
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "type": name, "dir": d,
|
||||
"i_ext": i_ext, "i_sure": i_sure,
|
||||
"lag_bars": i_sure - i_ext,
|
||||
"hit": near(i_ext, d),
|
||||
"atr_z": a / base[i_ext],
|
||||
"own_ext": own, "struct_px": struct,
|
||||
"entry_px": op[min(i_sure + 1, n - 1)],
|
||||
})
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(rec)
|
||||
r = r[(r.i_sure < n - 2) & np.isfinite(atr[r.i_sure.values])
|
||||
& (atr[r.i_sure.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
r["atr_at_entry"] = atr[r.i_sure.values]
|
||||
r["atr_pct"] = atr[r.i_sure.values] / cl[r.i_sure.values]
|
||||
r["to_struct"] = ((r.entry_px - r.struct_px) * r.dir
|
||||
/ r.atr_at_entry)
|
||||
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
for sfx, sgn in (("", 1), ("f_", -1)):
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.i_sure.values,
|
||||
"direction": sgn * r.dir.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c", "b"):
|
||||
r[sfx + c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame, pre: str = "") -> dict:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
gg, rr, cc = g[pre + "g"].values, g[pre + "r"].values, g[pre + "c"].values
|
||||
net = gg - fee_of(rr, cc)
|
||||
gR = gg / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(rr, cc)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"毛R": round(gR.mean(), 3),
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
for tf, x in d.groupby("tf"):
|
||||
print("\n" + "#" * 96)
|
||||
print(f"########## {tf} ##########")
|
||||
|
||||
print("\n【一】悖论的判据:二类命中线段顶点的比例是否高于一类")
|
||||
rows = []
|
||||
for t in ["B1", "S1", "B2", "S2"]:
|
||||
g = x[x.type == t]
|
||||
if len(g) < 30:
|
||||
continue
|
||||
rows.append({
|
||||
"类型": t, "样本": len(g),
|
||||
"命中线段顶点": f"{g.hit.mean()*100:.1f}%",
|
||||
"atr_z中位": round(g.atr_z.median(), 3),
|
||||
"滞后中位": int(g.lag_bars.median()),
|
||||
"入场到结构位(ATR)": round(g.to_struct.median(), 2),
|
||||
"止损2ATR落在结构内侧": f"{(g.to_struct > 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 「入场到结构位」是入场价离天然止损位多少个 ATR。二类的结构位取"
|
||||
"\n 它那个一类的极值(回踩不破的就是那点)。>2 表示 2ATR 的止损挂在"
|
||||
"\n 结构位以内,价格不用回踩到前低就出局 —— 一类实测 88.6%。")
|
||||
|
||||
print("\n【二】按原方向做(抄底/摸顶)")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g)}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【三】反手做")
|
||||
print(pd.DataFrame([{"类型": t, **perf(g, "f_")}
|
||||
for t, g in x.groupby("type")
|
||||
if len(g) >= 30]).to_string(index=False))
|
||||
|
||||
print("\n【四】二类拆命中/未命中 —— 一类的对应数字是 0.08 vs 0.70")
|
||||
two = x[x.type.isin(["B2", "S2"])]
|
||||
if len(two) >= 60:
|
||||
print(pd.DataFrame([
|
||||
{"命中线段顶点": "是" if k else "否", **perf(g)}
|
||||
for k, g in two.groupby(two.hit) if len(g) >= 20
|
||||
]).to_string(index=False))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""改识别规则之前,先量机会本身装不装得下确认成本。
|
||||
|
||||
用户问:改 B1/B2 的识别规则行不行?
|
||||
|
||||
「改识别规则」有两种含义,第一种已经被测过上限:
|
||||
(a) 换规则**挑**出更好的 B1 —— step60 用未来函数只留极值落在真线段底 ±2 根内
|
||||
的信号,这是任何识别规则的理论最好情况,PF 仅 0.70~0.83。这一类改法封死。
|
||||
|
||||
但 0.83 这个上限本身是**结果**,背后是一个从没直接测过的量:
|
||||
|
||||
一段线段从真底走到真顶,一共有几个 ATR?而等笔确认要花掉 2.9 个。
|
||||
|
||||
这决定了 (b) 类改法(改**入场时点/构造**,而非改选样)有没有空间:
|
||||
|
||||
线段幅度 4 ATR -> 进场吃掉 2.9,剩 1.1 去扛 2 ATR 止损,**任何规则都救不活**
|
||||
线段幅度 12 ATR -> 2.9 只是小费,值得继续改规则
|
||||
|
||||
注意这测的是**市场**,不是我们的检测器:标准答案直接取 `seg_list` 的真实端点,
|
||||
完全不经过 `find_all_bsp`。所以结论对「换任何一套识别规则」都成立。
|
||||
|
||||
而且这个比值大概率**随级别变化**(ATR 与线段幅度未必同比例缩放),所以跑
|
||||
5m/15m/1h/4h。真正的产出不是「改不改规则」,而是**「在哪个级别上改才有意义」**。
|
||||
|
||||
预算恒等式(每根线段一行):
|
||||
可捕获 = 线段幅度 − 确认成本 需要 > 止损 才有正期望的可能
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step65_room.feather"
|
||||
SL_ATR = 2.0
|
||||
|
||||
|
||||
def collect(sym: str, tf: str, rows: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.core.ChanEnum import Chan_SEG_DIR
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", tf, rows)
|
||||
if df is None or len(df) < 3_000:
|
||||
return None
|
||||
chan = TF_DF(df, 1, tf, lean=False)
|
||||
cdf = chan.dataframe
|
||||
segs = [s for s in (getattr(chan, "seg_list", []) or [])
|
||||
if s.end_time is not None and s.start_time is not None]
|
||||
if len(segs) < 20:
|
||||
return None
|
||||
|
||||
dser = pd.to_datetime(cdf["date"])
|
||||
if dser.dt.tz is not None:
|
||||
dser = dser.dt.tz_localize(None)
|
||||
didx = pd.DatetimeIndex(dser)
|
||||
n = len(cdf)
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
|
||||
def to_i(ts) -> int:
|
||||
t = pd.Timestamp(ts)
|
||||
return int(didx.searchsorted(t.tz_localize(None) if t.tz else t))
|
||||
|
||||
rec = []
|
||||
for sg in segs:
|
||||
i0, i1 = to_i(sg.start_time), to_i(sg.end_time)
|
||||
if not (0 <= i0 < i1 < n) or i1 - i0 < 2:
|
||||
continue
|
||||
a = atr[i0]
|
||||
if not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
up = sg.dir != Chan_SEG_DIR.DOWN
|
||||
# 从线段起点(上一段的真实反转点)到终点的幅度
|
||||
span = ((hi[i0:i1 + 1].max() - lo[i0]) if up
|
||||
else (hi[i0] - lo[i0:i1 + 1].min()))
|
||||
rec.append({
|
||||
"sym": sym, "tf": tf, "dir": 1 if up else -1,
|
||||
"bars": i1 - i0,
|
||||
"span_atr": span / a,
|
||||
"atr_pct": a / float(cdf["close"].to_numpy(float)[i0]),
|
||||
})
|
||||
return pd.DataFrame(rec) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} {tf} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
TFO = {"5m": 0, "15m": 1, "1h": 2, "4h": 3}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, cost: float) -> None:
|
||||
print("\n" + "=" * 96)
|
||||
print(f"【一】线段幅度 vs 确认成本(成本按一类实测的 {cost} ATR 计)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
q = x.span_atr.quantile([.25, .5, .75]).values
|
||||
room = x.span_atr - cost
|
||||
rows.append({
|
||||
"级别": tf, "线段数": len(x),
|
||||
"幅度Q1": round(q[0], 1), "幅度中位": round(q[1], 1),
|
||||
"幅度Q3": round(q[2], 1),
|
||||
"确认后剩余(中位)": round(q[1] - cost, 1),
|
||||
f"剩余>止损{SL_ATR}": f"{(room > SL_ATR).mean()*100:.0f}%",
|
||||
"剩余/止损": round((q[1] - cost) / SL_ATR, 2),
|
||||
"中位时长(根)": int(x.bars.median()),
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print(f"""
|
||||
「剩余/止损」是这张表的结论行:真底进场、扣掉确认成本后,还剩几倍止损的空间。
|
||||
< 1 机会装不下确认成本,**换任何识别规则都没用**
|
||||
1~2 勉强打平,要求选样精度极高(step60 实测最好 30%)
|
||||
> 2 有空间,值得改规则/改入场构造""")
|
||||
|
||||
print("\n【二】把确认成本当变量:多低才够用")
|
||||
rows = []
|
||||
for tf, x in sorted(d.groupby("tf"), key=lambda kv: TFO.get(kv[0], 9)):
|
||||
r = {"级别": tf}
|
||||
for c in (0.0, 1.0, 2.0, 2.9):
|
||||
r[f"成本{c}"] = round((x.span_atr.median() - c) / SL_ATR, 2)
|
||||
rows.append(r)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("\n 成本 0 = 完美实时(在真底那根就进)。若连成本 0 那列都 < 2,"
|
||||
"\n 说明**不是滞后的问题,是这个级别的线段本身就太小**。")
|
||||
|
||||
print("\n【三】逐币(中位幅度 ATR),看结论是否普适")
|
||||
print(d.pivot_table(index="sym", columns="tf", values="span_atr",
|
||||
aggfunc="median").round(1)
|
||||
.reindex(columns=[t for t in TFO if t in set(d.tf)])
|
||||
.to_string())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--tfs", default="5m,15m,1h,4h")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
ap.add_argument("--cost", type=float, default=2.9)
|
||||
ap.add_argument("--workers", type=int, default=2)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.cost)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
tfs = [t.strip() for t in args.tfs.split(",")]
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(collect, s, t, args.rows): (s, t)
|
||||
for s in syms for t in tfs}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
s, t = fut[f]
|
||||
print(f" [{i}/{len(fut)}] {s} {t} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.cost)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""改识别规则到底有没有用:把天花板一次性测到顶。
|
||||
|
||||
用户问「改 B1/B2 的识别规则呢」。step65 让这个问题变得可判定,但也制造了一个
|
||||
必须解释的矛盾:
|
||||
|
||||
线段中位 11.4 ATR,进场花掉 2.9,剩 8.5 —— 是 2 ATR 止损的 4.26 倍。
|
||||
可 step60 用未来函数选出真底的那批 B1,PF 仍只有 0.70~0.83。
|
||||
|
||||
空间明明在,就是拿不到。两种解释对用户的问题给出**相反**的答案:
|
||||
|
||||
选样问题:只有 30% 的 B1 落在真线段底,那 8.5 ATR 只存在于这 30% 里,
|
||||
其余 70% 是趋势中途,后面根本没有行情 -> **改识别规则有用**
|
||||
路径问题:就算落在真底,11.4 ATR 是净幅度,路上的回撤照样打掉止损
|
||||
-> **改识别规则没用**
|
||||
|
||||
区分只需要一个从没跑过的组合:**未来函数选样 + 结构止损 + 绝对目标**。
|
||||
三样单独都失败过(step60 的 0.83 / step58 的 0.36 / 本轮 abs 的 0.36),
|
||||
组合起来没试过。它测的不是某条规则,是**所有识别规则的上界**:
|
||||
|
||||
上界 < 1.0 -> 路径问题,一类线彻底关闭,别再改规则
|
||||
上界 > 1.5 -> 选样问题,改识别规则有用,且这里就是要够到的目标
|
||||
|
||||
同时量 MFE 做交叉验证:真底那批到底跑没跑出 8.5 ATR。
|
||||
跑出来了却还是亏 -> 铁证是止损/路径
|
||||
根本没跑出来 -> 说明 11.4 ATR 那个测量传导不到这批信号上,step65 要重解释
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
SRC = HERE / "out" / "step64_b2.feather"
|
||||
MAXB = 48
|
||||
|
||||
|
||||
def mfe_mae(cdf: pd.DataFrame, idx: np.ndarray, dirs: np.ndarray,
|
||||
atr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""入场后 MAXB 根内最远的顺向/逆向行程(ATR)。"""
|
||||
hi, lo = cdf["high"].to_numpy(float), cdf["low"].to_numpy(float)
|
||||
op = cdf["open"].to_numpy(float)
|
||||
n = len(cdf)
|
||||
fe = np.full(len(idx), np.nan)
|
||||
ae = np.full(len(idx), np.nan)
|
||||
for k, (i, d, a) in enumerate(zip(idx, dirs, atr)):
|
||||
e = int(i) + 1
|
||||
if e >= n or not np.isfinite(a) or a <= 0:
|
||||
continue
|
||||
j = min(e + MAXB, n)
|
||||
px = op[e]
|
||||
if d == 1:
|
||||
fe[k] = (hi[e:j].max() - px) / a
|
||||
ae[k] = (px - lo[e:j].min()) / a
|
||||
else:
|
||||
fe[k] = (px - lo[e:j].min()) / a
|
||||
ae[k] = (hi[e:j].max() - px) / a
|
||||
return fe, ae
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
d = pd.read_feather(SRC)
|
||||
d = d[(d.tf == args.tf) & d.type.isin(["B1", "S1"])].copy()
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
print(f"[识别规则天花板] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"其中落在真线段端点 {d.hit.sum()} 笔 ({d.hit.mean()*100:.1f}%)\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】MFE 交叉验证:真底那批到底跑没跑出 step65 说的 8.5 ATR")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
fe, ae = [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
f_, a_ = mfe_mae(cache[sym], x.i_sure.values, x.dir.values,
|
||||
x.atr_at_entry.values)
|
||||
fe.append(f_)
|
||||
ae.append(a_)
|
||||
fe, ae = np.concatenate(fe), np.concatenate(ae)
|
||||
ok = np.isfinite(fe)
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "笔数": int(ok.sum()),
|
||||
"MFE中位": round(float(np.median(fe[ok])), 2),
|
||||
"MFE≥3ATR": f"{(fe[ok] >= 3).mean()*100:.0f}%",
|
||||
"MFE≥8ATR": f"{(fe[ok] >= 8).mean()*100:.0f}%",
|
||||
"MAE中位": round(float(np.median(ae[ok])), 2),
|
||||
"MAE≥2ATR(会被现止损打掉)": f"{(ae[ok] >= 2).mean()*100:.0f}%",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:若「是」那行 MFE 中位远低于 8.5,说明 step65 的线段幅度传导不到
|
||||
信号上(进场时行情已经走掉了大半);若 MFE 够大而 MAE 也大,则是路径问题。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】天花板:未来函数选样 + 结构止损 + 绝对目标(三者首次组合)")
|
||||
print("=" * 96)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for label, margin in [("固定2ATR", None), ("结构+0.5", 0.5),
|
||||
("结构+1.0", 1.0), ("结构+1.5", 1.5)]:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
sl = (np.full(len(x), 2.0) if margin is None
|
||||
else (x.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((x.atr_at_entry.values / x.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
rows.append({
|
||||
"落在真线段端点": "是" if k else "否", "止损": label,
|
||||
**stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」那几行就是**任何识别规则的上界**(因为选样已经用了未来函数):
|
||||
< 1.0 路径问题,一类线关闭,改规则无解
|
||||
> 1.5 选样问题,改识别规则有用,且这就是要够到的目标""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""把 step66 的天花板换成实时可算的选样,看能兑现多少。
|
||||
|
||||
step66 测出上界:**未来函数选样 + 结构止损**在 5m 是 PF 2.29、15m 是 4.11。
|
||||
但那个选样用了线段端点(事后才知道),不能交易。本脚本把它换成 step62 那个
|
||||
**实时可算**的 `ext_run`(极值越过中枢边界几个 ATR,当根即可算),配上结构止损。
|
||||
|
||||
这是唯一同时处理两个已知约束的组合,也是 §3.3992 列出的第一个待跑实验:
|
||||
|
||||
step62 有实时过滤器(精度 28.7% -> 47.9%)但配了 2 ATR 止损 -> PF 0.54
|
||||
step58 有结构止损但没有过滤器 -> PF 0.36
|
||||
step66 两个都有,但选样是未来函数 -> PF 2.29 / 4.11
|
||||
**本脚本:实时过滤器 + 结构止损** -> ?
|
||||
|
||||
判读(对照 step66 的上界):
|
||||
> 1.3 兑现了相当部分,一类线值得继续,下一步做因果回放(sure_time 仍是未来函数)
|
||||
~ 1.0 过滤器精度不够,需要更好的实时特征
|
||||
< 0.8 实时特征抓不到那 28.7%,天花板兑现不了,一类线仍关闭
|
||||
|
||||
⚠️ 即使 > 1.3 也**不能算数**:入场仍在 `sure_time` 上,而 §3.396 证明实盘回放
|
||||
还要再晚 15 根。这一步只决定「值不值得再花一次因果回放的机器时间」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
F62 = HERE / "out" / "step62_trend_end.feather"
|
||||
F64 = HERE / "out" / "step64_b2.feather"
|
||||
KEY = ["sym", "tf", "type", "i_ext", "i_sure"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=200_000)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from lib.data import fetch_ohlcv
|
||||
from step58_struct_stop import simulate, stats
|
||||
|
||||
a = pd.read_feather(F62)[KEY + ["ext_run", "div", "ladder_n"]]
|
||||
b = pd.read_feather(F64)[KEY + ["entry_px", "own_ext",
|
||||
"atr_at_entry", "hit", "dir"]]
|
||||
d = a.merge(b, on=KEY, how="inner")
|
||||
d = d[d.tf == args.tf].reset_index(drop=True)
|
||||
d["to_ext"] = (d.entry_px - d.own_ext) * d.dir / d.atr_at_entry
|
||||
|
||||
print(f"[实时过滤器 + 结构止损] {args.tf} · 一类 {len(d)} 笔 · "
|
||||
f"真线段端点占比 {d.hit.mean()*100:.1f}%")
|
||||
print(f"step66 上界(同止损、但用未来函数选样):"
|
||||
f"{'PF 2.29' if args.tf == '5m' else 'PF 4.11'}\n")
|
||||
|
||||
cache = {}
|
||||
for sym in sorted(d.sym.unique()):
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", args.tf, args.rows)
|
||||
cache[sym] = TF_DF(df, 1, args.tf).dataframe
|
||||
|
||||
def run(x: pd.DataFrame, margin: float | None) -> dict:
|
||||
gs, rs, cs, ap_, sl_ = [], [], [], [], []
|
||||
for sym, g in x.groupby("sym"):
|
||||
g = g.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": g.i_sure.values,
|
||||
"direction": g.dir.values,
|
||||
"atr_at_entry": g.atr_at_entry.values})
|
||||
sl = (np.full(len(g), 2.0) if margin is None
|
||||
else (g.to_ext.values + margin))
|
||||
res = simulate(cache[sym], t, sl, r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
gs.append(res.g[ok])
|
||||
rs.append(res.r[ok])
|
||||
cs.append(res.c[ok])
|
||||
ap_.append((g.atr_at_entry.values / g.entry_px.values)[ok])
|
||||
sl_.append(sl[ok])
|
||||
return stats(pd.concat(gs, ignore_index=True),
|
||||
pd.concat(rs, ignore_index=True),
|
||||
pd.concat(cs, ignore_index=True),
|
||||
np.concatenate(ap_), np.concatenate(sl_))
|
||||
|
||||
print("=" * 96)
|
||||
print("【一】ext_run 分档 × 止损口径 —— 过滤器的效果依赖止损吗")
|
||||
print("=" * 96)
|
||||
q = d.ext_run.quantile([.25, .5, .75]).values
|
||||
lab = ["Q1最短", "Q2", "Q3", "Q4最延伸"]
|
||||
d["bucket"] = pd.cut(d.ext_run, [-np.inf, *q, np.inf], labels=lab)
|
||||
rows = []
|
||||
for bk, g in d.groupby("bucket", observed=True):
|
||||
for name, m in [("固定2ATR", None), ("结构+1.0", 1.0)]:
|
||||
rows.append({"ext_run档": bk, "止损": name, "真端点占比":
|
||||
f"{g.hit.mean()*100:.0f}%", **run(g, m)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
§3.399 判过「过滤器符号随入场时点翻转 -> 拟合」。这里换的是**止损**而非入场,
|
||||
若 Q4 在两种止损下都最好,说明 ext_run 是真信号;若又翻转,则仍是拟合。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【二】实时可交易组合 vs step66 上界")
|
||||
print("=" * 96)
|
||||
rows = [{"选样": "全体(无过滤)", "笔数": len(d), **run(d, 1.0)}]
|
||||
for p in (50, 75):
|
||||
thr = np.percentile(d.ext_run, p)
|
||||
g = d[d.ext_run >= thr]
|
||||
rows.append({"选样": f"ext_run ≥ P{p}(实时)", "笔数": len(g),
|
||||
**run(g, 1.0)})
|
||||
g = d[(d.ext_run >= np.percentile(d.ext_run, 75))
|
||||
& (d["div"] >= d["div"].median())]
|
||||
rows.append({"选样": "ext_run≥P75 且 div≥中位(实时)",
|
||||
"笔数": len(g), **run(g, 1.0)})
|
||||
gh = d[d.hit]
|
||||
rows.append({"选样": "★真线段端点(未来函数=上界)", "笔数": len(gh),
|
||||
**run(gh, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:实时行若接近 ★ 那行,说明 ext_run 抓到了同一批信号,一类线值得继续;
|
||||
若仍贴近「全体」,说明实时特征抓不到那 28.7%,天花板兑现不了。
|
||||
⚠️ 即便好也不算数——入场仍在 sure_time 上,必须再过一次因果回放。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【三】数字对不上,追一下:ext_run 挑出的真端点,质量还一样吗")
|
||||
print("=" * 96)
|
||||
print("Q4 的真端点浓度 44%(基线 28.7%),若真端点都值 PF 2.29,"
|
||||
"Q4 不该只有 0.51。\n拆开看 ext_run 在真端点**内部**是帮忙还是帮倒忙——"
|
||||
"这决定天花板是否可学:")
|
||||
rows = []
|
||||
for k, g in d.groupby(d.hit):
|
||||
for bk, gg in g.groupby("bucket", observed=True):
|
||||
if len(gg) < 25:
|
||||
continue
|
||||
rows.append({"真端点": "是" if k else "否", "ext_run档": bk,
|
||||
"笔数": len(gg), **run(gg, 1.0)})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
「是」组内若 Q4 明显低于 Q1 -> ext_run 在真端点里**反向选样**,
|
||||
它提高浓度的同时挑走了最差的那些,两个效应抵消,这解释了 0.51 vs 2.29。
|
||||
若「是」组内各档持平 -> 浓度提升是真的,缺的只是更强的实时特征。""")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("【四】那需要多高的实时精度才能翻正")
|
||||
print("=" * 96)
|
||||
print("【三】显示只有「是不是真端点」这一个变量在起作用(组内各档持平)。"
|
||||
"\n于是 PF 只是两组按精度 p 的混合,可以直接解出盈亏平衡精度:")
|
||||
net = {}
|
||||
for k, g in d.groupby(d.hit):
|
||||
ns = []
|
||||
for sym, x in g.groupby("sym"):
|
||||
x = x.reset_index(drop=True)
|
||||
t = pd.DataFrame({"entry_idx": x.i_sure.values,
|
||||
"direction": x.dir.values,
|
||||
"atr_at_entry": x.atr_at_entry.values})
|
||||
res = simulate(cache[sym], t, x.to_ext.values + 1.0,
|
||||
r_scale=False)
|
||||
ok = res.g.notna().values
|
||||
from lib.exit_model import fee_of
|
||||
ns.append(res.g[ok].values
|
||||
- fee_of(res.r[ok].values, res.c[ok].values))
|
||||
net[bool(k)] = np.concatenate(ns)
|
||||
|
||||
def mix_pf(p: float) -> float:
|
||||
"""精度 p 时的 PF:两组按 p 加权(组内分布不变,只变权重)。"""
|
||||
h, m = net[True], net[False]
|
||||
w = (p * h[h > 0].sum() / len(h)
|
||||
+ (1 - p) * m[m > 0].sum() / len(m))
|
||||
l = (p * -h[h <= 0].sum() / len(h)
|
||||
+ (1 - p) * -m[m <= 0].sum() / len(m))
|
||||
return w / l if l > 0 else np.inf
|
||||
|
||||
grid = [0.287, 0.35, 0.44, 0.5, 0.6, 0.7, 0.8, 1.0]
|
||||
print(pd.DataFrame([{
|
||||
"实时精度": f"{p*100:.1f}%", "PF": round(mix_pf(p), 2),
|
||||
"备注": {0.287: "← 当前基线", 0.44: "← ext_run Q4 已达到",
|
||||
1.0: "← step66 上界"}.get(p, "")} for p in grid]
|
||||
).to_string(index=False))
|
||||
lo, hi = 0.287, 1.0
|
||||
if mix_pf(hi) > 1 > mix_pf(lo):
|
||||
for _ in range(40):
|
||||
mid = (lo + hi) / 2
|
||||
lo, hi = (mid, hi) if mix_pf(mid) < 1 else (lo, mid)
|
||||
print(f"\n **盈亏平衡精度 ≈ {(lo+hi)/2*100:.1f}%** "
|
||||
f"(当前 28.7%,ext_run Q4 已到 44.0%)")
|
||||
print(" 这就是「改识别规则」要够到的具体门槛,且它只是毛平衡;"
|
||||
"\n 还要再扣 §3.396 的因果滞后(实盘比 sure_time 再晚 15 根)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""按**实盘口径**重放 B4,并测三道过滤能否刷掉那批亏钱的额外信号。
|
||||
|
||||
step63 的结论是一好一坏:
|
||||
好 时点干净 —— 100% 召回、100% 准时、零滞后(一类是 0% 准时、+15 根)
|
||||
坏 回放多出 592 个全量口径没有的信号,PF 0.46 / t −5.03,混合后 0.64 < 1
|
||||
|
||||
但 step63 有两个口径问题,本脚本一并修掉:
|
||||
|
||||
① 窗口不对。回测用全量 45000 根一次算完,step63 用 2 万涨到 4 万根的增长窗口,
|
||||
**而实盘用 2001 根滚动窗口、每 500 根 init_stream 拉回**(`shadow_signal.py`
|
||||
的 MAX_GROW)。三种口径的中枢结构都不一样。这也是 HANDOFF 里挂着的
|
||||
「回测用全量历史建中枢、实盘用 2000 根窗口」那条待办。
|
||||
顺带:窗口封顶后单步成本恒定,不再是 step63 那个平方级(143ms@2万根 ->
|
||||
292ms@4万根),所以本脚本快得多。
|
||||
|
||||
② 没测过滤。step63 跑的是裸信号,而实盘有三道滤网。ATR 门控已单独测过——
|
||||
它刷掉 7.9% 的额外信号却刷掉 10.7% 的好信号,PF 纹丝不动。剩下两道要测。
|
||||
|
||||
**一处刻意的简化,方向是保守的**:大级别分型时间线用全量历史算(真实盘的 HTF
|
||||
也会重画)。这等于**给同向过滤器开了未来函数的后门**。若连这样都刷不掉额外信号,
|
||||
结论只会更强。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step68_live_window.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _ladder(zones: pd.DataFrame) -> pd.DataFrame:
|
||||
z = zones.copy()
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
return z
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zs_full = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zs_full:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zs_full, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
# 大级别分型时间线:全量算(见模块 docstring 的「刻意简化」)
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan = None
|
||||
anchor = 0
|
||||
for i in range(WIN, len(df)):
|
||||
# 实盘的窗口纪律:2001 根起,长过 MAX_GROW 就 init_stream 拉回
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = _ladder(zones_from_zs_list(zl, sub))
|
||||
if z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last] # 实盘只做当根
|
||||
if s.empty:
|
||||
continue
|
||||
if "zone_i" in s.columns:
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in full_keys,
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 20:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
tn = taker_notional(g.r.values, g.c.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean() / tn.mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】实盘窗口下还有多少额外信号")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 其中全量口径也有 {int(d.in_full.sum())} · "
|
||||
f"**额外 {int((~d.in_full).sum())}**")
|
||||
print(f"(step63 的增长窗口口径:197 / 592)")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】三道过滤能不能刷掉额外信号 —— 这决定实盘是否在亏钱")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, m in [("① 无过滤", None), ("② 仅同向", d.h1_agree == 1),
|
||||
("③ 仅阶梯", d.ladder_ok == 1),
|
||||
("④ 仅ATR门控", d.gate_ok == 1),
|
||||
("⑤ 三道全开(实盘口径)", d.pass_all == 1)]:
|
||||
x = d if m is None else d[m]
|
||||
if x.empty:
|
||||
continue
|
||||
ex, bt = x[~x.in_full], x[x.in_full]
|
||||
row = {"过滤": nm, "留下": len(x),
|
||||
"额外占比": f"{(~x.in_full).mean()*100:.0f}%"}
|
||||
for lab, g in [("全部", x), ("额外", ex), ("回测口径", bt)]:
|
||||
s = perf(g)
|
||||
row[f"{lab}PF"] = "—" if s is None else s["PF"]
|
||||
row[f"{lab}n"] = len(g)
|
||||
rows.append(row)
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】实盘口径(三道全开)的完整表现")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, g in [("实盘会做的全部", d[d.pass_all == 1]),
|
||||
(" 其中额外的", d[(d.pass_all == 1) & ~d.in_full]),
|
||||
(" 其中回测也有的", d[(d.pass_all == 1) & d.in_full])]:
|
||||
s = perf(g)
|
||||
if s:
|
||||
rows.append({"分组": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
判读:「实盘会做的全部」PF > 1 -> 实盘安全,额外信号被滤网挡住了
|
||||
PF < 1 -> **实盘在做一批回测里不存在、且亏钱的信号**,要立刻处理""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[实盘口径回放] {len(syms)} 币 × {args.steps} 根 · "
|
||||
f"{WIN} 根滚动窗口 / 每 {MAX_GROW} 根重建\n", flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""那 72~77% 的「额外信号」到底怎么来的:中枢重画,还是扫描窗口错位?
|
||||
|
||||
我在 §3.3994 里把它归因为「中枢重画」,**这个归因没验证过,而且与已有结论矛盾**
|
||||
(step39 的假阳性 0%、`verify_window_sens` 的窗口 +200/+500/+1000 逐字段一致)。
|
||||
用户指出中枢不重画,代码注释也支持他:
|
||||
|
||||
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
|
||||
# 于是 available_ts 跟着漂——这是右边缘重画的根因
|
||||
—— chanlun/analysis/fast_bsp.py:47
|
||||
|
||||
漂的不是中枢**边界**(zg/zd),是它的**可用时刻**。而 `find_fast_bsp3` 只从
|
||||
`available_ts` 往后扫 `scan=200` 根。两种口径的窗口因此错位:
|
||||
|
||||
实时 中枢没吸收完,available_ts 偏早 -> 窗口开得早
|
||||
全量 中枢吸收完了,available_ts 偏晚(§5.41 实测中位晚 62 分钟)-> 窗口开得晚
|
||||
|
||||
落在「实时窗口内、全量窗口外」的信号,全量根本没扫到那个时段,于是显示为「额外」。
|
||||
|
||||
两种机制的修法完全不同,所以必须分清:
|
||||
|
||||
边界重画 结构本身不稳,只能用滞后换稳定性,代价大
|
||||
窗口错位 中枢是同一个真中枢,信号也是真信号,只是**开得太早、确认不足**
|
||||
—— 这正好解释它们为什么亏(PF 0.26~0.36),且修法是调 available_ts
|
||||
|
||||
判据:逐个额外信号,去全量中枢表里按 (zg, zd) 找它的中枢。
|
||||
找得到且边界一致 -> 窗口错位(用户是对的,我的归因错了)
|
||||
找不到或边界不同 -> 边界重画(我的归因成立)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step69_mech.feather"
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
TOL = 1e-6
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, rows: int, steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zf = zones_from_zs_list(full.cal_bi_zs_list_pure(full.bi_list), cdf)
|
||||
if zf is None or zf.empty:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zf)
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
fzg = zf["zg"].to_numpy(float)
|
||||
fzd = zf["zd"].to_numpy(float)
|
||||
fav = zf["available_ts"].to_numpy()
|
||||
ts_all = cdf["timestamp"].to_numpy()
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
# find_fast_bsp3 的输出自带 zg/zd,直接 merge 会加后缀,
|
||||
# 所以中枢侧的列全部改名再接
|
||||
zc = z[["zg", "zd", "available_ts"]].rename(columns={
|
||||
"zg": "z_zg", "zd": "z_zd", "available_ts": "z_av"})
|
||||
zc["zone_i"] = np.arange(len(z))
|
||||
s = s.drop(columns=[c for c in ("z_zg", "z_zd", "z_av")
|
||||
if c in s.columns])
|
||||
s = s.merge(zc, on="zone_i", how="left")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
# 该中枢在全量表里是否存在(按边界匹配,边界是不该漂的量)
|
||||
zg_, zd_ = float(r.z_zg), float(r.z_zd)
|
||||
m = (np.abs(fzg - zg_) <= TOL * max(1.0, abs(zg_))) \
|
||||
& (np.abs(fzd - zd_) <= TOL * max(1.0, abs(zd_)))
|
||||
j = int(np.argmax(m)) if m.any() else -1
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"zone_found": bool(m.any()),
|
||||
"z_zg": zg_, "z_zd": zd_,
|
||||
"avail_rt": int(r.z_av),
|
||||
"avail_full": int(fav[j]) if j >= 0 else -1,
|
||||
"ts_entry": int(ts_all[i]) if i < len(ts_all) else -1,
|
||||
}
|
||||
return pd.DataFrame(list(rec.values())) if rec else None
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def report(d: pd.DataFrame, ltf: str) -> None:
|
||||
ex = d[~d.in_full]
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号所在的中枢,在全量表里找得到吗")
|
||||
print("=" * 92)
|
||||
print(f"回放信号 {len(d)} · 额外 {len(ex)}")
|
||||
print(f"**额外信号中,其中枢按 (zg,zd) 在全量表里找得到的:"
|
||||
f"{ex.zone_found.mean()*100:.1f}%**")
|
||||
print(f"(对照:非额外信号 {d[d.in_full].zone_found.mean()*100:.1f}%)")
|
||||
print("""
|
||||
≈100% -> 中枢边界没变,是**扫描窗口错位**,用户对、我的「重画」归因错
|
||||
明显偏低 -> 中枢确实消失或改边界,「重画」成立""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【一b】数量级对账:中枢层面只有 6.5% 被改,信号层面却 74% 是额外的")
|
||||
print("=" * 92)
|
||||
print("§5.41 的 A/B 实测:`bis[-1]` 口径下确认时刻被改 6.5%、中枢消失 9.0%。")
|
||||
print("若信号层面的 74% 成立,必然有放大机制。怀疑是 `max_per_zone=1`:")
|
||||
print(" 每个中枢只返回**第一个**入场点,而扫描起点随 available_ts 棘轮后移,")
|
||||
print(" 越过旧入场点后,同一中枢会重新产出一个「第一个」——全量只用最终值,")
|
||||
print(" 所以每中枢至多一个信号,实时却能反复触发。")
|
||||
for nm, x in [("额外信号", ex), ("非额外信号", d[d.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["sym", "z_zg", "z_zd"]).size() \
|
||||
if "z_zg" in x.columns else None
|
||||
if nz is None:
|
||||
print(" (缺 z_zg/z_zd 列,跳过)")
|
||||
break
|
||||
print(f"\n{nm}:{len(x)} 个信号,落在 {len(nz)} 个不同中枢上 "
|
||||
f"-> 每中枢 {len(x)/len(nz):.2f} 次")
|
||||
print(f" 同一中枢触发次数分布 中位 {nz.median():.0f} "
|
||||
f"P90 {nz.quantile(.9):.0f} 最大 {nz.max()}")
|
||||
print("""
|
||||
若「额外信号」的每中枢次数显著 > 1 而「非额外」≈ 1,放大机制坐实:
|
||||
6.5% 的中枢改动通过棘轮重扫,放大成信号层面的几百个。""")
|
||||
|
||||
g = ex[ex.zone_found & (ex.avail_full > 0)].copy()
|
||||
if g.empty:
|
||||
return
|
||||
bar_ms = {"1m": 60_000, "5m": 300_000, "15m": 900_000}.get(ltf, 300_000)
|
||||
g["drift_bars"] = (g.avail_full - g.avail_rt) / bar_ms
|
||||
g["from_rt"] = (g.ts_entry - g.avail_rt) / bar_ms
|
||||
g["from_full"] = (g.ts_entry - g.avail_full) / bar_ms
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】available_ts 漂了多少,以及入场落在谁的扫描窗口里")
|
||||
print("=" * 92)
|
||||
print(f"avail 漂移(全量 − 实时,根) 中位 {g.drift_bars.median():.0f} "
|
||||
f"P25 {g.drift_bars.quantile(.25):.0f} "
|
||||
f"P75 {g.drift_bars.quantile(.75):.0f}")
|
||||
print(f" §5.41 记的是中位晚 62 分钟,本表 {ltf} 下即 "
|
||||
f"{62*60_000/bar_ms:.0f} 根,可交叉验证")
|
||||
print(f"\n入场距实时 avail(根) 中位 {g.from_rt.median():.0f} "
|
||||
f"(应落在 0~{SCAN} 内,否则实时也扫不到)")
|
||||
print(f"入场距全量 avail(根) 中位 {g.from_full.median():.0f}")
|
||||
out = ((g.from_full < 0) | (g.from_full > SCAN)).mean()
|
||||
print(f"\n**入场落在全量扫描窗口 [0,{SCAN}] 之外的比例:{out*100:.1f}%**")
|
||||
print("""
|
||||
这是机制的直接证据:比例高 -> 全量根本没扫到那个时段,所以「没有」这个信号,
|
||||
与中枢是否重画无关。其中 from_full < 0 表示入场早于全量的可用时刻——
|
||||
即**实时抢跑了**,中枢还没吸收完就下单。""")
|
||||
early = (g.from_full < 0).mean()
|
||||
print(f" 其中抢跑(早于全量 avail):{early*100:.1f}%")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT), args.ltf)
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[机制判定] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex_:
|
||||
fut = {ex_.submit(replay, s, args.ltf, args.rows, args.steps): s
|
||||
for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d, args.ltf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""把 B4 按「中枢末笔是否已确认」拆开——之前的统计把两种混在一起了。
|
||||
|
||||
用户指出:B4 有两种,**没确认的会出现然后消失,确认的不会**。而我在 step63/68
|
||||
里统计的是 `find_fast_bsp3` 的全部输出,它的返回列里根本没有确认标志
|
||||
(entry_idx/direction/bo_idx/pb_idx/lag/depth/zg/zd/width_pct/occ/zone_i),
|
||||
**两种被混在一起了**,所以「额外信号 74%」这个数字不能直接拿来说实盘。
|
||||
|
||||
确认状态在更上游,`zones_from_zs_list`:
|
||||
|
||||
sure_key = str(getattr(key_bi, "sure_time", "") or "")
|
||||
end_key = str(getattr(key_bi, "end_time", "") or "")
|
||||
avail = ts_of.get(sure_key) or ts_of.get(end_key) # ← 静默退回 end_time
|
||||
|
||||
`ChanBI` 初始 `is_sure=False / sure_time=None`,确认时才 `set_is_sure(True, ...)`。
|
||||
所以**末笔未确认时 available_ts 退回 end_time,而 end_time 随笔延伸而移动**,
|
||||
中枢的可用时刻跟着漂 —— 这正是「出现然后消失」的那一种。笔一旦确认,
|
||||
`sure_time` 固定,中枢不再动。
|
||||
|
||||
这也解释了 §5.41 的数量级:中枢层面确认时刻只被改 6.5%,而我在信号层面看到 74%。
|
||||
|
||||
本脚本按 `zs.bi_list[-1].is_sure` 把信号拆成两组,分别看:
|
||||
额外率 未确认组应显著高(会出现然后消失),已确认组应接近 0
|
||||
收益 若亏损集中在未确认组,那么修法就是**信号侧加一道 is_sure 门**,
|
||||
而不是动出场参数或放弃 B4
|
||||
|
||||
⚠️ 同时要查的第二件事:**实盘路径到底交易哪一种。** `shadow_signal.py` 的三道
|
||||
滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure 这一道**。若未确认组确实是亏损源,
|
||||
且实盘没有挡它,那这道门就是要补的东西。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
OUT = HERE / "out" / "step70_sure.feather"
|
||||
SL, SCALE_AT, RUNNER, RSTOP, MAXB = 2.0, 3.0, 8.0, 2.0, 48
|
||||
WIN, MAX_GROW, GATE_BP = 2001, 500, 8.0
|
||||
|
||||
|
||||
def _sure_map(zs_list) -> dict:
|
||||
"""(zg, zd) -> 末笔是否已确认。zones_from_zs_list 会按 available_ts 重排,
|
||||
索引对不上,所以用中枢边界当键接回去。"""
|
||||
m = {}
|
||||
for zs in zs_list:
|
||||
bis = getattr(zs, "bi_list", []) or []
|
||||
if not bis:
|
||||
continue
|
||||
m[(round(float(zs.zg), 10), round(float(zs.zd), 10))] = \
|
||||
bool(getattr(bis[-1], "is_sure", False))
|
||||
return m
|
||||
|
||||
|
||||
def replay(sym: str, ltf: str, htf: str, rows: int,
|
||||
steps: int) -> pd.DataFrame | None:
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
from lib.fx_signal import extract_fx_signals, signals_to_frame
|
||||
from lib.nested_bsp import attach_htf_context, htf_fx_timeline
|
||||
|
||||
try:
|
||||
df = fetch_ohlcv(f"{sym}/USDT:USDT", ltf, rows)
|
||||
if df is None or len(df) < WIN + steps + 100:
|
||||
return None
|
||||
df = df.iloc[-(WIN + steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, ltf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
if not zsf:
|
||||
return None
|
||||
sig_full = find_fast_bsp3(cdf, zones_from_zs_list(zsf, cdf))
|
||||
full_keys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()} if not sig_full.empty \
|
||||
else set()
|
||||
|
||||
dh = fetch_ohlcv(f"{sym}/USDT:USDT", htf, rows)
|
||||
tl = None
|
||||
if dh is not None and len(dh) > 500:
|
||||
ch = TF_DF(dh, 1, htf)
|
||||
tl = htf_fx_timeline(
|
||||
signals_to_frame(extract_fx_signals(ch, ch.dataframe)),
|
||||
ch.dataframe)
|
||||
|
||||
rec: dict[tuple, dict] = {}
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, ltf)
|
||||
chan.init_stream(w, 1, ltf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
sm = _sure_map(zl)
|
||||
pg, pdn = z["zg"].shift(), z["zd"].shift()
|
||||
z["z_above"], z["z_below"] = z["zd"] > pg, z["zg"] < pdn
|
||||
z["zone_i"] = np.arange(len(z))
|
||||
z["z_sure"] = [
|
||||
sm.get((round(float(a), 10), round(float(b), 10)), False)
|
||||
for a, b in zip(z["zg"], z["zd"])]
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty or "zone_i" not in s.columns:
|
||||
continue
|
||||
s = s.merge(z[["zone_i", "z_above", "z_below", "z_sure"]],
|
||||
on="zone_i", how="left")
|
||||
if tl is not None:
|
||||
s = attach_htf_context(s, sub, tl, "h1")
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
k = (i, int(r.direction))
|
||||
if k in rec:
|
||||
continue
|
||||
push = getattr(r, "z_above" if r.direction == 1
|
||||
else "z_below", None)
|
||||
ag = getattr(r, "h1_agree", 0)
|
||||
rec[k] = {
|
||||
"sym": sym, "entry_idx": i, "direction": int(r.direction),
|
||||
"in_full": k in full_keys,
|
||||
"z_sure": bool(getattr(r, "z_sure", False)),
|
||||
"ladder_ok": int(bool(pd.notna(push) and bool(push))),
|
||||
"h1_agree": int(ag) if pd.notna(ag) else 0,
|
||||
}
|
||||
if not rec:
|
||||
return None
|
||||
r = pd.DataFrame(list(rec.values()))
|
||||
|
||||
from lib.exit_model import cfg_name, walk_exits
|
||||
atr = cdf["atr"].to_numpy(float)
|
||||
cl = cdf["close"].to_numpy(float)
|
||||
r = r[(r.entry_idx < len(cdf) - 2)
|
||||
& np.isfinite(atr[r.entry_idx.values])
|
||||
& (atr[r.entry_idx.values] > 0)].reset_index(drop=True)
|
||||
if r.empty:
|
||||
return None
|
||||
res = walk_exits(cdf, pd.DataFrame({
|
||||
"entry_idx": r.entry_idx.values,
|
||||
"direction": r.direction.values}), [SL], [RUNNER], [MAXB],
|
||||
scale_at=SCALE_AT, runners=(RUNNER,), runner_stops=(RSTOP,))
|
||||
cfg = cfg_name(SL, RUNNER, MAXB, RSTOP)
|
||||
if len(res) != len(r):
|
||||
return None
|
||||
for c in ("g", "r", "c"):
|
||||
r[c] = res[f"{cfg}_{c}"].to_numpy()
|
||||
r["atr_pct"] = atr[r.entry_idx.values] / cl[r.entry_idx.values]
|
||||
r["gate_ok"] = (r.atr_pct * 1e4 >= GATE_BP).astype(int)
|
||||
r["pass_all"] = ((r.h1_agree == 1) & (r.ladder_ok == 1)
|
||||
& (r.gate_ok == 1)).astype(int)
|
||||
return r
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f" {sym} 失败: {type(e).__name__}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def perf(g: pd.DataFrame) -> dict | None:
|
||||
from lib.exit_model import fee_of, taker_notional
|
||||
if len(g) < 15:
|
||||
return None
|
||||
net = g.g.values - fee_of(g.r.values, g.c.values)
|
||||
gR = g.g.values / (SL * g.atr_pct.values)
|
||||
w, o = net[net > 0].sum(), -net[net <= 0].sum()
|
||||
return {
|
||||
"笔数": len(g), "胜率": f"{(net > 0).mean()*100:.1f}%",
|
||||
"PF": round(w / o, 2) if o > 0 else np.inf,
|
||||
"余量bp": round(net.mean()
|
||||
/ taker_notional(g.r.values, g.c.values).mean() * 1e4, 2),
|
||||
"t值": round(gR.mean() / (gR.std(ddof=1) / np.sqrt(len(g))), 2),
|
||||
}
|
||||
|
||||
|
||||
def report(d: pd.DataFrame) -> None:
|
||||
print("\n" + "=" * 92)
|
||||
print("【一】判据:额外信号是不是集中在「末笔未确认」那一组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
rows.append({"中枢末笔": "已确认" if k else "未确认", "信号数": len(g),
|
||||
"额外(全量没有)": int((~g.in_full).sum()),
|
||||
"额外率": f"{(~g.in_full).mean()*100:.1f}%"})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
用户的判断:「没确认的会出现然后消失,确认的不会」。
|
||||
若已确认组额外率接近 0 -> 判断成立,之前 74% 是把两种混在一起统计的结果。""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【二】亏损是不是也集中在未确认组")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for k, g in d.groupby(d.z_sure):
|
||||
nm = "已确认" if k else "未确认"
|
||||
for lab, x in [("全部", g), ("三道滤网后", g[g.pass_all == 1])]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"中枢末笔": nm, "口径": lab, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【三】若补一道 is_sure 门,实盘口径会变成什么样")
|
||||
print("=" * 92)
|
||||
rows = []
|
||||
for nm, x in [
|
||||
("现状:三道滤网", d[d.pass_all == 1]),
|
||||
("**加 is_sure 门**", d[(d.pass_all == 1) & d.z_sure]),
|
||||
(" 对照:仅未确认", d[(d.pass_all == 1) & ~d.z_sure]),
|
||||
]:
|
||||
s = perf(x)
|
||||
if s:
|
||||
rows.append({"口径": nm, **s})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
⚠️ `shadow_signal.py` 的三道滤网是同向 + 阶梯 + ATR 门控,**没有 is_sure**。
|
||||
若加上这道门 PF 明显回升,那它就是要补进信号路径的东西。""")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--symbols", default="BTC,ETH,SOL,LINK,DOGE")
|
||||
ap.add_argument("--ltf", default="5m")
|
||||
ap.add_argument("--htf", default="30m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=20_000)
|
||||
ap.add_argument("--workers", type=int, default=5)
|
||||
ap.add_argument("--reuse", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.reuse and OUT.exists():
|
||||
report(pd.read_feather(OUT))
|
||||
return
|
||||
syms = [s.strip() for s in args.symbols.split(",")]
|
||||
print(f"[确认态拆分] {len(syms)} 币 × {args.steps} 根 · {args.ltf}\n",
|
||||
flush=True)
|
||||
parts = []
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(replay, s, args.ltf, args.htf, args.rows,
|
||||
args.steps): s for s in syms}
|
||||
for i, f in enumerate(as_completed(fut), 1):
|
||||
r = f.result()
|
||||
print(f" [{i}/{len(syms)}] {fut[f]} "
|
||||
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)
|
||||
OUT.parent.mkdir(exist_ok=True)
|
||||
d.to_feather(OUT)
|
||||
report(d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""逐笔追「额外信号」:全量那一遍到底为什么没产出它。
|
||||
|
||||
用户说「原来的计算是没有问题的,是你算错了」。之前几轮都是统计口径,
|
||||
容易把自己的 bug 说成市场现象。这次不做统计,挑具体信号逐个对账。
|
||||
|
||||
对每个额外信号,把两边的中间量全摆出来:
|
||||
|
||||
回放侧 中枢 (zg,zd)、available_ts、扫描起点、入场根
|
||||
全量侧 同一个中枢是否存在、它的 available_ts、扫描区间 [start, start+200]
|
||||
入场根落不落在这个区间里、`diag` 记的拒绝原因
|
||||
|
||||
四种可能的结论,指向完全不同的处理:
|
||||
|
||||
A 全量里那个中枢的扫描区间**不覆盖**入场根
|
||||
-> available_ts 棘轮,机制成立,不是 bug
|
||||
B 中枢在全量里**不存在**
|
||||
-> 中枢集合本身有差异,要查是不是我窗口用错了
|
||||
C 区间覆盖了、全量却仍没出信号
|
||||
-> 两边输入不同(我传错了 df/zones),**是我的 bug**
|
||||
D 入场根索引对不上(差几根)
|
||||
-> 索引口径错,`chan.dataframe` 与原始 df 不是 1:1,**是我的 bug**
|
||||
|
||||
D 尤其要查:回放里我用原始 df 的下标 i 当 key,全量用的是 `cdf` 的 entry_idx,
|
||||
两者只有在 `TF_DF.dataframe` 与输入逐行对齐时才等价。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
WIN, MAX_GROW, SCAN = 2001, 500, 200
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sym", default="BTC")
|
||||
ap.add_argument("--tf", default="5m")
|
||||
ap.add_argument("--rows", type=int, default=45_000)
|
||||
ap.add_argument("--steps", type=int, default=3_000)
|
||||
ap.add_argument("--show", type=int, default=8)
|
||||
args = ap.parse_args()
|
||||
|
||||
from chanlun import TF_DF
|
||||
from chanlun.analysis.fast_bsp import (
|
||||
ensure_timestamp,
|
||||
find_fast_bsp3,
|
||||
zones_from_zs_list,
|
||||
)
|
||||
from lib.data import fetch_ohlcv
|
||||
|
||||
df = fetch_ohlcv(f"{args.sym}/USDT:USDT", args.tf, args.rows)
|
||||
df = df.iloc[-(WIN + args.steps):].reset_index(drop=True)
|
||||
|
||||
full = TF_DF(df, 1, args.tf)
|
||||
cdf = ensure_timestamp(full.dataframe)
|
||||
|
||||
print("=" * 92)
|
||||
print("【0】先查 D:索引口径是否 1:1")
|
||||
print("=" * 92)
|
||||
print(f"原始 df 行数 {len(df)} · TF_DF.dataframe 行数 {len(cdf)} "
|
||||
f"-> {'一致' if len(df) == len(cdf) else '**不一致,索引口径有问题**'}")
|
||||
if len(df) == len(cdf):
|
||||
t_df = pd.to_datetime(df["date"])
|
||||
t_cd = pd.to_datetime(cdf["date"])
|
||||
if t_df.dt.tz is not None:
|
||||
t_df = t_df.dt.tz_localize(None)
|
||||
if t_cd.dt.tz is not None:
|
||||
t_cd = t_cd.dt.tz_localize(None)
|
||||
same = int((t_df.values == t_cd.values).sum())
|
||||
print(f"逐行时间戳相同 {same}/{len(df)} "
|
||||
f"-> {'逐行对齐' if same == len(df) else '**有错位**'}")
|
||||
|
||||
zsf = full.cal_bi_zs_list_pure(full.bi_list)
|
||||
zf = zones_from_zs_list(zsf, cdf)
|
||||
diag_full: dict = {}
|
||||
sig_full = find_fast_bsp3(cdf, zf, diag=diag_full)
|
||||
fkeys = {(int(r.entry_idx), int(r.direction))
|
||||
for r in sig_full.itertuples()}
|
||||
ts = cdf["timestamp"].to_numpy()
|
||||
print(f"\n全量:中枢 {len(zf)} 个,信号 {len(sig_full)} 个")
|
||||
print(f"全量 diag:{diag_full}")
|
||||
|
||||
# ---- 回放 ----
|
||||
rec = []
|
||||
chan, anchor = None, 0
|
||||
for i in range(WIN, len(df)):
|
||||
if chan is None or (i - anchor) >= MAX_GROW:
|
||||
w = df.iloc[i - WIN + 1:i + 1].copy()
|
||||
chan = TF_DF(w, 1, args.tf)
|
||||
chan.init_stream(w, 1, args.tf)
|
||||
anchor = i
|
||||
else:
|
||||
chan.append_bar(df.iloc[i])
|
||||
try:
|
||||
zl = chan.cal_bi_zs_list_pure(chan.bi_list)
|
||||
if not zl:
|
||||
continue
|
||||
sub = ensure_timestamp(chan.dataframe)
|
||||
z = zones_from_zs_list(zl, sub)
|
||||
if z is None or z.empty:
|
||||
continue
|
||||
s = find_fast_bsp3(sub, z)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
last = len(sub) - 1
|
||||
s = s[s["entry_idx"].astype(int) == last]
|
||||
if s.empty:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for r in s.itertuples():
|
||||
zi = int(r.zone_i)
|
||||
rec.append({
|
||||
"i": i, "d": int(r.direction),
|
||||
"in_full": (i, int(r.direction)) in fkeys,
|
||||
"zg": float(z.zg.iloc[zi]), "zd": float(z.zd.iloc[zi]),
|
||||
"avail_rt": int(z.available_ts.iloc[zi]),
|
||||
"win_len": len(sub),
|
||||
"bo": int(r.bo_idx), "last": last,
|
||||
})
|
||||
rp = pd.DataFrame(rec)
|
||||
if rp.empty:
|
||||
print("回放无信号")
|
||||
return
|
||||
print(f"\n回放:信号 {len(rp)} 个 · 其中全量也有 "
|
||||
f"{int(rp.in_full.sum())} · 额外 {int((~rp.in_full).sum())}")
|
||||
|
||||
# ---- 逐笔对账 ----
|
||||
print("\n" + "=" * 92)
|
||||
print(f"【1】抽 {args.show} 个额外信号逐笔对账")
|
||||
print("=" * 92)
|
||||
fzg, fzd = zf.zg.to_numpy(float), zf.zd.to_numpy(float)
|
||||
fav = zf.available_ts.to_numpy()
|
||||
n = len(cdf)
|
||||
rows = []
|
||||
for r in rp[~rp.in_full].head(args.show).itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
rows.append({"入场根": r.i, "中枢在全量": "**不存在**",
|
||||
"结论": "B 中枢集合有差异"})
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
end = min(start + SCAN, n)
|
||||
cover = start <= r.i < end
|
||||
rows.append({
|
||||
"入场根": r.i, "中枢在全量": "存在",
|
||||
"回放avail根": int(np.searchsorted(ts, r.avail_rt, side="left")),
|
||||
"全量avail根": start,
|
||||
"棘轮(根)": start - int(np.searchsorted(ts, r.avail_rt,
|
||||
side="left")),
|
||||
"全量扫描区间": f"[{start},{end})",
|
||||
"覆盖入场根": "是" if cover else "否",
|
||||
"结论": "C **是我的bug**" if cover else "A 棘轮,机制成立",
|
||||
})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
A = 全量扫描区间不覆盖入场根(available_ts 棘轮后移),机制成立
|
||||
B = 中枢在全量里不存在 -> 中枢集合有差异,要查窗口
|
||||
C = 区间覆盖了全量却没出信号 -> 两边输入不同,是我的 bug""")
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【2】全体额外信号按结论归类")
|
||||
print("=" * 92)
|
||||
cnt = {"A 棘轮": 0, "B 中枢不存在": 0, "C 我的bug": 0}
|
||||
for r in rp[~rp.in_full].itertuples():
|
||||
m = (np.abs(fzg - r.zg) < 1e-9) & (np.abs(fzd - r.zd) < 1e-9)
|
||||
if not m.any():
|
||||
cnt["B 中枢不存在"] += 1
|
||||
continue
|
||||
j = int(np.argmax(m))
|
||||
start = int(np.searchsorted(ts, fav[j], side="left"))
|
||||
cnt["C 我的bug" if start <= r.i < min(start + SCAN, n)
|
||||
else "A 棘轮"] += 1
|
||||
tot = max(sum(cnt.values()), 1)
|
||||
print(pd.DataFrame([{"结论": k, "个数": v, "占比": f"{v/tot*100:.1f}%"}
|
||||
for k, v in cnt.items()]).to_string(index=False))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print("【3】按信号条数统计是否被同一中枢的重复触发放大了")
|
||||
print("=" * 92)
|
||||
print("上表可见 2691/2707/2750 的全量 avail 同为 2767,是**同一个中枢**触发三次。")
|
||||
print("`max_per_zone=1` 只保证「每次扫描返回一个」,但扫描起点随棘轮后移,")
|
||||
print("越过旧入场点后同一中枢会重新产出「第一个」——于是按条数统计被放大。\n")
|
||||
rows = []
|
||||
for nm, x in [("额外", rp[~rp.in_full]), ("全量也有", rp[rp.in_full])]:
|
||||
if x.empty:
|
||||
continue
|
||||
nz = x.groupby(["zg", "zd"]).size()
|
||||
rows.append({"分组": nm, "信号条数": len(x), "不同中枢数": len(nz),
|
||||
"每中枢触发": round(len(x) / len(nz), 2),
|
||||
"最多触发": int(nz.max())})
|
||||
print(pd.DataFrame(rows).to_string(index=False))
|
||||
print("""
|
||||
若「额外」组每中枢触发 >> 1 而「全量也有」组 ≈ 1,则先前那个「额外信号占 74%」
|
||||
是**按条数**统计的放大结果,不等于实盘会多开 74% 的仓。
|
||||
真实多开多少,取决于执行层对同一中枢是否去重/冷却 —— 那一层仍未审计。""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+21
-9
@@ -108,7 +108,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in analysis_result['bi_list'] if bi.is_sure],
|
||||
# 添加未完成笔列表
|
||||
'uncompleted_bi_list': [{
|
||||
@@ -118,7 +119,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in analysis_result['bi_list'] if not bi.is_sure],
|
||||
'seg_list': [{
|
||||
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
@@ -126,7 +128,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in analysis_result['seg_list'] if seg.is_sure],
|
||||
# 添加未完成线段列表
|
||||
'uncompleted_seg_list': get_uncompleted_seg_list(analysis_result['seg_list'], client_tz),
|
||||
@@ -305,7 +309,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in element_analysis['bi_list'] if bi.is_sure]
|
||||
# 添加次周期未完成笔列表
|
||||
result['element_uncompleted_bi_list'] = [{
|
||||
@@ -315,7 +320,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high, # 未完成笔没有结束价格
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in element_analysis['bi_list'] if not bi.is_sure]
|
||||
|
||||
# 添加小周期K线数据
|
||||
@@ -341,7 +347,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in element_analysis['seg_list'] if seg.is_sure]
|
||||
# 添加次周期未完成线段列表
|
||||
result['element_uncompleted_seg_list'] = get_uncompleted_seg_list(element_analysis['seg_list'], client_tz)
|
||||
@@ -446,7 +454,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.high if convert_direction(bi.dir) == 1 else bi.end_klc.low if bi.end_klc else None,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if bi.is_sure]
|
||||
result['sub_sub_uncompleted_bi_list'] = [{
|
||||
'start_time': bi.start_klc.end_time if isinstance(bi.start_klc.end_time, str) else bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
@@ -455,7 +464,8 @@ def analyze():
|
||||
'start_price': bi.start_klc.low if convert_direction(bi.dir) == 1 else bi.start_klc.high,
|
||||
'end_price': bi.end_klc.low if convert_direction(bi.dir) == 1 else bi.end_klc.high,
|
||||
'direction': convert_direction(bi.dir),
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0
|
||||
'macd_div': float(bi.macd_div) if hasattr(bi, 'macd_div') else 0,
|
||||
'macd_hist': float(bi.macd_hist) if getattr(bi, 'macd_hist', None) is not None else 0
|
||||
} for bi in sub_sub_analysis['bi_list'] if not bi.is_sure]
|
||||
# 次次周期 KLC 列表
|
||||
result['sub_sub_klc_list'] = [{
|
||||
@@ -477,7 +487,9 @@ def analyze():
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'end_price': seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low if seg.end_bi else None,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
} for seg in sub_sub_analysis['seg_list'] if seg.is_sure]
|
||||
result['sub_sub_uncompleted_seg_list'] = get_uncompleted_seg_list(sub_sub_analysis['seg_list'], client_tz)
|
||||
result['sub_sub_zs_list'] = [{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""把 data_provider 的资金面转给 Web,浏览器不直连交易所。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from services.runtime.market_data import (
|
||||
fetch_derivatives,
|
||||
fetch_sentiment_latest,
|
||||
fetch_sentiment_metrics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("provider", __name__)
|
||||
|
||||
|
||||
def _http_error(exc: requests.HTTPError):
|
||||
status = 502
|
||||
detail = str(exc)
|
||||
if exc.response is not None:
|
||||
status = exc.response.status_code or 502
|
||||
try:
|
||||
body = exc.response.json()
|
||||
detail = body.get("detail") or body.get("error") or detail
|
||||
except Exception:
|
||||
detail = exc.response.text or detail
|
||||
return jsonify({"error": detail}), status
|
||||
|
||||
|
||||
@bp.route("/api/derivatives")
|
||||
def api_derivatives():
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
exchange = (request.args.get("exchange") or "").strip() or None
|
||||
try:
|
||||
return jsonify(fetch_derivatives(symbol, exchange))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("derivatives 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
|
||||
|
||||
@bp.route("/api/sentiment/latest")
|
||||
def api_sentiment_latest():
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
try:
|
||||
return jsonify(fetch_sentiment_latest(symbol))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("sentiment latest 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
|
||||
|
||||
@bp.route("/api/sentiment/metrics")
|
||||
def api_sentiment_metrics():
|
||||
metric = (request.args.get("metric") or "").strip()
|
||||
if not metric:
|
||||
return jsonify({"error": "metric required"}), 400
|
||||
symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip()
|
||||
start = request.args.get("start", type=int)
|
||||
end = request.args.get("end", type=int)
|
||||
limit = request.args.get("limit", type=int)
|
||||
try:
|
||||
return jsonify(fetch_sentiment_metrics(metric, symbol, start=start, end=end, limit=limit))
|
||||
except requests.HTTPError as exc:
|
||||
return _http_error(exc)
|
||||
except Exception as exc:
|
||||
logger.warning("sentiment 中转失败: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
@@ -13,6 +13,7 @@ from flask import Flask
|
||||
from config import FLASK_HOST, FLASK_PORT
|
||||
from api.analyze import bp as analyze_bp
|
||||
from api.pages import bp as pages_bp
|
||||
from api.provider import bp as provider_bp
|
||||
from api.symbols import bp as symbols_bp
|
||||
from api.trend import bp as trend_bp
|
||||
|
||||
@@ -23,6 +24,7 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(analyze_bp)
|
||||
app.register_blueprint(symbols_bp)
|
||||
app.register_blueprint(trend_bp)
|
||||
app.register_blueprint(provider_bp)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ from .market_data import ( # noqa: F401
|
||||
get_crypto_kl_data,
|
||||
get_a_stock_kl_data,
|
||||
load_crypto_symbols,
|
||||
fetch_derivatives,
|
||||
fetch_sentiment_metrics,
|
||||
fetch_sentiment_latest,
|
||||
)
|
||||
from .indicators import ( # noqa: F401
|
||||
add_indicators,
|
||||
|
||||
@@ -48,6 +48,10 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
||||
for bi in bi_list:
|
||||
bi.cal_macd_div()
|
||||
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
||||
for seg in seg_list:
|
||||
seg.cal_macdhist()
|
||||
for seg in seg_list:
|
||||
seg.cal_macd_div()
|
||||
|
||||
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
|
||||
chan_macd = None
|
||||
|
||||
@@ -319,3 +319,34 @@ def load_crypto_symbols(limit=200):
|
||||
except Exception:
|
||||
return DEFAULT_SYMBOLS[:limit]
|
||||
|
||||
|
||||
def _provider_get(path, params, timeout=8):
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}{path}", params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def fetch_derivatives(symbol, exchange=None):
|
||||
"""当前资金面快照。只打 data_provider,不打交易所。"""
|
||||
params = {"symbol": symbol}
|
||||
if exchange:
|
||||
params["exchange"] = exchange
|
||||
return _provider_get("/api/derivatives", params)
|
||||
|
||||
|
||||
def fetch_sentiment_metrics(metric, symbol, start=None, end=None, limit=None):
|
||||
"""情绪/资金面序列。只打 data_provider。"""
|
||||
params = {"metric": metric, "symbol": symbol}
|
||||
if start is not None:
|
||||
params["start"] = int(start)
|
||||
if end is not None:
|
||||
params["end"] = int(end)
|
||||
if limit is not None:
|
||||
params["limit"] = int(limit)
|
||||
return _provider_get("/api/sentiment/metrics", params)
|
||||
|
||||
|
||||
def fetch_sentiment_latest(symbol):
|
||||
"""情绪面最新快照。只打 data_provider。"""
|
||||
return _provider_get("/api/sentiment/latest", {"symbol": symbol})
|
||||
|
||||
|
||||
@@ -98,8 +98,14 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||
# 序列化unittf_list(兼容新结构与枚举类型)
|
||||
for unittf in chan_macd_data.get('unittf_list', []):
|
||||
try:
|
||||
dir_value = getattr(unittf, 'uinttf_dir', None)
|
||||
dir_value = getattr(unittf, 'unittf_dir', None)
|
||||
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
|
||||
if dir_name == 'ABOVE':
|
||||
dir_num = 1
|
||||
elif dir_name == 'UNDER':
|
||||
dir_num = -1
|
||||
else:
|
||||
dir_num = 0
|
||||
start_t = getattr(unittf, 'start_type', None)
|
||||
start_type = getattr(start_t, 'name', start_t)
|
||||
end_t = getattr(unittf, 'end_type', None)
|
||||
@@ -114,7 +120,7 @@ def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||
unittf_data = {
|
||||
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
|
||||
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
|
||||
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
|
||||
'dir': dir_num,
|
||||
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
|
||||
'end_type': end_type,
|
||||
'invalid': getattr(unittf, 'invalid', False),
|
||||
@@ -307,7 +313,9 @@ def get_uncompleted_seg_list(seg_list, client_tz):
|
||||
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||
'direction': convert_direction(seg.dir)
|
||||
'direction': convert_direction(seg.dir),
|
||||
'macd_div': float(getattr(seg, 'macd_div', 0) or 0),
|
||||
'macd_hist': float(getattr(seg, 'macd_hist', 0) or 0)
|
||||
}
|
||||
|
||||
if is_last:
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||
("5m", "5分钟"),
|
||||
("15m", "15分钟"),
|
||||
("30m", "30分钟"),
|
||||
("45m", "45分钟"),
|
||||
("1h", "1小时"),
|
||||
("2h", "2小时"),
|
||||
("4h", "4小时"),
|
||||
|
||||
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
默认偏好:主 4h、次 1h、次次 15m。
|
||||
默认偏好:主 45m、次 15m、次次 5m。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
|
||||
preferred_main = next((tf for tf in ['45m', '30m', '1h'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
|
||||
default_element = _prefer_smaller(['15m', '5m', '30m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['5m', '1m', '15m'], labels_ordered, default_element, timeframe_keys)
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
@@ -10,6 +10,33 @@ window.ChanApi = {
|
||||
symbols: function() {
|
||||
return fetch('/api/symbols').then(r => r.json());
|
||||
},
|
||||
derivatives: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/derivatives?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
sentimentLatest: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/sentiment/latest?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
sentimentMetrics: function(params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
return fetch('/api/sentiment/metrics?' + q.toString()).then(function(r) {
|
||||
return r.json().then(function(body) {
|
||||
if (!r.ok) throw new Error((body && body.error) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
},
|
||||
macdConfig: function(body) {
|
||||
if (body === undefined) return fetch('/api/macd_config').then(r => r.json());
|
||||
return fetch('/api/macd_config', {
|
||||
|
||||
@@ -229,29 +229,29 @@ function updateTradingViewData(options) {
|
||||
}
|
||||
|
||||
// 更新MACD数据
|
||||
if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) {
|
||||
// 提取MACD数据
|
||||
const macdKlineSrc = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || []));
|
||||
const macdSrc = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
if (tvWidget.series.macdLineSeries && macdSrc && macdKlineSrc && Array.isArray(macdKlineSrc)) {
|
||||
const macdData = [];
|
||||
const signalData = [];
|
||||
const histogramData = [];
|
||||
|
||||
for (let i = 0; i < currentData.kline_data.length; i++) {
|
||||
const kline = currentData.kline_data[i];
|
||||
for (let i = 0; i < macdKlineSrc.length; i++) {
|
||||
const kline = macdKlineSrc[i];
|
||||
const timestamp = Math.floor(new Date(kline.date).getTime() / 1000);
|
||||
|
||||
if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) {
|
||||
if (macdSrc && macdSrc.macd && macdSrc.macd[i] !== undefined) {
|
||||
macdData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.macd[i]
|
||||
value: macdSrc.macd[i]
|
||||
});
|
||||
|
||||
signalData.push({
|
||||
time: timestamp,
|
||||
value: currentData.macd.signal[i]
|
||||
value: macdSrc.signal[i]
|
||||
});
|
||||
|
||||
// 设置直方图颜色
|
||||
const histValue = currentData.macd.histogram[i];
|
||||
const histValue = macdSrc.histogram[i];
|
||||
histogramData.push({
|
||||
time: timestamp,
|
||||
value: histValue,
|
||||
@@ -309,6 +309,9 @@ function updateTradingViewData(options) {
|
||||
}
|
||||
);
|
||||
}
|
||||
if (typeof refreshUnittfOverlayFromData === 'function') {
|
||||
refreshUnittfOverlayFromData(currentData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('更新ChanMACD标注失败:', e);
|
||||
}
|
||||
@@ -331,7 +334,8 @@ function updateTradingViewData(options) {
|
||||
tvWidget.volumeChart,
|
||||
tvWidget.atrChart,
|
||||
tvWidget.macdChart,
|
||||
tvWidget.chanMacdChart
|
||||
tvWidget.chanMacdChart,
|
||||
tvWidget.sentimentChart
|
||||
].filter(Boolean);
|
||||
|
||||
const vr = clampedVisibleRange || savedVisibleRange;
|
||||
@@ -409,8 +413,11 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
volume: false,
|
||||
atr: false,
|
||||
macd: false,
|
||||
chanmacd: false
|
||||
chanmacd: false,
|
||||
sentiment: false
|
||||
};
|
||||
const sentimentChart = tvWidget && tvWidget.sentimentChart;
|
||||
const sentimentChartContainer = tvWidget && tvWidget.sentimentChartContainer;
|
||||
|
||||
// 同步图表的时间范围
|
||||
function syncCharts(sourceChart, sourceContainer) {
|
||||
@@ -438,6 +445,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
||||
try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
if (sentimentChart && sourceChart !== sentimentChart && sentimentChart.timeScale) {
|
||||
try { sentimentChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {}
|
||||
}
|
||||
|
||||
if (tvWidget && tvWidget.state) {
|
||||
tvWidget.state.logicalRange = logicalRange;
|
||||
@@ -458,7 +468,8 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
chart === volumeChart ? 'volume' :
|
||||
chart === atrChart ? 'atr' :
|
||||
chart === macdChart ? 'macd' :
|
||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
||||
chart === chanMacdChart ? 'chanmacd' :
|
||||
chart === sentimentChart ? 'sentiment' : 'unknown';
|
||||
|
||||
const timeRangeHandler = () => {
|
||||
if (!syncInProgress) {
|
||||
@@ -515,6 +526,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChartContainer && chanMacdChart) {
|
||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
||||
}
|
||||
if (sentimentChartContainer && sentimentChart) {
|
||||
addChartSyncEvents(sentimentChartContainer, sentimentChart);
|
||||
}
|
||||
|
||||
// 窗口大小变化时重绘图表 — 使用可清理的方式注册
|
||||
const resizeHandler = () => {
|
||||
@@ -533,6 +547,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai
|
||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
||||
chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight });
|
||||
}
|
||||
if (sentimentChart && sentimentChartContainer) {
|
||||
sentimentChart.applyOptions({ width: sentimentChartContainer.clientWidth, height: sentimentChartContainer.clientHeight });
|
||||
}
|
||||
setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200);
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
@@ -549,9 +566,11 @@ function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartCon
|
||||
// 初始化 U 显示状态(主/次周期分开控制)
|
||||
const isShowUMain = $('#toggleUOnMain').is(':checked');
|
||||
const isShowUElement = $('#toggleUOnElement').is(':checked');
|
||||
const isShowUSubSub = $('#toggleUOnSubSub').is(':checked');
|
||||
window.showUOnMain = isShowUMain;
|
||||
window.showUOnElement = isShowUElement;
|
||||
if (!isShowUMain && !isShowUElement) {
|
||||
window.showUOnSubSub = isShowUSubSub;
|
||||
if (!isShowUMain && !isShowUElement && !isShowUSubSub) {
|
||||
// 隐藏时清空子图上的 U 标记
|
||||
if (tvWidget.series && tvWidget.series.chanMacdLineSeries) {
|
||||
try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {}
|
||||
|
||||
@@ -169,7 +169,7 @@ function updateTables(currentData) {
|
||||
});
|
||||
|
||||
// 更新数据源信息显示
|
||||
const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期');
|
||||
const selectedPeriod = useSubSubPeriod ? '次次' : (useElementPeriod ? '次' : '主');
|
||||
const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val());
|
||||
$('#dataSourceText').html(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
|
||||
|
||||
@@ -297,55 +297,55 @@ function setupDataSourceInfo(data) {
|
||||
$('#kline-tab, #macd-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 数据`);
|
||||
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#bi-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 笔数据`);
|
||||
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 笔数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 笔数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#seg-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 线段数据`);
|
||||
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 线段数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 线段数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#zs-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 中枢数据`);
|
||||
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 中枢数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 中枢数据`);
|
||||
}
|
||||
});
|
||||
|
||||
$('#trade-points-tab').off('click').on('click', function() {
|
||||
$('.data-source-info').show();
|
||||
if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 买卖点数据`);
|
||||
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>次 (${elementTimeframe})</strong> 买卖点数据`);
|
||||
} else {
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
|
||||
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 买卖点数据`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function initTradingView(symbol, timeframe) {
|
||||
chartTvRenderChan(ctx);
|
||||
chartTvRenderOverlays(ctx);
|
||||
chartTvFinalize(ctx);
|
||||
if (window.ChanDeriv) ChanDeriv.loadOverlays();
|
||||
|
||||
console.log('图表初始化完成');
|
||||
} catch (e) {
|
||||
|
||||
@@ -178,38 +178,6 @@ function chartTvRenderChan(ctx) {
|
||||
color: bi.direction === 1 ? '#dc3545' : '#28a745',
|
||||
lineWidth: 1
|
||||
});
|
||||
|
||||
// 在笔的末端添加macd_div值标记
|
||||
if (bi.macd_div && bi.macd_div !== 0 && $('#showMainMacdDiv').is(':checked')) {
|
||||
console.log(`添加主周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
|
||||
|
||||
const macdDivLabel = mainChart.addLineSeries({
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
color: 'transparent', // 设置为透明色
|
||||
lineWidth: 0, // 线宽为0
|
||||
});
|
||||
|
||||
// 添加一个透明的数据点用于承载标记
|
||||
macdDivLabel.setData([
|
||||
{ time: endTime, value: endPrice }
|
||||
]);
|
||||
|
||||
// 主周期MACD背离标记根据笔方向显示,远离K线避免与分型重叠
|
||||
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
|
||||
const textColor = bi.macd_div > 0 ? '#dc3545' : '#28a745';
|
||||
|
||||
// 只使用标记,不添加数据点
|
||||
macdDivLabel.setMarkers([
|
||||
{
|
||||
time: endTime,
|
||||
position: markerPosition,
|
||||
color: textColor,
|
||||
text: `${bi.macd_div.toFixed(2)}`, // 添加M前缀区分
|
||||
size: 0.6, // 更小的尺寸,远离分型标记
|
||||
}
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('主周期笔处理出错:', e);
|
||||
}
|
||||
@@ -260,38 +228,6 @@ function chartTvRenderChan(ctx) {
|
||||
color: bi.direction === 1 ? '#9c27b0' : '#673ab7',
|
||||
lineWidth: 1
|
||||
});
|
||||
|
||||
// 在笔的末端添加macd_div值标记
|
||||
if (bi.macd_div && bi.macd_div !== 0 && $('#showElementMacdDiv').is(':checked')) {
|
||||
console.log(`添加元素周期macd_div标记: ${bi.macd_div.toFixed(2)}, 在时间点: ${endTime}`);
|
||||
|
||||
const macdDivLabel = mainChart.addLineSeries({
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
color: 'transparent', // 设置为透明色
|
||||
lineWidth: 0, // 线宽为0
|
||||
});
|
||||
|
||||
// 添加一个透明的数据点用于承载标记
|
||||
macdDivLabel.setData([
|
||||
{ time: endTime, value: endPrice }
|
||||
]);
|
||||
|
||||
// 次周期MACD背离标记使用不同位置,进一步避免重叠
|
||||
const markerPosition = bi.direction === 1 ? 'aboveBar' : 'belowBar';
|
||||
const textColor = bi.macd_div > 0 ? '#9c27b0' : '#673ab7';
|
||||
|
||||
// 只使用标记,不添加数据点
|
||||
macdDivLabel.setMarkers([
|
||||
{
|
||||
time: endTime,
|
||||
position: markerPosition,
|
||||
color: textColor,
|
||||
text: `${bi.macd_div.toFixed(2)}`, // 添加E前缀区分次周期
|
||||
size: 0.4, // 更小的尺寸,让分型标记有更多空间
|
||||
}
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('次周期笔处理出错:', e);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ function chartTvFinalize(ctx) {
|
||||
var atrChart = ctx.atrChart;
|
||||
var macdChart = ctx.macdChart;
|
||||
var chanMacdChart = ctx.chanMacdChart;
|
||||
var sentimentChart = ctx.sentimentChart;
|
||||
var sentimentChartContainer = ctx.sentimentChartContainer;
|
||||
var createChartOptions = ctx.createChartOptions;
|
||||
// 同步所有图表的时间轴配置
|
||||
const hasPendingRestoreView = !!window._pendingRestoreView;
|
||||
@@ -55,6 +57,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && macdChart) {
|
||||
macdChart.timeScale().applyOptions(baseOptions);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().applyOptions(baseOptions);
|
||||
}
|
||||
};
|
||||
|
||||
// 首先同步时间轴设置
|
||||
@@ -65,7 +70,8 @@ function chartTvFinalize(ctx) {
|
||||
const visibleBarsCount = 200;
|
||||
const allChartsNow = [mainChart, volumeChart, atrChart]
|
||||
.concat(showMacd && macdChart ? [macdChart] : [])
|
||||
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
|
||||
.concat(showMacd && chanMacdChart ? [chanMacdChart] : [])
|
||||
.concat(sentimentChart ? [sentimentChart] : []);
|
||||
const restoreOpts = function () {
|
||||
const firstT = candles && candles.length ? candles[0].time : null;
|
||||
const lastT = candles && candles.length ? candles[candles.length - 1].time : null;
|
||||
@@ -103,6 +109,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -117,6 +126,9 @@ function chartTvFinalize(ctx) {
|
||||
if (showMacd && chanMacdChart) {
|
||||
chanMacdChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
if (sentimentChart) {
|
||||
sentimentChart.timeScale().setVisibleLogicalRange(logRange);
|
||||
}
|
||||
console.log('🔧 时间轴同步完成');
|
||||
}
|
||||
}, 50);
|
||||
@@ -127,6 +139,8 @@ function chartTvFinalize(ctx) {
|
||||
tvWidget.atrChart = atrChart;
|
||||
tvWidget.macdChart = macdChart;
|
||||
tvWidget.chanMacdChart = chanMacdChart;
|
||||
tvWidget.sentimentChart = sentimentChart;
|
||||
tvWidget.sentimentChartContainer = sentimentChartContainer;
|
||||
tvWidget.state.isInitialized = true;
|
||||
// 注册窗口卸载时释放资源,避免GPU内存泄漏
|
||||
window.onbeforeunload = function() {
|
||||
@@ -137,6 +151,7 @@ function chartTvFinalize(ctx) {
|
||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove();
|
||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove();
|
||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove();
|
||||
if (tvWidget.sentimentChart && typeof tvWidget.sentimentChart.remove === 'function') tvWidget.sentimentChart.remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
@@ -220,6 +235,7 @@ function chartTvFinalize(ctx) {
|
||||
const allCharts = [mainChart, volumeChart, atrChart];
|
||||
if (showMacd && macdChart) allCharts.push(macdChart);
|
||||
if (showMacd && chanMacdChart) allCharts.push(chanMacdChart);
|
||||
if (sentimentChart) allCharts.push(sentimentChart);
|
||||
|
||||
// 检查是否有待恢复的视图(缩放 + 位置)
|
||||
const pending = window._pendingRestoreView || pendingView;
|
||||
@@ -243,7 +259,8 @@ function chartTvFinalize(ctx) {
|
||||
console.log('🔧 最终同步可见范围:', visibleRange);
|
||||
[volumeChart, atrChart].concat(
|
||||
showMacd && macdChart ? [macdChart] : [],
|
||||
showMacd && chanMacdChart ? [chanMacdChart] : []
|
||||
showMacd && chanMacdChart ? [chanMacdChart] : [],
|
||||
sentimentChart ? [sentimentChart] : []
|
||||
).forEach(c => {
|
||||
try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
/* chart_tv_indicators.js — volume / ATR / ChanMACD */
|
||||
|
||||
function formatSdMarkerText(separateDiv) {
|
||||
const n = Number(separateDiv);
|
||||
return `SD${n === 99999 ? 0 : n}`;
|
||||
}
|
||||
|
||||
function shouldShowSdMarker(separateDiv) {
|
||||
const n = Number(separateDiv);
|
||||
return isFinite(n) && n > 0 && n !== 99999;
|
||||
}
|
||||
|
||||
var SD_CD_MARKER_COLOR = '#dc3545';
|
||||
var SD_CD_MARKER_SIZE = 1.2;
|
||||
|
||||
function histArrowShape(pos) {
|
||||
return pos === 'belowBar' ? 'arrowDown' : 'arrowUp';
|
||||
}
|
||||
|
||||
function makeSdMarker(ts, pos, separateDiv) {
|
||||
return {
|
||||
time: ts,
|
||||
position: pos,
|
||||
color: SD_CD_MARKER_COLOR,
|
||||
shape: histArrowShape(pos),
|
||||
text: formatSdMarkerText(separateDiv),
|
||||
size: SD_CD_MARKER_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
function makeCdMarker(ts, pos) {
|
||||
return {
|
||||
time: ts,
|
||||
position: pos,
|
||||
color: SD_CD_MARKER_COLOR,
|
||||
shape: histArrowShape(pos),
|
||||
text: 'CD',
|
||||
size: SD_CD_MARKER_SIZE
|
||||
};
|
||||
}
|
||||
|
||||
function chartTvRenderIndicators(ctx) {
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
@@ -141,11 +180,11 @@ function chartTvRenderIndicators(ctx) {
|
||||
|
||||
// 使用与K线数据相同的数据源来确保时间对齐
|
||||
const klineDataSource = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? currentData.element_kline_data : currentData.kline_data);
|
||||
const macdDataSource = useElementPeriod ?
|
||||
(currentData.element_macd || currentData.macd) : // 如果有次周期MACD数据则使用,否则使用主周期
|
||||
currentData.macd; // 主周期使用主周期MACD数据
|
||||
const macdDataSource = useSubSubPeriod
|
||||
? (currentData.sub_sub_macd || currentData.macd)
|
||||
: (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd);
|
||||
|
||||
console.log('MACD数据源选择:', useElementPeriod ? '次周期' : '主周期');
|
||||
console.log('MACD数据源选择:', useSubSubPeriod ? '次次周期' : (useElementPeriod ? '次周期' : '主周期'));
|
||||
console.log('K线数据长度:', klineDataSource.length);
|
||||
console.log('MACD数据:', macdDataSource);
|
||||
|
||||
@@ -209,7 +248,7 @@ function chartTvRenderIndicators(ctx) {
|
||||
const chanMacdLineSeries = chanMacdChart.addLineSeries({
|
||||
color: '#2962FF',
|
||||
lineWidth: 1,
|
||||
title: 'ChanMACD',
|
||||
title: 'MACD',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
@@ -370,7 +409,8 @@ function chartTvRenderIndicators(ctx) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
|
||||
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
@@ -387,15 +427,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
mainMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
mainMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -409,15 +449,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
elementMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
elementMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -427,21 +467,24 @@ function chartTvRenderIndicators(ctx) {
|
||||
|
||||
const subSubCm = currentData.sub_sub_chan_macd || {};
|
||||
const subSubMarkers = [];
|
||||
const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data);
|
||||
const subSubMacdMap = buildMacdTimeMap(
|
||||
(currentData.sub_sub_macd || currentData.macd),
|
||||
(currentData.sub_sub_kline_data || currentData.kline_data)
|
||||
);
|
||||
if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) {
|
||||
subSubCm.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
subSubMarkers.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = subSubMacdMap.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
subSubMarkers.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -484,7 +527,8 @@ function chartTvRenderIndicators(ctx) {
|
||||
const k = klineArr[i];
|
||||
if (!k || !k.date) continue;
|
||||
const t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null;
|
||||
const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null;
|
||||
const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null);
|
||||
map.set(t, val);
|
||||
}
|
||||
return map;
|
||||
@@ -494,20 +538,24 @@ function chartTvRenderIndicators(ctx) {
|
||||
(currentData.element_macd || currentData.macd),
|
||||
(currentData.element_kline_data || currentData.kline_data)
|
||||
);
|
||||
const subSubMacdMapAll = buildMacdTimeMapAll(
|
||||
(currentData.sub_sub_macd || currentData.macd),
|
||||
(currentData.sub_sub_kline_data || currentData.kline_data)
|
||||
);
|
||||
if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) {
|
||||
mainCmAll.klu_list.forEach((item) => {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
mainMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = mainMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
mainMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -519,15 +567,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
elementMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
const macdVal = elementMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
elementMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -543,11 +591,15 @@ function chartTvRenderIndicators(ctx) {
|
||||
if (!item || !item.time) return;
|
||||
const ts = Math.floor(new Date(item.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
if (Number(item.separate_div) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: `SD${Number(item.separate_div)}`, size: 0.6 });
|
||||
if (shouldShowSdMarker(item.separate_div)) {
|
||||
const macdVal = subSubMacdMapAll.get(ts);
|
||||
const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar';
|
||||
subSubMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div));
|
||||
}
|
||||
if (item.continue_div === true) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 });
|
||||
const macdVal = subSubMacdMapAll.get(ts);
|
||||
const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar';
|
||||
subSubMarkersAll.push(makeCdMarker(ts, posCd));
|
||||
}
|
||||
if (item.near0_return && Number(item.near0_return) > 0) {
|
||||
subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 });
|
||||
@@ -555,6 +607,9 @@ function chartTvRenderIndicators(ctx) {
|
||||
});
|
||||
}
|
||||
window.kluDivMarkersSubSub = subSubMarkersAll;
|
||||
if (typeof refreshUnittfOverlayFromData === 'function') {
|
||||
refreshUnittfOverlayFromData(currentData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('独立计算 KLU 背驰标记出错:', e);
|
||||
window.kluDivMarkersMain = [];
|
||||
|
||||
@@ -25,7 +25,7 @@ function disposeTradingViewCharts() {
|
||||
}
|
||||
|
||||
if (tvWidget) {
|
||||
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
|
||||
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart', 'sentimentChart'].forEach(function (key) {
|
||||
try {
|
||||
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
|
||||
tvWidget[key].remove();
|
||||
|
||||
@@ -82,6 +82,97 @@ var SUB_SUB_KLC_TREND_STYLE = {
|
||||
UNKNOWN: { position: 'inBar', color: '#004d40', shape: 'square', size: 0.5 }
|
||||
};
|
||||
|
||||
function pushAreaTextLabel(out, item, style, text) {
|
||||
if (!item || !item.end_time || !text) return;
|
||||
var ts = Math.floor(new Date(item.end_time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
var price = Number(item.end_price);
|
||||
if (!isFinite(price)) price = Number(item.start_price);
|
||||
if (!isFinite(price)) return;
|
||||
var up = Number(item.direction) === 1;
|
||||
out.push({
|
||||
time: ts,
|
||||
price: price,
|
||||
text: text,
|
||||
color: style.color,
|
||||
above: up
|
||||
});
|
||||
}
|
||||
|
||||
function formatMacdAreaText(v) {
|
||||
var n = Number(v);
|
||||
if (!isFinite(n) || n === 0) return '';
|
||||
var abs = Math.abs(n);
|
||||
if (abs >= 100) return n.toFixed(0);
|
||||
if (abs >= 10) return n.toFixed(1);
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function pushAreaHistMarker(out, item, style) {
|
||||
pushAreaTextLabel(out, item, style, formatMacdAreaText(item && item.macd_hist));
|
||||
}
|
||||
|
||||
function collectAreaHistMarkers(biList, uncompletedBi, segList, uncompletedSeg, biStyle, segStyle, showBi, showSeg) {
|
||||
var out = [];
|
||||
if (showBi) {
|
||||
(biList || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
|
||||
(uncompletedBi || []).forEach(function (bi) { pushAreaHistMarker(out, bi, biStyle); });
|
||||
}
|
||||
if (showSeg) {
|
||||
(segList || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
|
||||
(uncompletedSeg || []).forEach(function (seg) { pushAreaHistMarker(out, seg, segStyle); });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildAreaHistMarkersFromData(data) {
|
||||
var markers = [];
|
||||
if (!data) return markers;
|
||||
var showMainBi = $('#showMainBiArea').is(':checked');
|
||||
var showMainSeg = $('#showMainSegArea').is(':checked');
|
||||
if (showMainBi || showMainSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.bi_list,
|
||||
data.uncompleted_bi_list,
|
||||
data.seg_list,
|
||||
data.uncompleted_seg_list,
|
||||
{ color: '#1565c0', size: 0.55 },
|
||||
{ color: '#00838f', size: 0.65 },
|
||||
showMainBi,
|
||||
showMainSeg
|
||||
));
|
||||
}
|
||||
var showElementBi = $('#showElementBiArea').is(':checked');
|
||||
var showElementSeg = $('#showElementSegArea').is(':checked');
|
||||
if (showElementBi || showElementSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.element_bi_list,
|
||||
data.element_uncompleted_bi_list,
|
||||
data.element_seg_list,
|
||||
data.element_uncompleted_seg_list,
|
||||
{ color: '#3949ab', size: 0.5 },
|
||||
{ color: '#5c6bc0', size: 0.6 },
|
||||
showElementBi,
|
||||
showElementSeg
|
||||
));
|
||||
}
|
||||
var showSubSubBi = $('#showSubSubBiArea').is(':checked');
|
||||
var showSubSubSeg = $('#showSubSubSegArea').is(':checked');
|
||||
if (showSubSubBi || showSubSubSeg) {
|
||||
markers = markers.concat(collectAreaHistMarkers(
|
||||
data.sub_sub_bi_list,
|
||||
data.sub_sub_uncompleted_bi_list,
|
||||
data.sub_sub_seg_list,
|
||||
data.sub_sub_uncompleted_seg_list,
|
||||
{ color: '#2e7d32', size: 0.45 },
|
||||
{ color: '#558b2f', size: 0.55 },
|
||||
showSubSubBi,
|
||||
showSubSubSeg
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function buildKlcTrendMarker(timeAligned, trendRaw, palette) {
|
||||
var kind = normalizeKlcTrendRaw(trendRaw);
|
||||
var style = palette[kind] || palette.UNKNOWN || palette.FLAT;
|
||||
@@ -136,6 +227,21 @@ function getMainPriceSeries() {
|
||||
s.lineSeries || s.areaSeries || s.baselineSeries || null;
|
||||
}
|
||||
|
||||
function plotLeftOffset(chart) {
|
||||
try {
|
||||
var left = chart && chart.priceScale && chart.priceScale('left');
|
||||
if (!left) return 0;
|
||||
var visible = true;
|
||||
try {
|
||||
var opts = left.options && left.options();
|
||||
if (opts && opts.visible === false) return 0;
|
||||
} catch (e) {}
|
||||
return (typeof left.width === 'function' ? left.width() : 0) || 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
if (!mainChart || !mainChartContainer) return;
|
||||
if (typeof window._fxBoxOverlayCleanup === 'function') {
|
||||
@@ -163,23 +269,38 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
// LWC 4 无 priceScale 订阅:采样坐标变化(含增量 setData 后自动缩放)
|
||||
var sampleSig = function () {
|
||||
var boxes = window._fxBoxVerticals || [];
|
||||
var labels = window._areaTextLabels || [];
|
||||
var series = getMainPriceSeries();
|
||||
if (!series || !boxes.length) return '0';
|
||||
if (!series || (!boxes.length && !labels.length)) return '0';
|
||||
var ts = mainChart.timeScale();
|
||||
var a = boxes[0];
|
||||
var b = boxes[boxes.length - 1];
|
||||
return [
|
||||
boxes.length,
|
||||
quant(ts.timeToCoordinate(a.time)),
|
||||
quant(series.priceToCoordinate(a.hi)),
|
||||
quant(series.priceToCoordinate(a.lo)),
|
||||
quant(ts.timeToCoordinate(b.time)),
|
||||
quant(series.priceToCoordinate(b.hi)),
|
||||
quant(series.priceToCoordinate(b.lo))
|
||||
].join('|');
|
||||
var parts = [boxes.length, labels.length, quant(plotLeftOffset(mainChart))];
|
||||
if (boxes.length) {
|
||||
var a = boxes[0];
|
||||
var b = boxes[boxes.length - 1];
|
||||
parts.push(
|
||||
quant(ts.timeToCoordinate(a.time)),
|
||||
quant(series.priceToCoordinate(a.hi)),
|
||||
quant(series.priceToCoordinate(a.lo)),
|
||||
quant(ts.timeToCoordinate(b.time)),
|
||||
quant(series.priceToCoordinate(b.hi)),
|
||||
quant(series.priceToCoordinate(b.lo))
|
||||
);
|
||||
}
|
||||
if (labels.length) {
|
||||
var la = labels[0];
|
||||
var lb = labels[labels.length - 1];
|
||||
parts.push(
|
||||
quant(ts.timeToCoordinate(la.time)),
|
||||
quant(series.priceToCoordinate(la.price)),
|
||||
quant(ts.timeToCoordinate(lb.time)),
|
||||
quant(series.priceToCoordinate(lb.price))
|
||||
);
|
||||
}
|
||||
return parts.join('|');
|
||||
};
|
||||
var redraw = function () {
|
||||
var boxes = window._fxBoxVerticals || [];
|
||||
var labels = window._areaTextLabels || [];
|
||||
var series = getMainPriceSeries();
|
||||
var rect = mainChartContainer.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
@@ -191,26 +312,41 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
if (!ctx2) return;
|
||||
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx2.clearRect(0, 0, rect.width, rect.height);
|
||||
if (!series || !boxes.length) {
|
||||
if (!series || (!boxes.length && !labels.length)) {
|
||||
lastSig = sampleSig();
|
||||
return;
|
||||
}
|
||||
var ts = mainChart.timeScale();
|
||||
var x0 = plotLeftOffset(mainChart);
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
var box = boxes[i];
|
||||
var x = ts.timeToCoordinate(box.time);
|
||||
var y1 = series.priceToCoordinate(box.hi);
|
||||
var y2 = series.priceToCoordinate(box.lo);
|
||||
if (x == null || y1 == null || y2 == null) continue;
|
||||
var px = Math.round(x + x0) + 0.5;
|
||||
ctx2.beginPath();
|
||||
ctx2.strokeStyle = box.color;
|
||||
ctx2.lineWidth = 1;
|
||||
ctx2.setLineDash([4, 3]);
|
||||
ctx2.moveTo(Math.round(x) + 0.5, y1);
|
||||
ctx2.lineTo(Math.round(x) + 0.5, y2);
|
||||
ctx2.moveTo(px, y1);
|
||||
ctx2.lineTo(px, y2);
|
||||
ctx2.stroke();
|
||||
}
|
||||
ctx2.setLineDash([]);
|
||||
if (labels.length) {
|
||||
ctx2.font = '11px sans-serif';
|
||||
ctx2.textAlign = 'center';
|
||||
for (var li = 0; li < labels.length; li++) {
|
||||
var lab = labels[li];
|
||||
var lx = ts.timeToCoordinate(lab.time);
|
||||
var ly = series.priceToCoordinate(lab.price);
|
||||
if (lx == null || ly == null) continue;
|
||||
ctx2.fillStyle = lab.color;
|
||||
ctx2.textBaseline = lab.above ? 'bottom' : 'top';
|
||||
ctx2.fillText(lab.text, Math.round(lx + x0), lab.above ? ly - 3 : ly + 3);
|
||||
}
|
||||
}
|
||||
lastSig = sampleSig();
|
||||
};
|
||||
var scheduleRedraw = function () {
|
||||
@@ -257,6 +393,7 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
|
||||
|
||||
function chartTvRenderOverlays(ctx) {
|
||||
window._fxBoxVerticals = [];
|
||||
window._areaTextLabels = [];
|
||||
var symbol = ctx.symbol;
|
||||
var timeframe = ctx.timeframe;
|
||||
var symbolConfig = ctx.symbolConfig;
|
||||
@@ -990,56 +1127,6 @@ function chartTvRenderOverlays(ctx) {
|
||||
window.bspMarkers = [];
|
||||
}
|
||||
|
||||
// 第四类买卖点(B4/S4):中枢突破回抽后当根入场,位置同 B3/S3 但早 7~8 根。
|
||||
// 与 BSP 分开收集,因为它数量远多于 B1/B2/B3,混在一个开关里图会糊掉。
|
||||
if ($('#showMainFastBsp').is(':checked') || $('#showElementFastBsp').is(':checked') || $('#showSubSubFastBsp').is(':checked')) {
|
||||
// 深色 = 区间套(大级别分型同向) + 中枢顺向推进都满足;浅色 = 未通过过滤
|
||||
const FAST_BSP_STYLE = {
|
||||
'BUY': { strong: '#FF6D00', weak: '#FFCC80', text: 'B4', position: 'belowBar' },
|
||||
'SELL': { strong: '#0091EA', weak: '#81D4FA', text: 'S4', position: 'aboveBar' },
|
||||
};
|
||||
const onlyFiltered = ($('#fastBspFilterMode').val() || 'all') === 'filtered';
|
||||
const allFastBspMarkers = [];
|
||||
|
||||
const collectFastBsp = function(list, prefix, label) {
|
||||
(list || []).forEach(function(bsp) {
|
||||
try {
|
||||
const ts = Math.floor(new Date(bsp.time).getTime() / 1000);
|
||||
if (isNaN(ts)) return;
|
||||
const style = FAST_BSP_STYLE[(bsp.dir || '').toUpperCase()];
|
||||
if (!style) return;
|
||||
const passed = !!(bsp.htf_agree && bsp.ladder_ok);
|
||||
if (onlyFiltered && !passed) return;
|
||||
allFastBspMarkers.push({
|
||||
time: ts,
|
||||
position: style.position,
|
||||
color: passed ? style.strong : style.weak,
|
||||
text: prefix + (passed ? style.text : style.text.toLowerCase()),
|
||||
size: passed ? 2 : 1
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(label + '第四类买卖点处理出错:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ($('#showMainFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.fast_bsp_list, '', '主周期');
|
||||
}
|
||||
if ($('#showElementFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.element_fast_bsp_list, 'e', '次周期');
|
||||
}
|
||||
if ($('#showSubSubFastBsp').is(':checked')) {
|
||||
collectFastBsp(currentData.sub_sub_fast_bsp_list, 's', '次次周期');
|
||||
}
|
||||
|
||||
allFastBspMarkers.sort((a, b) => a.time - b.time);
|
||||
window.fastBspMarkers = allFastBspMarkers;
|
||||
console.log(`绘制第四类买卖点,共${allFastBspMarkers.length}个标记(${onlyFiltered ? '仅过滤后' : '全部'})`);
|
||||
} else {
|
||||
window.fastBspMarkers = [];
|
||||
}
|
||||
|
||||
// 添加买卖点标记(旧版,保留兼容)
|
||||
// 这里为了与主面板上的「买卖点」开关保持一致,
|
||||
// 同时响应顶部的 `#showMainBsp` 复选框
|
||||
@@ -1615,7 +1702,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineStyle: 2, // 虚线
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林上轨'
|
||||
title: '次布林上轨'
|
||||
});
|
||||
elementUpperBandSeries.setData(elementUpperBandData);
|
||||
|
||||
@@ -1626,7 +1713,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineStyle: 2, // 虚线
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林下轨'
|
||||
title: '次布林下轨'
|
||||
});
|
||||
elementLowerBandSeries.setData(elementLowerBandData);
|
||||
|
||||
@@ -1636,7 +1723,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
lineWidth: 1,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '次周期布林中轨'
|
||||
title: '次布林中轨'
|
||||
});
|
||||
elementMiddleBandSeries.setData(elementMiddleBandData);
|
||||
|
||||
@@ -1751,7 +1838,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const fxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
主${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1815,7 +1902,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const fxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
主${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1852,6 +1939,14 @@ function chartTvRenderOverlays(ctx) {
|
||||
window.mainFxMarkers = [];
|
||||
window.fxMarkers = [];
|
||||
}
|
||||
window._areaTextLabels = alignMarkersToCandles(
|
||||
buildAreaHistMarkersFromData(currentData),
|
||||
candles
|
||||
);
|
||||
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
}
|
||||
|
||||
// 绘制小周期分型标记(含次次周期)
|
||||
if (($('#showElementKlcFxType').is(':checked') && currentData.element_klc_fx_info && currentData.element_klc_fx_info.length > 0) ||
|
||||
($('#showElementKluFxType').is(':checked') && currentData.element_klu_fx_info && currentData.element_klu_fx_info.length > 0) ||
|
||||
@@ -1940,7 +2035,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const elementFxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
次${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -1996,7 +2091,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
const elementFxMarker = {
|
||||
time: timestamp,
|
||||
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
|
||||
小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
次${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
|
||||
强度分数: ${fx.fx_strength}分<br>
|
||||
强度等级: ${fx.fx_strength_level}<br>
|
||||
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
|
||||
@@ -2157,7 +2252,7 @@ function chartTvRenderOverlays(ctx) {
|
||||
trendMarkersToUse = trendMarkersToUse.concat(subSubMarkers);
|
||||
}
|
||||
|
||||
// 合并标记并设置
|
||||
// 合并标记并设置(主图不画 U/穿零轴,只留背驰 SD/CD)
|
||||
const combinedMarkers = [
|
||||
...(window.mainFxMarkers || []),
|
||||
...allElementFxMarkers,
|
||||
@@ -2165,17 +2260,15 @@ function chartTvRenderOverlays(ctx) {
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || []),
|
||||
...(window.fastBspMarkers || [])
|
||||
...(window.bspMarkers || [])
|
||||
];
|
||||
if (combinedMarkers.length > 0) {
|
||||
console.log(
|
||||
'合并设置', combinedMarkers.length, '个标记(主周期分型:',
|
||||
(window.mainFxMarkers || []).length,
|
||||
'个,小周期分型:', allElementFxMarkers.length,
|
||||
'个,UnitTF:', (window.unittfMarkers || []).length,
|
||||
'个,背驰:', (window.kluDivMarkersMain || []).length,
|
||||
'个,BSP标记:', (window.bspMarkers || []).length,
|
||||
'个,第四类标记:', (window.fastBspMarkers || []).length,
|
||||
'个)'
|
||||
);
|
||||
|
||||
@@ -2277,18 +2370,17 @@ function chartTvRenderOverlays(ctx) {
|
||||
// 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记
|
||||
// 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记,
|
||||
// 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。
|
||||
// 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。
|
||||
// 修复:把 BSP 标记一并合并进来。两处都要带上,否则后面这次 setMarkers 会把买卖点盖掉。
|
||||
const onlyMainAndU = [
|
||||
...(window.mainFxMarkers || []),
|
||||
...(window.kluDivMarkersMain || []),
|
||||
...(window.kluDivMarkersElement || []),
|
||||
...(window.kluDivMarkersSubSub || []),
|
||||
...trendMarkersToUse,
|
||||
...(window.bspMarkers || []),
|
||||
...(window.fastBspMarkers || [])
|
||||
...(window.bspMarkers || [])
|
||||
];
|
||||
if (onlyMainAndU.length > 0) {
|
||||
console.log('仅设置', onlyMainAndU.length, '个主周期/UnitTF标记(主周期分型:', (window.mainFxMarkers || []).length, ',UnitTF:', (window.unittfMarkers || []).length, ')');
|
||||
console.log('仅设置', onlyMainAndU.length, '个主图标记(主周期分型:', (window.mainFxMarkers || []).length, ',背驰:', (window.kluDivMarkersMain || []).length, ')');
|
||||
|
||||
// 根据当前主系列类型设置标记
|
||||
const klineType2 = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
|
||||
@@ -77,6 +77,7 @@ function chartTvBuildShell(ctx) {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
macdChart: null,
|
||||
sentimentChart: null,
|
||||
series: {
|
||||
candleSeries: null,
|
||||
lineSeries: null,
|
||||
@@ -125,6 +126,7 @@ function chartTvBuildShell(ctx) {
|
||||
// 是否显示MACD
|
||||
const showMacd = $('#showMacd').is(':checked');
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
const showSentiment = ($('#dataSource').val() || 'crypto') === 'crypto' && $('#showDeriv').is(':checked');
|
||||
|
||||
// 创建主图容器
|
||||
const mainChartContainer = document.createElement('div');
|
||||
@@ -153,25 +155,20 @@ function chartTvBuildShell(ctx) {
|
||||
// 如果需要显示MACD,创建MACD容器
|
||||
let macdChartContainer = null;
|
||||
let chanMacdChartContainer = null;
|
||||
if (showMacd) {
|
||||
// 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度
|
||||
// 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%)
|
||||
mainChartContainer.style.height = '40%';
|
||||
|
||||
// 隐藏旧 MACD 容器(不创建)
|
||||
// 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%)
|
||||
let sentimentChartContainer = null;
|
||||
if (showMacd && showSentiment) {
|
||||
mainChartContainer.style.height = '34%';
|
||||
chanMacdChartContainer = document.createElement('div');
|
||||
chanMacdChartContainer.style.width = '100%';
|
||||
chanMacdChartContainer.style.height = '30%';
|
||||
chanMacdChartContainer.style.height = 'calc(22% - 50px)';
|
||||
chanMacdChartContainer.style.position = 'absolute';
|
||||
chanMacdChartContainer.style.top = '40%';
|
||||
chanMacdChartContainer.style.top = '34%';
|
||||
chanMacdChartContainer.style.left = '0';
|
||||
chanMacdChartContainer.style.right = '0';
|
||||
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
chanMacdChartContainer.style.zIndex = '10';
|
||||
// 水印:便于区分是新的 ChanMACD 子图
|
||||
const chanMacdWatermark = document.createElement('div');
|
||||
chanMacdWatermark.textContent = 'ChanMACD';
|
||||
chanMacdWatermark.textContent = 'MACD';
|
||||
chanMacdWatermark.style.position = 'absolute';
|
||||
chanMacdWatermark.style.top = '4px';
|
||||
chanMacdWatermark.style.left = '8px';
|
||||
@@ -179,31 +176,81 @@ function chartTvBuildShell(ctx) {
|
||||
chanMacdWatermark.style.color = '#888';
|
||||
chanMacdWatermark.style.pointerEvents = 'none';
|
||||
chanMacdChartContainer.appendChild(chanMacdWatermark);
|
||||
|
||||
// 成交量位于 ChanMACD 之下
|
||||
volumeChartContainer.style.top = '70%';
|
||||
volumeChartContainer.style.height = '17.5%';
|
||||
|
||||
// ATR 位于最底部
|
||||
atrChartContainer.style.top = '87.5%';
|
||||
atrChartContainer.style.height = '12.5%';
|
||||
volumeChartContainer.style.top = 'calc(56% - 50px)';
|
||||
volumeChartContainer.style.height = 'calc(12% + 20px)';
|
||||
atrChartContainer.style.top = 'calc(68% - 30px)';
|
||||
atrChartContainer.style.height = 'calc(10% - 20px)';
|
||||
} else if (showMacd) {
|
||||
mainChartContainer.style.height = '40%';
|
||||
chanMacdChartContainer = document.createElement('div');
|
||||
chanMacdChartContainer.style.width = '100%';
|
||||
chanMacdChartContainer.style.height = 'calc(30% - 50px)';
|
||||
chanMacdChartContainer.style.position = 'absolute';
|
||||
chanMacdChartContainer.style.top = '40%';
|
||||
chanMacdChartContainer.style.left = '0';
|
||||
chanMacdChartContainer.style.right = '0';
|
||||
chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
chanMacdChartContainer.style.zIndex = '10';
|
||||
const chanMacdWatermark = document.createElement('div');
|
||||
chanMacdWatermark.textContent = 'MACD';
|
||||
chanMacdWatermark.style.position = 'absolute';
|
||||
chanMacdWatermark.style.top = '4px';
|
||||
chanMacdWatermark.style.left = '8px';
|
||||
chanMacdWatermark.style.fontSize = '11px';
|
||||
chanMacdWatermark.style.color = '#888';
|
||||
chanMacdWatermark.style.pointerEvents = 'none';
|
||||
chanMacdChartContainer.appendChild(chanMacdWatermark);
|
||||
volumeChartContainer.style.top = 'calc(70% - 50px)';
|
||||
volumeChartContainer.style.height = 'calc(17.5% + 20px)';
|
||||
atrChartContainer.style.top = 'calc(87.5% - 30px)';
|
||||
atrChartContainer.style.height = 'calc(12.5% - 20px)';
|
||||
} else if (showSentiment) {
|
||||
mainChartContainer.style.height = '48%';
|
||||
volumeChartContainer.style.top = '48%';
|
||||
volumeChartContainer.style.height = '14%';
|
||||
atrChartContainer.style.top = '62%';
|
||||
atrChartContainer.style.height = '12%';
|
||||
} else {
|
||||
// 不显示MACD时的高度 - 主图、成交量图和ATR图分配
|
||||
mainChartContainer.style.height = '55%'; // 主图占55%
|
||||
mainChartContainer.style.height = '55%';
|
||||
volumeChartContainer.style.top = '55%';
|
||||
volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5%
|
||||
|
||||
atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始
|
||||
atrChartContainer.style.height = '22.5%'; // ATR图占22.5%
|
||||
volumeChartContainer.style.height = '22.5%';
|
||||
atrChartContainer.style.top = '77.5%';
|
||||
atrChartContainer.style.height = '22.5%';
|
||||
}
|
||||
if (showSentiment) {
|
||||
sentimentChartContainer = document.createElement('div');
|
||||
sentimentChartContainer.style.width = '100%';
|
||||
sentimentChartContainer.style.position = 'absolute';
|
||||
sentimentChartContainer.style.left = '0';
|
||||
sentimentChartContainer.style.right = '0';
|
||||
sentimentChartContainer.style.borderTop = '1px solid #e0e0e0';
|
||||
if (showMacd) {
|
||||
sentimentChartContainer.style.top = '78%';
|
||||
sentimentChartContainer.style.height = '22%';
|
||||
} else {
|
||||
sentimentChartContainer.style.top = '74%';
|
||||
sentimentChartContainer.style.height = '26%';
|
||||
}
|
||||
const sentimentWatermark = document.createElement('div');
|
||||
sentimentWatermark.textContent = '衍生品 买卖比 / 多空 / 大户 / 费率';
|
||||
sentimentWatermark.style.position = 'absolute';
|
||||
sentimentWatermark.style.top = '4px';
|
||||
sentimentWatermark.style.left = '8px';
|
||||
sentimentWatermark.style.fontSize = '11px';
|
||||
sentimentWatermark.style.color = '#888';
|
||||
sentimentWatermark.style.pointerEvents = 'none';
|
||||
sentimentChartContainer.appendChild(sentimentWatermark);
|
||||
}
|
||||
|
||||
container.appendChild(mainChartContainer);
|
||||
container.appendChild(volumeChartContainer);
|
||||
container.appendChild(atrChartContainer);
|
||||
if (showMacd) {
|
||||
// 只追加新的 ChanMACD 容器
|
||||
container.appendChild(chanMacdChartContainer);
|
||||
}
|
||||
if (showSentiment && sentimentChartContainer) {
|
||||
container.appendChild(sentimentChartContainer);
|
||||
}
|
||||
|
||||
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||
|
||||
@@ -221,6 +268,8 @@ function chartTvBuildShell(ctx) {
|
||||
chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0;
|
||||
} else if (chartType === 'chanmacd') {
|
||||
chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0;
|
||||
} else if (chartType === 'sentiment') {
|
||||
chartHeight = sentimentChartContainer ? sentimentChartContainer.clientHeight : 0;
|
||||
} else {
|
||||
chartHeight = mainChartContainer.clientHeight;
|
||||
}
|
||||
@@ -376,9 +425,26 @@ function chartTvBuildShell(ctx) {
|
||||
// 创建MACD图表(如果需要):仅创建新的 ChanMACD 图
|
||||
let macdChart = null;
|
||||
let chanMacdChart = null;
|
||||
let sentimentChart = null;
|
||||
if (showMacd) {
|
||||
chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd'));
|
||||
}
|
||||
if (showSentiment && sentimentChartContainer) {
|
||||
sentimentChart = LightweightCharts.createChart(sentimentChartContainer, createChartOptions(false, 'sentiment'));
|
||||
if (candles.length) {
|
||||
const axisSeries = sentimentChart.addLineSeries({
|
||||
priceScaleId: '__time',
|
||||
color: 'rgba(0,0,0,0)',
|
||||
lineWidth: 0,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false
|
||||
});
|
||||
sentimentChart.priceScale('__time').applyOptions({ visible: false });
|
||||
axisSeries.setData(candles.map(function (c) { return { time: c.time, value: 0 }; }));
|
||||
tvWidget.series.sentimentAxisSeries = axisSeries;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建主价格系列并设置数据(支持多种图表类型)
|
||||
(function(){
|
||||
@@ -495,10 +561,13 @@ function chartTvBuildShell(ctx) {
|
||||
ctx.atrChartContainer = atrChartContainer;
|
||||
ctx.macdChartContainer = macdChartContainer;
|
||||
ctx.chanMacdChartContainer = chanMacdChartContainer;
|
||||
ctx.sentimentChartContainer = sentimentChartContainer;
|
||||
ctx.showSentiment = showSentiment;
|
||||
ctx.mainChart = mainChart;
|
||||
ctx.volumeChart = volumeChart;
|
||||
ctx.atrChart = atrChart;
|
||||
ctx.macdChart = macdChart;
|
||||
ctx.chanMacdChart = chanMacdChart;
|
||||
ctx.sentimentChart = sentimentChart;
|
||||
ctx.createChartOptions = createChartOptions;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,10 @@ function readChartFormContext() {
|
||||
return {
|
||||
dataSource: dataSource,
|
||||
symbol: symbol,
|
||||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h',
|
||||
timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '45m',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai',
|
||||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m',
|
||||
subSubTimeframe: $('#subSubTimeframe').val() || '',
|
||||
elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '15m',
|
||||
subSubTimeframe: $('#subSubTimeframe').val() || window.DEFAULT_SUB_SUB_TIMEFRAME || '5m',
|
||||
startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null,
|
||||
endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null
|
||||
};
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/* 资金面 + 情绪面。只打本站中转。OI 叠主图左侧,其余叠情绪副图。 */
|
||||
window.ChanDeriv = (function () {
|
||||
var snapshotReq = 0;
|
||||
var latestReq = 0;
|
||||
var overlayReq = 0;
|
||||
var caches = {};
|
||||
|
||||
var METRICS = [
|
||||
{
|
||||
id: 'oi',
|
||||
checkbox: 'showOi',
|
||||
metric: 'open_interest_history',
|
||||
field: 'open_interest_amount',
|
||||
seriesKey: 'oiSeries',
|
||||
target: 'main',
|
||||
color: 'rgba(123, 31, 162, 0.85)',
|
||||
title: 'OI',
|
||||
priceFormat: { type: 'volume' }
|
||||
},
|
||||
{
|
||||
id: 'taker',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'taker_buy_sell_ratio',
|
||||
field: 'buy_sell_ratio',
|
||||
seriesKey: 'takerSeries',
|
||||
target: 'sentiment',
|
||||
color: '#0d9488',
|
||||
title: '买卖比',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'lsAccount',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'long_short_account_ratio',
|
||||
field: 'long_short_ratio',
|
||||
seriesKey: 'lsAccountSeries',
|
||||
target: 'sentiment',
|
||||
color: '#2563eb',
|
||||
title: '多空',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'lsTop',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'top_long_short_position_ratio',
|
||||
field: 'long_short_ratio',
|
||||
seriesKey: 'lsTopSeries',
|
||||
target: 'sentiment',
|
||||
color: '#ea580c',
|
||||
title: '大户',
|
||||
priceFormat: { type: 'price', precision: 3 }
|
||||
},
|
||||
{
|
||||
id: 'funding',
|
||||
checkbox: 'showDeriv',
|
||||
metric: 'funding_rate_history',
|
||||
field: 'funding_rate',
|
||||
seriesKey: 'fundingHistSeries',
|
||||
target: 'sentiment',
|
||||
color: '#7c3aed',
|
||||
title: '费率%',
|
||||
scale: 'funding',
|
||||
mul: 100,
|
||||
priceFormat: { type: 'price', precision: 4 }
|
||||
}
|
||||
];
|
||||
|
||||
function isCrypto() {
|
||||
return ($('#dataSource').val() || 'crypto') === 'crypto';
|
||||
}
|
||||
|
||||
function currentSymbol() {
|
||||
return $('#symbol').val() || 'BTC/USDT:USDT';
|
||||
}
|
||||
|
||||
function fmtOi(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
function fmtFunding(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return (Number(n) * 100).toFixed(4) + '%';
|
||||
}
|
||||
|
||||
function fmtChg(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
var v = Number(n);
|
||||
return (v > 0 ? '+' : '') + v.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function fmtBasis(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
var v = Number(n);
|
||||
return (v > 0 ? '+' : '') + v.toFixed(2);
|
||||
}
|
||||
|
||||
function fmtRatio(n) {
|
||||
if (n == null || !isFinite(Number(n))) return '—';
|
||||
return Number(n).toFixed(3);
|
||||
}
|
||||
|
||||
function setChip(id, text, tone) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove('up', 'down');
|
||||
if (tone) el.classList.add(tone);
|
||||
}
|
||||
|
||||
function tone(n, invert) {
|
||||
if (!isFinite(n) || n === 0) return null;
|
||||
var up = n > 0;
|
||||
if (invert) up = !up;
|
||||
return up ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function renderDerivEmpty() {
|
||||
setChip('derivOi', 'OI —');
|
||||
setChip('derivOiChg', 'Δ —');
|
||||
setChip('derivFunding', '费率 —');
|
||||
setChip('derivBasis', '基差 —');
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = '';
|
||||
}
|
||||
|
||||
function renderSentimentEmpty() {
|
||||
setChip('derivTaker', '买卖比 —');
|
||||
setChip('derivLs', '多空 —');
|
||||
setChip('derivTop', '大户 —');
|
||||
}
|
||||
|
||||
function setVisible(on) {
|
||||
var bar = document.getElementById('derivBar');
|
||||
var wrap = document.getElementById('showSentimentWrap');
|
||||
if (bar) bar.style.display = on ? '' : 'none';
|
||||
if (wrap) wrap.style.display = on ? '' : 'none';
|
||||
if (!on) clearAllSeries();
|
||||
}
|
||||
|
||||
function activeKlines() {
|
||||
var data = (typeof currentData !== 'undefined') ? currentData : null;
|
||||
if (!data) return [];
|
||||
if ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) return data.sub_sub_kline_data;
|
||||
if ($('#elementPeriodKline').is(':checked') && data.element_kline_data) return data.element_kline_data;
|
||||
return data.kline_data || [];
|
||||
}
|
||||
|
||||
function klineRangeMs() {
|
||||
var rows = activeKlines();
|
||||
if (!rows.length) return null;
|
||||
var start = new Date(rows[0].date).getTime();
|
||||
var end = new Date(rows[rows.length - 1].date).getTime();
|
||||
if (!isFinite(start) || !isFinite(end)) return null;
|
||||
return { start: start, end: end };
|
||||
}
|
||||
|
||||
function toPoints(rows, field, mul) {
|
||||
var out = [];
|
||||
var lastT = null;
|
||||
var factor = mul || 1;
|
||||
(rows || []).forEach(function (row) {
|
||||
var ms = Number(row.timestamp);
|
||||
var val = Number(row[field]);
|
||||
if (!isFinite(ms) || !isFinite(val)) return;
|
||||
var t = Math.floor(ms / 1000);
|
||||
var v = val * factor;
|
||||
if (lastT === t) {
|
||||
out[out.length - 1].value = v;
|
||||
return;
|
||||
}
|
||||
lastT = t;
|
||||
out.push({ time: t, value: v });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function candleTimesSec() {
|
||||
var rows = activeKlines();
|
||||
var times = [];
|
||||
(rows || []).forEach(function (k) {
|
||||
var t = Math.floor(new Date(k.date).getTime() / 1000);
|
||||
if (isFinite(t)) times.push(t);
|
||||
});
|
||||
return times;
|
||||
}
|
||||
|
||||
function alignToTimes(src, times) {
|
||||
if (!src || !src.length || !times || !times.length) return [];
|
||||
var out = [];
|
||||
var j = 0;
|
||||
var lastVal;
|
||||
for (var i = 0; i < times.length; i++) {
|
||||
var t = times[i];
|
||||
while (j < src.length && src[j].time <= t) {
|
||||
lastVal = src[j].value;
|
||||
j++;
|
||||
}
|
||||
if (lastVal !== undefined) out.push({ time: t, value: lastVal });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncSentimentTime() {
|
||||
if (!tvWidget || !tvWidget.mainChart || !tvWidget.sentimentChart) return;
|
||||
try {
|
||||
var vr = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
var lr = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
var opts = tvWidget.mainChart.timeScale().options ? tvWidget.mainChart.timeScale().options() : null;
|
||||
if (opts) {
|
||||
tvWidget.sentimentChart.timeScale().applyOptions({
|
||||
barSpacing: opts.barSpacing,
|
||||
rightOffset: opts.rightOffset
|
||||
});
|
||||
}
|
||||
if (vr) tvWidget.sentimentChart.timeScale().setVisibleRange(vr);
|
||||
if (lr) tvWidget.sentimentChart.timeScale().setVisibleLogicalRange(lr);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function targetChart(spec) {
|
||||
if (!tvWidget) return null;
|
||||
if (spec.target === 'main') return tvWidget.mainChart;
|
||||
return tvWidget.sentimentChart || null;
|
||||
}
|
||||
|
||||
function safeRemove(chart, seriesKey) {
|
||||
var series = tvWidget && tvWidget.series && tvWidget.series[seriesKey];
|
||||
if (series && chart) {
|
||||
try { chart.removeSeries(series); } catch (e) {}
|
||||
}
|
||||
if (tvWidget && tvWidget.series) tvWidget.series[seriesKey] = null;
|
||||
}
|
||||
|
||||
function clearAllSeries() {
|
||||
METRICS.forEach(function (spec) {
|
||||
safeRemove(targetChart(spec), spec.seriesKey);
|
||||
});
|
||||
safeRemove(tvWidget && tvWidget.sentimentChart, 'sentimentBaseSeries');
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function drawSeries(spec, points) {
|
||||
var chart = targetChart(spec);
|
||||
if (!chart || !points || !points.length) return;
|
||||
safeRemove(chart, spec.seriesKey);
|
||||
if (spec.target === 'main') {
|
||||
chart.applyOptions({
|
||||
leftPriceScale: {
|
||||
visible: true,
|
||||
borderVisible: false,
|
||||
scaleMargins: { top: 0.08, bottom: 0.12 }
|
||||
}
|
||||
});
|
||||
}
|
||||
var opts = {
|
||||
color: spec.color,
|
||||
lineWidth: 1,
|
||||
title: spec.title,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: false,
|
||||
priceFormat: spec.priceFormat
|
||||
};
|
||||
if (spec.target === 'main') opts.priceScaleId = 'left';
|
||||
if (spec.scale) {
|
||||
opts.priceScaleId = spec.scale;
|
||||
chart.priceScale(spec.scale).applyOptions({
|
||||
scaleMargins: { top: 0.15, bottom: 0.1 },
|
||||
borderVisible: false
|
||||
});
|
||||
}
|
||||
var series = chart.addLineSeries(opts);
|
||||
series.setData(points);
|
||||
tvWidget.series[spec.seriesKey] = series;
|
||||
if (spec.target === 'sentiment') syncSentimentTime();
|
||||
if (spec.target === 'main' && typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
setTimeout(window._redrawFxBoxVerticalOverlay, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function drawRatioBaseline(points) {
|
||||
var chart = tvWidget && tvWidget.sentimentChart;
|
||||
if (!chart || !points || !points.length) return;
|
||||
safeRemove(chart, 'sentimentBaseSeries');
|
||||
var baseline = points.map(function (p) { return { time: p.time, value: 1 }; });
|
||||
var series = chart.addLineSeries({
|
||||
color: 'rgba(120, 120, 120, 0.45)',
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
title: '1.0'
|
||||
});
|
||||
series.setData(baseline);
|
||||
tvWidget.series.sentimentBaseSeries = series;
|
||||
}
|
||||
|
||||
function loadOne(spec, range, symbol, req) {
|
||||
var checked = $('#' + spec.checkbox).is(':checked');
|
||||
var chart = targetChart(spec);
|
||||
if (!checked) {
|
||||
safeRemove(chart, spec.seriesKey);
|
||||
if (spec.id === 'oi') {
|
||||
try {
|
||||
if (tvWidget && tvWidget.mainChart) {
|
||||
tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!chart) return;
|
||||
var times = candleTimesSec();
|
||||
var key = [symbol, spec.metric, times[0] || '', times[times.length - 1] || '', times.length].join(':');
|
||||
var cached = caches[spec.id];
|
||||
function paint(raw) {
|
||||
var aligned = spec.target === 'main' || spec.target === 'sentiment' ? alignToTimes(raw, times) : raw;
|
||||
if (!aligned.length) aligned = raw;
|
||||
drawSeries(spec, aligned);
|
||||
if (spec.target === 'sentiment') maybeDrawBaseline();
|
||||
}
|
||||
if (cached && cached.raw && cached.raw.length) {
|
||||
paint(cached.raw);
|
||||
if (cached.key === key) return;
|
||||
}
|
||||
if (!range || !window.ChanApi || !ChanApi.sentimentMetrics) return;
|
||||
ChanApi.sentimentMetrics({
|
||||
metric: spec.metric,
|
||||
symbol: symbol,
|
||||
start: range.start - 8 * 60 * 60 * 1000,
|
||||
end: range.end,
|
||||
limit: 2000
|
||||
}).then(function (payload) {
|
||||
if (req !== overlayReq) return;
|
||||
var raw = toPoints(payload && payload.data, spec.field, spec.mul);
|
||||
if (!raw.length) return;
|
||||
caches[spec.id] = { key: key, raw: raw };
|
||||
if ($('#' + spec.checkbox).is(':checked') && targetChart(spec)) paint(raw);
|
||||
}).catch(function (err) {
|
||||
if (req !== overlayReq) return;
|
||||
console.warn(spec.title + ' 序列不可用', err);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeDrawBaseline() {
|
||||
var times = candleTimesSec();
|
||||
if (!times.length) return;
|
||||
drawRatioBaseline(times.map(function (t) { return { time: t, value: 1 }; }));
|
||||
syncSentimentTime();
|
||||
}
|
||||
|
||||
function loadOverlays() {
|
||||
if (!isCrypto()) {
|
||||
clearAllSeries();
|
||||
return;
|
||||
}
|
||||
if (!tvWidget || !tvWidget.mainChart) return;
|
||||
var range = klineRangeMs();
|
||||
var symbol = currentSymbol();
|
||||
var req = ++overlayReq;
|
||||
METRICS.forEach(function (spec) {
|
||||
loadOne(spec, range, symbol, req);
|
||||
});
|
||||
maybeDrawBaseline();
|
||||
}
|
||||
|
||||
function loadSnapshot() {
|
||||
if (!isCrypto() || !window.ChanApi || !ChanApi.derivatives) return;
|
||||
var req = ++snapshotReq;
|
||||
ChanApi.derivatives({ symbol: currentSymbol() }).then(function (row) {
|
||||
if (req !== snapshotReq) return;
|
||||
var chg = Number(row.oi_change_pct);
|
||||
var fund = Number(row.funding_rate);
|
||||
var basis = Number(row.basis);
|
||||
setChip('derivOi', 'OI ' + fmtOi(row.open_interest));
|
||||
setChip('derivOiChg', 'Δ ' + fmtChg(row.oi_change_pct), tone(chg));
|
||||
setChip('derivFunding', '费率 ' + fmtFunding(row.funding_rate), tone(fund));
|
||||
setChip('derivBasis', '基差 ' + fmtBasis(row.basis), tone(basis));
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = row.exchange || '';
|
||||
}).catch(function (err) {
|
||||
if (req !== snapshotReq) return;
|
||||
console.warn('资金面快照不可用', err);
|
||||
renderDerivEmpty();
|
||||
var src = document.getElementById('derivSrc');
|
||||
if (src) src.textContent = '不可用';
|
||||
});
|
||||
}
|
||||
|
||||
function loadLatest() {
|
||||
if (!isCrypto() || !window.ChanApi || !ChanApi.sentimentLatest) return;
|
||||
var req = ++latestReq;
|
||||
ChanApi.sentimentLatest({ symbol: currentSymbol() }).then(function (payload) {
|
||||
if (req !== latestReq) return;
|
||||
var data = (payload && payload.data) || {};
|
||||
var taker = data.taker_buy_sell_ratio || {};
|
||||
var ls = data.long_short_account_ratio || {};
|
||||
var top = data.top_long_short_position_ratio || {};
|
||||
var takerN = Number(taker.buy_sell_ratio);
|
||||
var lsN = Number(ls.long_short_ratio);
|
||||
var topN = Number(top.long_short_ratio);
|
||||
setChip('derivTaker', '买卖比 ' + fmtRatio(taker.buy_sell_ratio), isFinite(takerN) ? (takerN >= 1 ? 'up' : 'down') : null);
|
||||
setChip('derivLs', '多空 ' + fmtRatio(ls.long_short_ratio), isFinite(lsN) ? (lsN >= 1 ? 'up' : 'down') : null);
|
||||
setChip('derivTop', '大户 ' + fmtRatio(top.long_short_ratio), isFinite(topN) ? (topN >= 1 ? 'up' : 'down') : null);
|
||||
}).catch(function (err) {
|
||||
if (req !== latestReq) return;
|
||||
console.warn('情绪快照不可用', err);
|
||||
renderSentimentEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
function sync(opts) {
|
||||
opts = opts || {};
|
||||
var show = isCrypto();
|
||||
setVisible(show);
|
||||
if (!show) return;
|
||||
loadSnapshot();
|
||||
loadLatest();
|
||||
if (opts.overlay !== false) loadOverlays();
|
||||
}
|
||||
|
||||
return {
|
||||
sync: sync,
|
||||
setVisible: setVisible,
|
||||
loadOiOverlay: loadOverlays,
|
||||
loadOverlays: loadOverlays
|
||||
};
|
||||
})();
|
||||
@@ -151,7 +151,7 @@ $('#elementTimeframe').change(function() {
|
||||
|
||||
// 检查选择的元素时间周期是否小于等于主周期
|
||||
if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) {
|
||||
alert('元素时间周期必须小于或等于主图表时间周期。');
|
||||
alert('次必须小于或等于主。');
|
||||
setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ $('#subSubTimeframe').change(function() {
|
||||
const subSub = $(this).val();
|
||||
const elementTf = $('#elementTimeframe').val();
|
||||
if (compareTimeframes(subSub, elementTf) > 0) {
|
||||
alert('次次周期必须小于或等于次周期。');
|
||||
alert('次次必须小于或等于次。');
|
||||
ensureSubSubLteElement();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,67 @@ function clearChanMacdMarkers() {
|
||||
}
|
||||
// 清空全局UnitTF标记,避免旧数据残留影响主图合并
|
||||
window.unittfMarkers = [];
|
||||
window.unittfMarkersMain = [];
|
||||
window.unittfMarkersElement = [];
|
||||
window.unittfMarkersSubSub = [];
|
||||
}
|
||||
|
||||
function unittfDirSign(dir) {
|
||||
if (dir === 'ABOVE' || dir === 1 || dir === '1' || dir === true) return 1;
|
||||
if (dir === 'UNDER' || dir === -1 || dir === '-1') return -1;
|
||||
const n = Number(dir);
|
||||
if (n > 0) return 1;
|
||||
if (n < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildUnittfOverlayMarkers(unittfList, opt) {
|
||||
const markers = [];
|
||||
if (!Array.isArray(unittfList) || !opt) return markers;
|
||||
const up = opt.up, down = opt.down, prefix = opt.prefix || 'U';
|
||||
const size = opt.size || 0.5;
|
||||
for (let i = 0; i < unittfList.length; i++) {
|
||||
const unittf = unittfList[i];
|
||||
if (!unittf || !unittf.start_time || unittf.invalid) continue;
|
||||
const startTime = Math.floor(new Date(unittf.start_time).getTime() / 1000);
|
||||
if (isNaN(startTime)) continue;
|
||||
const sign = unittfDirSign(unittf.dir);
|
||||
const color = sign > 0 ? up : down;
|
||||
const pos = sign > 0 ? 'aboveBar' : 'belowBar';
|
||||
markers.push({ time: startTime, position: pos, color: color, shape: 'circle', text: prefix + i, size: size });
|
||||
if (unittf.end_time) {
|
||||
const endTime = Math.floor(new Date(unittf.end_time).getTime() / 1000);
|
||||
if (!isNaN(endTime)) {
|
||||
markers.push({ time: endTime, position: pos, color: color, shape: 'circle', text: prefix + i + 'E', size: size });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i + 1 < unittfList.length; i++) {
|
||||
const cur = unittfList[i];
|
||||
const nxt = unittfList[i + 1];
|
||||
if (!cur || !nxt || !cur.end_time || !nxt.start_time) continue;
|
||||
const tEnd = new Date(cur.end_time).getTime();
|
||||
const tStart = new Date(nxt.start_time).getTime();
|
||||
if (!isNaN(tEnd) && tEnd === tStart) {
|
||||
const sign = unittfDirSign(nxt.dir);
|
||||
markers.push({
|
||||
time: Math.floor(tEnd / 1000),
|
||||
position: sign > 0 ? 'aboveBar' : 'belowBar',
|
||||
color: sign > 0 ? (opt.boundaryUp || up) : (opt.boundaryDown || down),
|
||||
shape: 'square',
|
||||
text: prefix + '↔',
|
||||
size: 0.6
|
||||
});
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function refreshUnittfOverlayFromData(data) {
|
||||
// U/穿零轴只画在 MACD 副图,不再铺到主图
|
||||
window.unittfMarkersMain = [];
|
||||
window.unittfMarkersElement = [];
|
||||
window.unittfMarkersSubSub = [];
|
||||
}
|
||||
// 添加所有ChanMACD标记
|
||||
function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
|
||||
@@ -215,8 +276,7 @@ function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) {
|
||||
|
||||
// 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时)
|
||||
console.log('DEBUG: U 标记数量:', signalMarkers.length);
|
||||
const allowUMerge = (window.showUOnMain && window.showUOnElement);
|
||||
window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : [];
|
||||
window.unittfMarkers = [...signalMarkers, ...boundaryMarkers];
|
||||
if (uTooltipMarkers.length > 0) {
|
||||
if (window.fxMarkers) {
|
||||
window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ];
|
||||
|
||||
+17
-15
@@ -26,10 +26,10 @@ function loadSymbols() {
|
||||
});
|
||||
}
|
||||
|
||||
// 设置默认时间范围:最近 1 个月
|
||||
// 设置默认时间范围:现在倒退 1 周
|
||||
function setDefaultTimeRange() {
|
||||
const now = new Date();
|
||||
const daysBack = 30;
|
||||
const daysBack = 7;
|
||||
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
||||
|
||||
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
|
||||
@@ -72,17 +72,20 @@ $(document).ready(function() {
|
||||
window.astockStatusInterval = null;
|
||||
}
|
||||
loadSymbols();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(true);
|
||||
} else if (dataSource === 'a_stock') {
|
||||
$('#cryptoSymbolContainer').hide();
|
||||
$('#astockSymbolContainer').show();
|
||||
loadAStockSymbols();
|
||||
startAStockStatusUpdater();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 检查初始数据源设置
|
||||
const initialDataSource = $('#dataSource').val();
|
||||
if (window.ChanDeriv) ChanDeriv.setVisible(initialDataSource !== 'a_stock');
|
||||
if (initialDataSource === 'a_stock') {
|
||||
$.getJSON('/api/chart_metadata', { source: 'a_stock' })
|
||||
.done(function(meta) {
|
||||
@@ -404,6 +407,7 @@ function mapTimeframeToInterval(timeframe) {
|
||||
'5m': '5',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'45m': '45',
|
||||
'1h': '60',
|
||||
'2h': '120',
|
||||
'4h': '240',
|
||||
@@ -547,6 +551,7 @@ function refreshChart(data, options) {
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
if (window.ChanDeriv) ChanDeriv.sync({ overlay: false });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('增量刷新失败,回退全量重建:', e);
|
||||
@@ -584,6 +589,7 @@ function refreshChart(data, options) {
|
||||
if (currentData && currentData.ema52_dict) {
|
||||
updateEMA52Display(currentData);
|
||||
}
|
||||
if (window.ChanDeriv) ChanDeriv.sync({ overlay: true });
|
||||
|
||||
}
|
||||
|
||||
@@ -597,14 +603,9 @@ function refreshChartOnly() {
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定主周期MACD背离显示开关
|
||||
$('#showMainMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
});
|
||||
|
||||
// 绑定次周期MACD背离显示开关
|
||||
$('#showElementMacdDiv').change(function() {
|
||||
refreshChartOnly();
|
||||
// 绑定主/次/次次周期笔面积、线段面积显示开关
|
||||
$('#showMainBiArea, #showMainSegArea, #showElementBiArea, #showElementSegArea, #showSubSubBiArea, #showSubSubSegArea').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐)
|
||||
@@ -642,6 +643,12 @@ $('#toggleUOnElement').change(function() {
|
||||
});
|
||||
|
||||
// 买卖点显示开关
|
||||
$('#showOi').change(function() {
|
||||
if (window.ChanDeriv) ChanDeriv.loadOverlays();
|
||||
});
|
||||
$('#showDeriv').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
$('#showMainBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
@@ -649,11 +656,6 @@ $('#showElementBsp').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 第四类买卖点(B4/S4)显示开关与过滤模式
|
||||
$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() {
|
||||
updateChartDisplay();
|
||||
});
|
||||
|
||||
// 在控制台输出当前显示状态
|
||||
console.log('当前显示状态:', {
|
||||
'showOriginalKline': $('#showOriginalKline').is(':checked'),
|
||||
|
||||
+103
-44
@@ -858,6 +858,38 @@
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.deriv-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.deriv-bar .deriv-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.deriv-bar .deriv-chip {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #343a40;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.deriv-bar .deriv-chip.up { color: #198754; }
|
||||
.deriv-bar .deriv-chip.down { color: #dc3545; }
|
||||
.deriv-bar .deriv-src {
|
||||
color: #868e96;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -935,21 +967,31 @@
|
||||
<!-- 添加K线周期切换 -->
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="mainPeriodKline" checked>
|
||||
<label class="form-check-label" for="mainPeriodKline">主周期</label>
|
||||
<label class="form-check-label" for="mainPeriodKline">主</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="elementPeriodKline">
|
||||
<label class="form-check-label" for="elementPeriodKline">小周期</label>
|
||||
<label class="form-check-label" for="elementPeriodKline">次</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="klinePeriod" id="subSubPeriodKline">
|
||||
<label class="form-check-label" for="subSubPeriodKline">次次周期</label>
|
||||
<label class="form-check-label" for="subSubPeriodKline">次次</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMacd" checked>
|
||||
<label class="form-check-label" for="showMacd">ChanMACD</label>
|
||||
<label class="form-check-label" for="showMacd">MACD</label>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1 p-0" onclick="showMacdConfig()" style="width:22px;height:22px;line-height:1;font-size:12px;" title="MACD参数设置">⚙</button>
|
||||
</div>
|
||||
<span id="showSentimentWrap" class="d-inline-flex align-items-center">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showOi" checked>
|
||||
<label class="form-check-label" for="showOi">OI</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showDeriv" checked>
|
||||
<label class="form-check-label" for="showDeriv">衍生品</label>
|
||||
</div>
|
||||
</span>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
|
||||
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
|
||||
@@ -977,7 +1019,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">主周期:</label>
|
||||
<label class="form-label me-0 mb-0">主:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -998,11 +1040,11 @@
|
||||
<label class="form-check-label" for="showMainZs">SEG中枢</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiZs">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiZs" checked>
|
||||
<label class="form-check-label" for="showMainBiZs">BI中枢</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showKlcFxType" checked>
|
||||
<input class="form-check-input" type="checkbox" id="showKlcFxType">
|
||||
<label class="form-check-label" for="showKlcFxType">KLC分型</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
@@ -1010,27 +1052,24 @@
|
||||
<label class="form-check-label" for="showMainTrend">Trend</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnMain">
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnMain" checked>
|
||||
<label class="form-check-label" for="toggleUOnMain">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBiArea">
|
||||
<label class="form-check-label" for="showMainBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainSegArea">
|
||||
<label class="form-check-label" for="showMainSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainBsp">
|
||||
<label class="form-check-label" for="showMainBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showMainFastBsp">
|
||||
<label class="form-check-label" for="showMainFastBsp">第四类</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="fastBspFilterMode" class="form-select form-select-sm" style="width: 130px;"
|
||||
title="深色为区间套(大级别分型同向)+中枢顺向推进都满足的信号,浅色为未通过过滤">
|
||||
<option value="all" selected>第四类:全部</option>
|
||||
<option value="filtered">第四类:仅过滤后</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次周期:</label>
|
||||
<label class="form-label me-0 mb-0">次:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -1066,17 +1105,21 @@
|
||||
<input class="form-check-input" type="checkbox" id="toggleUOnElement">
|
||||
<label class="form-check-label" for="toggleUOnElement">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementBiArea">
|
||||
<label class="form-check-label" for="showElementBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementSegArea">
|
||||
<label class="form-check-label" for="showElementSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementBsp">
|
||||
<label class="form-check-label" for="showElementBsp">买卖点</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showElementFastBsp">
|
||||
<label class="form-check-label" for="showElementFastBsp">第四类</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<label class="form-label me-0 mb-0">次次周期:</label>
|
||||
<label class="form-label me-0 mb-0">次次:</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<select id="subSubTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
|
||||
{% for value, label in timeframes.items() %}
|
||||
@@ -1113,12 +1156,16 @@
|
||||
<label class="form-check-label" for="toggleUOnSubSub">显示U</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBiArea">
|
||||
<label class="form-check-label" for="showSubSubBiArea">笔面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubFastBsp">
|
||||
<label class="form-check-label" for="showSubSubFastBsp">第四类</label>
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubSegArea">
|
||||
<label class="form-check-label" for="showSubSubSegArea">线段面积</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
|
||||
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1126,6 +1173,17 @@
|
||||
</div>
|
||||
|
||||
<div id="loadingIndicator" style="display:none;"></div>
|
||||
<div id="derivBar" class="deriv-bar">
|
||||
<span class="deriv-label">资金面</span>
|
||||
<span class="deriv-chip" id="derivOi">OI —</span>
|
||||
<span class="deriv-chip" id="derivOiChg">Δ —</span>
|
||||
<span class="deriv-chip" id="derivFunding">费率 —</span>
|
||||
<span class="deriv-chip" id="derivBasis">基差 —</span>
|
||||
<span class="deriv-chip" id="derivTaker">买卖比 —</span>
|
||||
<span class="deriv-chip" id="derivLs">多空 —</span>
|
||||
<span class="deriv-chip" id="derivTop">大户 —</span>
|
||||
<span class="deriv-src" id="derivSrc"></span>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<div id="tradingview_chart"></div>
|
||||
@@ -1278,24 +1336,25 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/deriv_ui.js') }}?v=20260911i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/state.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260810d"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260827a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260809z"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260827a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260912a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260911l"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260911n"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260910a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260910c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260911j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260912a"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260901d"></script>
|
||||
|
||||
<!-- 均线配置弹窗 -->
|
||||
<div id="maConfigModal" class="ma-config-modal">
|
||||
@@ -1479,7 +1538,7 @@
|
||||
<!-- MACD参数配置弹窗 -->
|
||||
<div id="macdConfigModal" class="macd-config-modal">
|
||||
<div class="macd-config-content">
|
||||
<div class="macd-config-title">ChanMACD 参数设置</div>
|
||||
<div class="macd-config-title">MACD 参数设置</div>
|
||||
<div class="macd-config-form">
|
||||
<div class="macd-config-group">
|
||||
<label class="macd-config-label">快线周期 (Fast)</label>
|
||||
|
||||
@@ -105,6 +105,32 @@ def test_analyze_chan_keys_on_fixture():
|
||||
assert k in result["chan_macd"]
|
||||
|
||||
|
||||
def test_bi_and_seg_area_div_computed():
|
||||
from services.runtime import add_indicators, analyze_chan
|
||||
|
||||
df = add_indicators(make_ohlcv(400))
|
||||
result = analyze_chan(df, symbol="TEST/USDT:USDT", timeframe="5m")
|
||||
bis = result["bi_list"]
|
||||
segs = result["seg_list"]
|
||||
assert bis, "fixture should produce bi"
|
||||
assert all(hasattr(bi, "macd_div") for bi in bis)
|
||||
assert all(hasattr(seg, "macd_div") for seg in segs)
|
||||
assert all(hasattr(bi, "macd_hist") for bi in bis)
|
||||
assert all(hasattr(seg, "macd_hist") for seg in segs)
|
||||
same_dir_bis = [bi for bi in bis if getattr(bi, "pre", None) and getattr(bi.pre, "pre", None)]
|
||||
if same_dir_bis:
|
||||
bi = same_dir_bis[-1]
|
||||
prev = bi.pre.pre
|
||||
if prev.macd_hist:
|
||||
assert abs(bi.macd_div - (bi.macd_hist / prev.macd_hist)) < 1e-9
|
||||
same_dir_segs = [seg for seg in segs if getattr(seg, "pre", None) and getattr(seg.pre, "pre", None)]
|
||||
if same_dir_segs:
|
||||
seg = same_dir_segs[-1]
|
||||
prev = seg.pre.pre
|
||||
if prev.macd_hist:
|
||||
assert abs(seg.macd_div - (seg.macd_hist / prev.macd_hist)) < 1e-9
|
||||
|
||||
|
||||
def test_serialize_chan_macd_shape():
|
||||
from pytz import timezone
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""资金面中转:Web 只打 data_provider,字段原样回给前端。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
WEB_ROOT = Path(__file__).resolve().parents[1]
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(WEB_ROOT))
|
||||
|
||||
|
||||
SNAPSHOT = {
|
||||
"exchange": "bitget",
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timestamp": 1789039236740,
|
||||
"datetime": "2026-09-10T11:20:36.740000Z",
|
||||
"funding_rate": 0.0001,
|
||||
"open_interest": 36207.21,
|
||||
"oi_change_pct": -0.01,
|
||||
"basis": -0.03,
|
||||
}
|
||||
|
||||
OI_HIST = {
|
||||
"metric": "open_interest_history",
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"period": "15m",
|
||||
"count": 1,
|
||||
"data": [
|
||||
{
|
||||
"timestamp": 1789038900000,
|
||||
"datetime": "2026-09-10T11:15:00Z",
|
||||
"open_interest_amount": 106162.005,
|
||||
"open_interest_value": 8269553076.678,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_derivatives_proxy_passthrough(client):
|
||||
with patch("api.provider.fetch_derivatives", return_value=SNAPSHOT) as mock_fetch:
|
||||
resp = client.get("/api/derivatives?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["funding_rate"] == 0.0001
|
||||
assert body["open_interest"] == 36207.21
|
||||
assert body["oi_change_pct"] == -0.01
|
||||
assert body["basis"] == -0.03
|
||||
mock_fetch.assert_called_once()
|
||||
assert mock_fetch.call_args[0][0] == "BTC/USDT:USDT"
|
||||
|
||||
|
||||
def test_sentiment_metrics_proxy_passthrough(client):
|
||||
with patch("api.provider.fetch_sentiment_metrics", return_value=OI_HIST) as mock_fetch:
|
||||
resp = client.get(
|
||||
"/api/sentiment/metrics?metric=open_interest_history&symbol=BTC/USDT:USDT&limit=1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["metric"] == "open_interest_history"
|
||||
assert body["data"][0]["open_interest_amount"] == 106162.005
|
||||
mock_fetch.assert_called_once()
|
||||
assert mock_fetch.call_args[0][0] == "open_interest_history"
|
||||
|
||||
|
||||
def test_sentiment_metrics_requires_metric(client):
|
||||
resp = client.get("/api/sentiment/metrics?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_sentiment_latest_proxy_passthrough(client):
|
||||
latest = {
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"data": {
|
||||
"taker_buy_sell_ratio": {"buy_sell_ratio": 1.36},
|
||||
"long_short_account_ratio": {"long_short_ratio": 1.5},
|
||||
"top_long_short_position_ratio": {"long_short_ratio": 2.26},
|
||||
},
|
||||
}
|
||||
with patch("api.provider.fetch_sentiment_latest", return_value=latest) as mock_fetch:
|
||||
resp = client.get("/api/sentiment/latest?symbol=BTC/USDT:USDT")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["data"]["taker_buy_sell_ratio"]["buy_sell_ratio"] == 1.36
|
||||
mock_fetch.assert_called_once()
|
||||
Reference in New Issue
Block a user