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