fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+155
View File
@@ -0,0 +1,155 @@
# Dry-Run Decision Checklist — GATED_V1_1_LOCKED
```text
Purpose: 上线前不改规则,只验执行链
Stack: Market State → Decision → Frozen Signal
Version: GATED_V1_1_LOCKED
Mode: dry-run / monitoring only
```
研究线已收手。本清单是 **operational acceptance**,不是新实验。
---
## Locked defaults(不可在 dry-run 中改动)
| Item | Value |
|------|--------|
| Strategy | `Wyckoff_BTC_GATED` |
| Spring | `V1_BASELINE` FROZEN |
| Gate | `market_state in {accumulation, markup}` → allow Spring |
| Soft-score | rejected |
| Range | observe only(非交易规则) |
| gate_version | `GATED_V1_1_LOCKED` |
---
## 1. 信号一致性
上线前逐项勾选:
- [ ] 同一根 entry candle 上,`market_state` **只使用已收盘 8h** 数据(无 lookaheadmerge 后读的是上一根已完成 bias bar)
- [ ] `allow_spring == True` **仅当** `market_state ∈ {accumulation, markup}`
- [ ] `allow_spring == False``market_state ∈ {distribution, markdown, range}` 或缺失
- [ ] Baseline 产生 `SPRING_LONG` 且 Gate block 时:**不下单**
- [ ] 同上 blocked 事件:**写入决策日志**(见 §2),与 kept 同 schema
- [ ] UTAD(若启用)镜像:`allow_utad``{distribution, markdown}`;本清单以 Spring 为主
快速自检(可在 dry-run 启动后抽查最近 N 条日志):
```text
assert gate_version == "GATED_V1_1_LOCKED"
assert allow ⇒ market_state in {accumulation, markup}
assert market_state == "distribution" ⇒ allow == false
assert block ⇒ order_not_sent
```
---
## 2. 日志字段(每条候选信号一行)
必需字段:
| Field | Example / notes |
|-------|-----------------|
| `timestamp` | entry candle open/close timeUTC |
| `pair` | e.g. `BTC/USDT:USDT` |
| `signal_type` | `SPRING_LONG` / `UTAD_SHORT` |
| `market_state` | accumulation \| markup \| distribution \| markdown \| range \| missing |
| `allow` | `true` / `false` |
| `gate_version` | `GATED_V1_1_LOCKED` |
| `baseline_signal` | `SPRING_LONG`Gate 前 Baseline 标签) |
| `block_reason` | `not_in_allow_set` \| `state_missing` \| `state_lag` \| `""` if allow |
推荐附加(便于监控,非规则):
| Field | Notes |
|-------|--------|
| `bias_bar_time` | 决策所用已收盘 8h bar 时间 |
| `accumulation_score``range_score` | 诊断用,**不参与默认 Gate** |
| `would_enter` | Baseline 是否曾置 `enter_long=1` |
| `order_sent` | dry-run 下应为 `allow` 的结果 |
Blocked 必须落盘;禁止静默丢弃。
---
## 3. Dry-run 监控指标
周期性汇总(建议日 / 周):
| Metric | 关注点 |
|--------|--------|
| `kept_n` / `blocked_n` | 量级是否合理,非零且非异常尖刺 |
| blocked domain 分布 | **尤其 `distribution` 应仍为主要 block 源** |
| kept trade PF / expectancy | 参考,不强求 > ungated baseline |
| max DDkept / 账户) | 应相对 ungated 历史继续偏低 |
| range share among blocked | 仅观察;上升不自动改规则 |
### 2023+ OOS 参考阈值(研究窗,非调参目标)
| | Gated(研究) | 解读 |
|--|---------------|------|
| PF | ~1.34baseline ~1.45 | **不强求超过 baseline** |
| DD | ~3.4%baseline ~7.9% | **DD 应继续低** |
| full DD | ~9.6% vs ~26% | 结构性降 DD 仍是成功标准 |
Dry-run 短期 PF 波动 **不触发规则变更**
---
## 4. 报警条件
| Severity | Condition | Action |
|----------|-----------|--------|
| P0 | `market_state` 缺失或滞后(bias bar 过旧 / merge 失败) | 停新开仓,查数据链 |
| P0 | Gate 放行且 `market_state ∉ {accumulation, markup}` | 立即停机排查;视为执行链 bug |
| P0 | `distribution` 被放行 Spring | 同上 |
| P1 | blocked 样本中 `range` **长期主导** 且 kept PF/expectancy 同步恶化 | 记观察票;**不改规则**,升级人工 review |
| P2 | kept/blocked 比为 0 或异常尖刺(数据空洞) | 查 feed / 时区 / 8h 对齐 |
报警只服务执行完整性,不服务「再优化一次 Gate」。
---
## 5. 不允许事项(硬禁)
- 不调 SpringTF / ATR / stoploss / entry 形态)
- 不调 soft-score,不把 soft-score 接回默认路径
- 不全样本扫 Gate 阈值 / 状态集合
- 不因短期 dry-run PF 调规则
- 不因 `range` 小样本表现把 range 升格为交易域
- 不默认合并 ETH/SOL 进生产路径
- 不复活 LPS 分支
违反任一条 = 退出 dry-run,回到研究流程(需新证据包)。
---
## 6. Go / No-Godry-run → 有限实盘)
**Go**(全部满足):
- [ ] §1 信号一致性全部勾选
- [ ] §2 日志字段齐全,blocked 可见
- [ ] §4 无未关闭的 P0
- [ ] 监控窗内 blocked 仍以坏域为主(distribution 不消失为噪音)
- [ ] 规则文件与运行配置仍为 `GATED_V1_1_LOCKED` / `state_set`
**No-Go**
- 任一 P0
- 日志无法区分 kept vs blocked
- 发现非因果 8h 状态
- 有人为改动 Spring / Gate 默认值
---
## Related
- Status: `research/SYSTEM_STATUS.md`
- Boundary: `research/VALIDITY_BOUNDARY.md`
- Strategy: `strategies/Wyckoff_BTC_GATED.py`
- State engine: `engine/market_state.py`
- Audit evidence: `scripts/wyckoff_negative_domain_audit_result.json`
- Robustness: `scripts/wyckoff_gate_robustness_slices_result.json`
+63
View File
@@ -0,0 +1,63 @@
# Wyckoff BTC System v1 — Decision Rule Locked
```
Architecture: Market State → Decision → Signal
Spring: FROZEN
Gate v1.1: LOCKED DEFAULT Decision rule (PASS)
Soft-score: REJECTED (no increment)
Hard-score: REJECTED
Minimal rule:
market_state in {accumulation, markup} -> allow Spring
else -> block Spring
Primary invalidation domain: distribution
range: observation bucket only (NOT a trading rule)
Validity: DEFINED
Confidence: MEDIUM / defined-domain PASS
Status: DEFAULT RULES FROZEN
Next: dry-run / monitoring only(见 operational checklist
```
## Operational
上线前不改规则,只验执行链:
→ [`DRY_RUN_DECISION_CHECKLIST.md`](./DRY_RUN_DECISION_CHECKLIST.md)
覆盖:信号一致性 · 日志字段 · dry-run 监控 · 报警 · 硬禁 · Go/No-Go。
## Locked stack
| Layer | File | Status |
|-------|------|--------|
| Signal | `Wyckoff_BTC_V1_BASELINE.py` | FROZEN |
| State | `engine/market_state.py` | causal v1.1 |
| Decision | `Wyckoff_BTC_GATED.py` | **LOCKED state_set** |
| Boundary | `VALIDITY_BOUNDARY.md` | active |
## Robustness slices (blocked Spring, by year/era)
证据:`scripts/wyckoff_gate_robustness_slices_result.json`
| Slice | blocked n | dist share | top blocked | blocked PF |
|-------|-----------|------------|-------------|------------|
| 2020 | 3 | **1.00** | distribution | 0.73 |
| 2021 | 4 | **0.75** | distribution | 0.31 |
| 2022 | 1 | 1.00 | distribution | 0 |
| 2023 | 1 | 1.00 | distribution | 0 |
| 2024 | 3 | 0.33 | distribution+range | 0 |
| 2025 | 1 | 0 | range (obs) | n=1 win |
| pre_2023 | 8 | **0.875** | distribution | 0.37 |
| 2023plus | 5 | 0.40 | distribution+range | 1.22 |
Verdict: **distribution 归因在多数有样本切片上稳定**PASS)。
2023+ / 202425 中 range 占比上升 → 保持 **观察标签**,不升格为交易规则。
## Do not
- 调 Spring / soft-score / Gate 阈值
- 因 range 小样本正 PF 开放 range 交易
- 复活 LPS / 默认跨资产
+85
View File
@@ -0,0 +1,85 @@
# Validity Boundary — Market-State Gated Spring
## Definition (hard)
```text
market_state in {accumulation, markup} -> allow Spring
else -> block Spring
```
Spring 信号本体 = `V1_BASELINE`FROZEN)。
Gate = Decision 层默认规则(state_set v1.1 = **PASS**)。
Soft-score / hard-score 阈值 **不进入默认规则**
## Validity statement
Spring has positive expectancy under:
1. BTC market
2. Causal `market_state ∈ {accumulation, markup}`
3. 8h / 4h / 1h alignment
4. Trend-compatible (range already blocked in Baseline)
Invalid under:
1. `distribution`
2. `range`
3. `markdown`(对 SPRING_LONG
4. Ungated global trading
## Causal state (entry-time only)
```
bear & ema_slope >= -1% → accumulation
bull & ema_slope > +0.5% → markup
bull & ema_slope <= +0.5% → distribution
bear & ema_slope < -1% → markdown
else → range
```
## Gate performance (net fee+slip)
| Window | Baseline | Gated state_set |
|--------|----------|-----------------|
| 2023+ | n=20 PF 1.45 DD 7.9% | n=7 PF **1.34** DD **3.4%** |
| full | n=47 PF 0.74 DD 26% | n=17 PF **0.92** DD **9.6%** |
Confidence: **MEDIUM / defined-domain PASS**full PF 仍 < 1)。
## Negative-domain audit
`scripts/wyckoff_negative_domain_audit_result.json`
对 Baseline 全部 `SPRING_LONG`n=28)按因果状态拆 kept/blocked
| | n | PF | 含义 |
|--|---|-----|------|
| Kept | 15 | 1.09 | 全部在 markup |
| Blocked | 13 | 0.58 | **100% bad domain** |
| Blocked × distribution | 9 | **0.38** | 主杀伤区 |
| Blocked × range | 4 | 1.14 | 样本小,非干净杀伤 |
→ Gate 主要过滤 **distribution 结构性失效**,符合威科夫「Spring 是吸筹事件而非形态」的边界叙事。
## Default stackLOCKED
```
8h causal market_state
Decision: state_set Gate v1.1 ← LOCKED
Frozen V1_BASELINE Spring / UTAD
```
## Year/era robustness(冻结前确认)
`scripts/wyckoff_gate_robustness_slices_result.json`
- pre_2023 blockeddistribution share **87.5%**blocked PF 0.37
- 多数年份 blocked 以 distribution 为首
- 2023+ blockeddistribution + range 并存;range **仅观察**,不改规则
- 不因 2023+ blocked 弱正 PF 或 range n=4 回滚 Gate
**Primary invalidation domain = distribution(稳定)**
**range = observation bucket only**
+19
View File
@@ -0,0 +1,19 @@
# Spring Baseline V1 — FROZEN SNAPSHOT
勿改本目录文件。可运行副本在:
- `strategies/Wyckoff_BTC_V1_BASELINE.py`
- `config/Wyckoff_BTC_V1_BASELINE.json`
## Evidence (cost-adjusted)
| Window | Profit | n | DD | Net PF |
|--------|--------|---|-----|--------|
| Train | +1.66% | 12 | 3.6% | 1.17 |
| Validate | +9.99% | 6 | 1.8% | 6.20 |
| Test | +0.85% | 2 | 0.7% | 2.18 |
| Full | +12.74% | 20 | 3.6% | 2.02 |
| fee+slip 5bps | +6.78% | 20 | — | **1.45** |
Status: **PASS + Limited Evidence** (N=20)
Next: Phase3 → N≥50(延历史 / 多品种),不改规则。
@@ -0,0 +1,86 @@
{
"$schema": "https://schema.freqtrade.io/schema.json",
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"db_url": "sqlite:///tradesv3.wyckoff_btc_v1_baseline.sqlite",
"dry_run_wallet": 10000,
"cancel_open_orders_on_exit": true,
"trading_mode": "futures",
"margin_mode": "isolated",
"can_short": true,
"timeframe": "1h",
"process_only_new_candles": true,
"unfilledtimeout": {
"entry": 60,
"exit": 60,
"exit_timeout_count": 5,
"unit": "minutes"
},
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {
"proxies": {
"http": "http://127.0.0.1:7897",
"https": "http://127.0.0.1:7897"
}
},
"ccxt_async_config": {
"aiohttp_proxy": "http://127.0.0.1:7897"
},
"pair_whitelist": [
"BTC/USDT:USDT"
],
"pair_blacklist": [
"BNB/.*"
]
},
"pairlists": [
{
"method": "StaticPairList"
}
],
"telegram": {
"enabled": false,
"token": "",
"chat_id": ""
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8823,
"verbosity": "error",
"enable_openapi": false,
"jwt_secret_key": "wyckoff-v1-baseline-change-me",
"ws_token": "wyckoff-v1-baseline-ws-change-me",
"CORS_origins": [],
"username": "freqtrader",
"password": "FreqTrade007"
},
"bot_name": "wyckoff_btc_v1_baseline",
"initial_state": "running",
"force_entry_enable": false,
"internals": {
"process_throttle_secs": 5
}
}
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE — FROZEN
Status: BASELINE FROZEN
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample — 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究,禁止并入本文件调参。
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_V1_BASELINE(IStrategy):
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_BASELINE"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)
@@ -0,0 +1,460 @@
{
"branches": {
"Spring_V1": {
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": 1.6587295176,
"trades": 12,
"dd_pct": 3.644907735100005,
"pf": 1.1700179329477578,
"winrate": 25.0,
"final": 10165.87295176,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": 9.990534148400002,
"trades": 6,
"dd_pct": 1.797834787912851,
"pf": 6.201791679101682,
"winrate": 66.66666666666666,
"final": 10999.05341484,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.8458820224000001,
"trades": 2,
"dd_pct": 0.7197049309999966,
"pf": 2.175317808681236,
"winrate": 50.0,
"final": 10084.58820224,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": 8.166882314399999,
"trades": 12,
"dd_pct": 3.4837023928902555,
"pf": 1.9398544482027922,
"winrate": 33.33333333333333,
"final": 10816.688231439999,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 4.2503064875000005,
"trades": 8,
"dd_pct": 3.173714645599994,
"pf": 2.084295240772406,
"winrate": 50.0,
"final": 10425.03064875,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": -4.3352539371,
"trades": 6,
"dd_pct": 4.404162180500007,
"pf": 0.15746188404490422,
"winrate": 16.666666666666664,
"final": 9566.47460629,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": 7.831216539699999,
"trades": 26,
"dd_pct": 7.883451762900004,
"pf": 1.454582067425369,
"winrate": 34.61538461538461,
"final": 10783.12165397,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": 6.782772099999998,
"trades": 20,
"dd_pct": 7.851805397900007,
"pf": 1.4511324473780693,
"winrate": 35.0,
"final": 10678.27721,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": 3.182909279400001,
"trades": 20,
"dd_pct": 9.126146157700004,
"pf": 1.1834520309921508,
"winrate": 35.0,
"final": 10318.29092794,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.3,
"dd": 10.0,
"note": "Spring: PF>1.3 DD<10%"
},
"verdict": {
"full_pf": 2.0183507402435503,
"full_dd": 3.644907735100005,
"trades_per_year": 5.555555555555555,
"net_mid_pf": 1.4511324473780693,
"target_pf_ok": true,
"target_dd_ok": true
}
},
"LPS_V1": {
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -1.2518571096,
"trades": 1,
"dd_pct": 1.251857109600005,
"pf": 0.0,
"winrate": 0.0,
"final": 9874.81428904,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -0.24613064569999998,
"trades": 1,
"dd_pct": 0.24613064569999552,
"pf": 0.0,
"winrate": 0.0,
"final": 9975.38693543,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -1.4952529704,
"trades": 2,
"dd_pct": 1.4952529703999973,
"pf": 0.0,
"winrate": 0.0,
"final": 9850.47470296,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -1.6902037039,
"trades": 2,
"dd_pct": 1.6902037039000062,
"pf": 0.0,
"winrate": 0.0,
"final": 9830.97962961,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -2.0801051709,
"trades": 2,
"dd_pct": 2.080105170900006,
"pf": 0.0,
"winrate": 0.0,
"final": 9791.98948291,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS: PF>1.2, 次数增加"
},
"version": "LPS_V1.1",
"verdict": {
"full_pf": 0.0,
"full_dd": 1.4952529703999973,
"trades_per_year": 0.5555555555555556,
"net_mid_pf": 0.0,
"target_pf_ok": false,
"target_dd_ok": true
}
},
"LPS_V2": {
"version": "LPS_V2",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -3.5049591933000004,
"trades": 3,
"dd_pct": 3.504959193300001,
"pf": 0.0,
"winrate": 0.0,
"final": 9649.50408067,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -1.6143743830000001,
"trades": 2,
"dd_pct": 1.614374382999995,
"pf": 0.0,
"winrate": 0.0,
"final": 9838.5625617,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -5.0599199851,
"trades": 5,
"dd_pct": 5.059919985100005,
"pf": 0.0,
"winrate": 0.0,
"final": 9494.00800149,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -4.5549168852,
"trades": 7,
"dd_pct": 7.020877735199993,
"pf": 0.3512325585213674,
"winrate": 14.285714285714285,
"final": 9544.50831148,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -5.371762636800001,
"trades": 7,
"dd_pct": 8.1297357765,
"pf": 0.33924511392759654,
"winrate": 14.285714285714285,
"final": 9462.82373632,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
},
"verdict": {
"full_pf": 0.39720654015221873,
"full_dd": 6.479119889400008,
"trades_per_year": 1.9444444444444444,
"net_mid_pf": 0.3512325585213674,
"target_pf_ok": false,
"target_dd_ok": true,
"freq_ok": false,
"regime_logic_ok": true,
"status": "FAIL",
"hypothesis": "4h native SOS → 1h LPS"
}
}
},
"portfolio_note": {
"spring_tpy": 5.555555555555555,
"lps_tpy": 0.5555555555555556,
"sum_tpy_approx": 6.111111111111111,
"combined_target_tpy": "15-25",
"lps_status": "FAIL",
"spring_status": "PASS"
},
"system_status": {
"spring": "BASELINE FROZEN / PASS + Limited Evidence",
"lps": "FAIL",
"spring_tpy": 5.555555555555555,
"lps_tpy": 1.9444444444444444,
"next": "若 LPS PASS → 组合层;否则 Spring-only"
}
}
@@ -0,0 +1,141 @@
{
"note": "V1 BASELINE frozen; Range disabled; Spring/UTAD only; net cost included",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": 1.6587295176,
"trades": 12,
"dd_pct": 3.644907735100005,
"pf": 1.1700179329477578,
"winrate": 25.0,
"final": 10165.87295176,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": 9.990534148400002,
"trades": 6,
"dd_pct": 1.797834787912851,
"pf": 6.201791679101682,
"winrate": 66.66666666666666,
"final": 10999.05341484,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 0.8458820224000001,
"trades": 2,
"dd_pct": 0.7197049309999966,
"pf": 2.175317808681236,
"winrate": 50.0,
"final": 10084.58820224,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": 8.166882314399999,
"trades": 12,
"dd_pct": 3.4837023928902555,
"pf": 1.9398544482027922,
"winrate": 33.33333333333333,
"final": 10816.688231439999,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 4.2503064875000005,
"trades": 8,
"dd_pct": 3.173714645599994,
"pf": 2.084295240772406,
"winrate": 50.0,
"final": 10425.03064875,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": -4.3352539371,
"trades": 6,
"dd_pct": 4.404162180500007,
"pf": 0.15746188404490422,
"winrate": 16.666666666666664,
"final": 9566.47460629,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": 7.831216539699999,
"trades": 26,
"dd_pct": 7.883451762900004,
"pf": 1.454582067425369,
"winrate": 34.61538461538461,
"final": 10783.12165397,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": 12.7374753063,
"trades": 20,
"dd_pct": 3.644907735100005,
"pf": 2.0183507402435503,
"winrate": 40.0,
"final": 11273.74753063,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": 6.782772099999998,
"trades": 20,
"dd_pct": 7.851805397900007,
"pf": 1.4511324473780693,
"winrate": 35.0,
"final": 10678.27721,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": 3.182909279400001,
"trades": 20,
"dd_pct": 9.126146157700004,
"pf": 1.1834520309921508,
"winrate": 35.0,
"final": 10318.29092794,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"verdict": {
"full_pf": 2.0183507402435503,
"full_dd": 3.644907735100005,
"trades_per_year": 5.555555555555555,
"net_mid_pf": 1.4511324473780693,
"target_pf_ok": true,
"target_dd_ok": true
}
}
+14
View File
@@ -0,0 +1,14 @@
# LPS V1.1 — REJECTED
## Hypothesis
在 V1 上收紧:严格 8h bias + 吸筹前置窗口 + 每事件首次回踩
## Result
- Full: **-1.50%**, n=**2**, 全亏
- 过滤方向正确,但过度收缩 → 无统计意义
## Reject reason
无法同时满足「理论纯度」与「可交易样本」。确认问题在事件定义,继续收紧无意义。
+15
View File
@@ -0,0 +1,15 @@
# LPS V1 — REJECTED
## Hypothesis
1h 侦测突破 + 回踩 = Wyckoff LPS(趋势跟随)
## Result
- Full: **-18.92%**, n=133, PF **0.73**
- Regime anomaly: **trend 亏、range 赚**(反理论)
## Reject reason
捕获的是普通突破回踩噪音,不是 Accumulation → Markup 下的 Composite Operator LPS。
定义错误,不是参数问题。
+43
View File
@@ -0,0 +1,43 @@
# LPS V2 — REJECTED(归档,不再救援)
## Hypothesis
**4h 原生 SOS Confirm → 1h LPS Entry**
大级别事件、小级别执行(非 1h 假突破)
## Implementation
`Wyckoff_BTC_LPS_V2.py`
4h SOS: 实体收盘离开区间 + vol>MA*1.5 + close strength>0.7 + 3 根 hold
1h LPS: 首次回踩 + 0.5~1.5 ATR + vol<breakout_vol + close>prev high
## Result
| Window | Profit | n | PF |
|--------|--------|---|-----|
| Train | -3.50% | 3 | 0 |
| Validate | -1.61% | 2 | 0 |
| Test | +1.22% | 2 | 1.82 |
| Full | **-3.91%** | 7 | **0.40** |
| fee+slip | -4.55% | 7 | **0.35** |
证据文件: `wyckoff_lps_v2_phase2_result.json`
## Funnel
```
4h sos_raw 183 → confirmed 123 → 1h LPS 7
```
SOS 识别有产出;**SOS→LPS 映射无稳定边际**。
## Reject reason
在 BTC 永续当前结构下,传统股票式 SOS→LPS→Markup 假设不成立:
突破后常不给标准 LPS,或首次回踩已破坏结构。
样本少/成本/Regime 均非主因。**停止优化本假设。**
## Reopen only if
成交量分布 / 订单流 / 资金费率等新信息源进入假设。
@@ -0,0 +1,492 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC — Branch B: LPS Trend Continuation(独立 Setup 研究)
Status: RESEARCH
Spring V1: BASELINE FROZEN(禁止改动 / 禁止与本分支合并调参)
LPS V2 假设(验证中):
4h 原生 SOS Confirm → 1h LPS Entry
不是 1h 假突破回踩
4h SOS:
① close > range_high(实体收盘离开区间,非 wick)
② volume > MA20 * 1.5
③ close strength (close-low)/(high-low) > 0.7
④ 随后 3 根 4h close 仍 > breakout_level
1h LPS:
第一次回踩 breakout_level
回踩深度 0.5~1.5 ATR(1h)
volume_4h < sos_break_volume
转强: close > previous high
setup_type / enter_tag: LPS / LPSY
regime_mode=trendRange disabled
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, stoploss_from_absolute,
)
from freqtrade.persistence import Trade
import talib.abstract as ta
from pandas import DataFrame
import pandas as pd
import numpy as np
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_LPS.json \
# --strategy Wyckoff_BTC_LPS --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_LPS(IStrategy):
"""LPS V2: 4h 原生 SOS → 1h LPS。不与 Spring 混用。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "LPS_V2"
SETUP_FAMILY = "LPS"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.12,
"1440": 0.06,
"4320": 0.03,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.025
trailing_stop_positive_offset = 0.05
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 固定规则(不做 hyperopt----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
sos_vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
sos_close_strength = DecimalParameter(0.55, 0.90, default=0.70, decimals=2, space="buy", optimize=False)
sos_hold_bars_4h = IntParameter(1, 6, default=3, space="buy", optimize=False)
lps_pb_atr_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=False)
lps_pb_atr_max = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="buy", optimize=False)
lps_max_age_1h = IntParameter(12, 120, default=72, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=168, space="sell", optimize=False)
use_lps_long = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_lps_short = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_bias_tf(self, df: DataFrame) -> DataFrame:
df = df.copy()
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
return df
def _add_sos_structure_4h(self, df: DataFrame) -> DataFrame:
"""在 4h 原生计算 SOS / SOW(含 hold 确认,无前视进场)。"""
df = df.copy()
lb = int(self.range_lookback.value)
hold = int(self.sos_hold_bars_4h.value)
vol_m = float(self.sos_vol_mult.value)
strength_min = float(self.sos_close_strength.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
# 区间用「突破前」边界:shift(1) 的 rolling,避免当根抬高
df["range_high"] = df["high"].rolling(lb).max().shift(1)
df["range_low"] = df["low"].rolling(lb).min().shift(1)
bar_range = (df["high"] - df["low"]).replace(0, np.nan)
df["close_strength"] = (df["close"] - df["low"]) / bar_range
df["close_weakness"] = (df["high"] - df["close"]) / bar_range
vol_ok = df["volume"] > df["volume_ma"] * vol_m
# ① 实体收盘离开区间 ② 放量 ③ Effort Result
sos_raw = (
df["range_high"].notna()
& (df["close"] > df["range_high"])
& (df["close"].shift(1) <= df["range_high"])
& vol_ok
& (df["close_strength"] > strength_min)
)
sow_raw = (
df["range_low"].notna()
& (df["close"] < df["range_low"])
& (df["close"].shift(1) >= df["range_low"])
& vol_ok
& (df["close_weakness"] > strength_min)
)
# 事件位:突破当根冻结
sos_level = df["range_high"].where(sos_raw)
sos_vol = df["volume"].where(sos_raw)
sos_origin = df["range_low"].where(sos_raw)
sow_level = df["range_low"].where(sow_raw)
sow_vol = df["volume"].where(sow_raw)
sow_origin = df["range_high"].where(sow_raw)
# ④ Hold:突破后 hold 根 4h 收盘仍在突破侧 → 在第 hold 根确认(无前视)
sos_confirmed = sos_raw.shift(hold).fillna(False)
sow_confirmed = sow_raw.shift(hold).fillna(False)
for k in range(hold):
sos_confirmed = sos_confirmed & (df["close"].shift(k) > sos_level.shift(hold))
sow_confirmed = sow_confirmed & (df["close"].shift(k) < sow_level.shift(hold))
# 确认当根带出冻结字段,再 ffill 供 1h 使用
df["sos_raw"] = sos_raw.fillna(False)
df["sow_raw"] = sow_raw.fillna(False)
df["sos_confirmed"] = sos_confirmed.fillna(False)
df["sow_confirmed"] = sow_confirmed.fillna(False)
df["sos_break_level"] = sos_level.shift(hold).where(df["sos_confirmed"])
df["sos_break_volume"] = sos_vol.shift(hold).where(df["sos_confirmed"])
df["sos_origin"] = sos_origin.shift(hold).where(df["sos_confirmed"])
df["sow_break_level"] = sow_level.shift(hold).where(df["sow_confirmed"])
df["sow_break_volume"] = sow_vol.shift(hold).where(df["sow_confirmed"])
df["sow_origin"] = sow_origin.shift(hold).where(df["sow_confirmed"])
df["sos_break_level"] = df["sos_break_level"].ffill()
df["sos_break_volume"] = df["sos_break_volume"].ffill()
df["sos_origin"] = df["sos_origin"].ffill()
df["sow_break_level"] = df["sow_break_level"].ffill()
df["sow_break_volume"] = df["sow_break_volume"].ffill()
df["sow_origin"] = df["sow_origin"].ffill()
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
return df
@staticmethod
def _bars_since(event: pd.Series) -> pd.Series:
ev = event.fillna(False).astype(bool).to_numpy()
out = np.full(len(ev), np.nan)
c = np.nan
for i, e in enumerate(ev):
if e:
c = 0.0
elif not np.isnan(c):
c += 1.0
out[i] = c
return pd.Series(out, index=event.index)
@staticmethod
def _expanding_max_since(event: pd.Series, value: pd.Series) -> pd.Series:
"""每个 event 之后对 value 做分段累计 max。"""
ev = event.fillna(False).astype(bool).to_numpy()
vals = value.to_numpy(dtype=float)
out = np.full(len(ev), np.nan)
cur = np.nan
active = False
for i in range(len(ev)):
if ev[i]:
active = True
cur = vals[i]
elif active:
if not np.isnan(vals[i]):
cur = vals[i] if np.isnan(cur) else max(cur, vals[i])
out[i] = cur if active else np.nan
return pd.Series(out, index=event.index)
@staticmethod
def _expanding_min_since(event: pd.Series, value: pd.Series) -> pd.Series:
ev = event.fillna(False).astype(bool).to_numpy()
vals = value.to_numpy(dtype=float)
out = np.full(len(ev), np.nan)
cur = np.nan
active = False
for i in range(len(ev)):
if ev[i]:
active = True
cur = vals[i]
elif active:
if not np.isnan(vals[i]):
cur = vals[i] if np.isnan(cur) else min(cur, vals[i])
out[i] = cur if active else np.nan
return pd.Series(out, index=event.index)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
btf = self.bias_timeframe
inf4 = self.dp.get_pair_dataframe(pair=pair, timeframe=stf)
inf4 = self._add_sos_structure_4h(inf4)
keep4 = [
"date", "atr", "adx", "volume",
"range_high", "range_low", "close_strength",
"sos_raw", "sow_raw", "sos_confirmed", "sow_confirmed",
"sos_break_level", "sos_break_volume", "sos_origin",
"sow_break_level", "sow_break_volume", "sow_origin",
"bull_bias", "bear_bias",
]
inf4 = inf4[[c for c in keep4 if c in inf4.columns]].copy()
dataframe = merge_informative_pair(dataframe, inf4, self.timeframe, stf, ffill=True)
if btf and self.use_bias_filter and btf != stf:
infb = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
infb = self._add_bias_tf(infb)
infb = infb[["date", "bull_bias", "bear_bias", "ema50", "ema200"]].copy()
dataframe = merge_informative_pair(dataframe, infb, self.timeframe, btf, ffill=True)
ss = f"_{stf}"
bs = f"_{btf}" if btf and btf != stf else ss
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
# 8h bias(优先);否则退回 4h bias
if f"bull_bias{bs}" in dataframe.columns:
bull = dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
bear = dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
else:
bull = dataframe[f"bull_bias{ss}"].fillna(False).astype(bool)
bear = dataframe[f"bear_bias{ss}"].fillna(False).astype(bool)
dataframe["bias_long_ok"] = bull
dataframe["bias_short_ok"] = bear
sos_conf = dataframe[f"sos_confirmed{ss}"].fillna(False).astype(bool)
sow_conf = dataframe[f"sow_confirmed{ss}"].fillna(False).astype(bool)
# 确认沿上升沿:4h 确认映射到 1h 后的首次 True
sos_event = sos_conf & ~sos_conf.shift(1).fillna(False)
sow_event = sow_conf & ~sow_conf.shift(1).fillna(False)
sos_level = dataframe[f"sos_break_level{ss}"]
sos_bvol = dataframe[f"sos_break_volume{ss}"]
sos_origin = dataframe[f"sos_origin{ss}"]
sow_level = dataframe[f"sow_break_level{ss}"]
sow_bvol = dataframe[f"sow_break_volume{ss}"]
sow_origin = dataframe[f"sow_origin{ss}"]
vol4 = dataframe[f"volume{ss}"]
sos_age = self._bars_since(sos_event)
sow_age = self._bars_since(sow_event)
post_high = self._expanding_max_since(sos_event, dataframe["high"])
post_low = self._expanding_min_since(sow_event, dataframe["low"])
atr = dataframe["atr"]
pb_min = float(self.lps_pb_atr_min.value)
pb_max = float(self.lps_pb_atr_max.value)
max_age = float(self.lps_max_age_1h.value)
# 回踩深度:SOS 后高点回撤的 ATR 倍数
retrace_long = (post_high - dataframe["low"]) / atr.replace(0, np.nan)
retrace_short = (dataframe["high"] - post_low) / atr.replace(0, np.nan)
near_sos = dataframe["low"] <= (sos_level + atr * 0.35)
near_sow = dataframe["high"] >= (sow_level - atr * 0.35)
vol_dry_long = vol4 < sos_bvol
vol_dry_short = vol4 < sow_bvol
reclaim_long = dataframe["close"] > dataframe["high"].shift(1)
reclaim_short = dataframe["close"] < dataframe["low"].shift(1)
first_near_long = near_sos & ~near_sos.shift(1).fillna(False)
first_near_short = near_sow & ~near_sow.shift(1).fillna(False)
alive_long = (
sos_age.notna()
& (sos_age >= 1)
& (sos_age <= max_age)
& (dataframe["close"] > sos_origin)
)
alive_short = (
sow_age.notna()
& (sow_age >= 1)
& (sow_age <= max_age)
& (dataframe["close"] < sow_origin)
)
dataframe["lps"] = (
alive_long
& first_near_long
& retrace_long.between(pb_min, pb_max)
& (dataframe["low"] > sos_origin)
& (dataframe["close"] >= sos_level * 0.995)
& vol_dry_long
& reclaim_long
& dataframe["bias_long_ok"]
)
dataframe["lpsy"] = (
alive_short
& first_near_short
& retrace_short.between(pb_min, pb_max)
& (dataframe["high"] < sow_origin)
& (dataframe["close"] <= sow_level * 1.005)
& vol_dry_short
& reclaim_short
& dataframe["bias_short_ok"]
)
dataframe["sos"] = sos_event
dataframe["sow"] = sow_event
dataframe["sos_level"] = sos_level
dataframe["sos_origin"] = sos_origin
dataframe["sow_level"] = sow_level
dataframe["sow_origin"] = sow_origin
dataframe["sos_age"] = sos_age
dataframe["sow_age"] = sow_age
for col in ["lps", "lpsy", "bias_long_ok", "bias_short_ok", "sos", "sow"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["lps"], "setup_type"] = "LPS"
dataframe.loc[dataframe["lpsy"], "setup_type"] = "LPSY"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
if bool(self.use_lps_long.value):
cond = vol_ok & dataframe["lps"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "LPS")
if bool(self.use_lps_short.value):
cond = vol_ok & dataframe["lpsy"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "LPSY")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
# 结构失效:收盘跌破 SOS 突破位 / 升破 SOW 突破位
exit_long = (
dataframe["sos_level"].notna()
& (dataframe["close"] < dataframe["sos_level"])
& (dataframe["close"] < dataframe["ema21"])
) | dataframe["sow"]
exit_short = (
dataframe["sow_level"].notna()
& (dataframe["close"] > dataframe["sow_level"])
& (dataframe["close"] > dataframe["ema21"])
) | dataframe["sos"]
dataframe.loc[exit_long.fillna(False), ["exit_long", "exit_tag"]] = (1, "lps_structure_fail")
dataframe.loc[exit_short.fillna(False), ["exit_short", "exit_tag"]] = (1, "lps_structure_fail")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe.empty:
return None
last = dataframe.iloc[-1]
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
if atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if tag == "LPS" and pd.notna(last.get("sos_origin")):
trade.set_custom_data("struct_stop", float(last["sos_origin"]) - buffer)
elif tag == "LPSY" and pd.notna(last.get("sow_origin")):
trade.set_custom_data("struct_stop", float(last["sow_origin"]) + buffer)
elif trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in ("LPS", "LPSY"):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
def leverage(
self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
side: str, **kwargs,
) -> float:
return min(self.lev, max_leverage)
@@ -0,0 +1,150 @@
{
"version": "LPS_V2",
"wfo": {
"train": {
"timerange": "20230101-20250101",
"profit_pct": -3.5049591933000004,
"trades": 3,
"dd_pct": 3.504959193300001,
"pf": 0.0,
"winrate": 0.0,
"final": 9649.50408067,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"validate": {
"timerange": "20250101-20260101",
"profit_pct": -1.6143743830000001,
"trades": 2,
"dd_pct": 1.614374382999995,
"pf": 0.0,
"winrate": 0.0,
"final": 9838.5625617,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"test": {
"timerange": "20260101-",
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"full": {
"timerange": "20230101-",
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
}
},
"regimes": {
"trend": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"bull": {
"profit_pct": -5.0599199851,
"trades": 5,
"dd_pct": 5.059919985100005,
"pf": 0.0,
"winrate": 0.0,
"final": 9494.00800149,
"fee_used": 0.0005,
"regime_loaded": "bull"
},
"bear": {
"profit_pct": 1.2174468187999996,
"trades": 2,
"dd_pct": 1.4924489317000007,
"pf": 1.81573767312309,
"winrate": 50.0,
"final": 10121.74468188,
"fee_used": 0.0005,
"regime_loaded": "bear"
},
"range": {
"profit_pct": 0.0,
"trades": 0,
"dd_pct": 0.0,
"pf": 0.0,
"winrate": 0.0,
"final": 10000.0,
"fee_used": 0.0005,
"regime_loaded": "range"
},
"all": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "all"
}
},
"cost_stress": {
"fee_5bps": {
"profit_pct": -3.9055710949000004,
"trades": 7,
"dd_pct": 6.479119889400008,
"pf": 0.39720654015221873,
"winrate": 14.285714285714285,
"final": 9609.44289051,
"fee_used": 0.0005,
"regime_loaded": "trend"
},
"fee_5bps+slip_5bps": {
"profit_pct": -4.5549168852,
"trades": 7,
"dd_pct": 7.020877735199993,
"pf": 0.3512325585213674,
"winrate": 14.285714285714285,
"final": 9544.50831148,
"fee_used": 0.001,
"regime_loaded": "trend"
},
"fee_10bps+slip_10bps": {
"profit_pct": -5.371762636800001,
"trades": 7,
"dd_pct": 8.1297357765,
"pf": 0.33924511392759654,
"winrate": 14.285714285714285,
"final": 9462.82373632,
"fee_used": 0.002,
"regime_loaded": "trend"
}
},
"target": {
"pf": 1.2,
"dd": 15.0,
"note": "LPS V2: 4h SOS→1h LPS; PF>1.2; ~5-15/yr"
},
"verdict": {
"full_pf": 0.39720654015221873,
"full_dd": 6.479119889400008,
"trades_per_year": 1.9444444444444444,
"net_mid_pf": 0.3512325585213674,
"target_pf_ok": false,
"target_dd_ok": true,
"freq_ok": false,
"regime_logic_ok": true,
"status": "FAIL",
"hypothesis": "4h native SOS → 1h LPS"
}
}