|
| 1 | +# Fibonacci Level Interaction Events (research-only) |
| 2 | + |
| 3 | +Status: **RESEARCH** — implements issue #8. |
| 4 | + |
| 5 | +Where a swing previously carried a single behavior label per Fibonacci level, this |
| 6 | +overlay records **an event stream per level**: every time price interacts with a level |
| 7 | +it emits a *candidate* event with a timestamp and supporting evidence, for human review. |
| 8 | + |
| 9 | +> To human-validate these candidates on a phone, see |
| 10 | +> [LEVEL_EVENT_HUMAN_REVIEW.md](LEVEL_EVENT_HUMAN_REVIEW.md). |
| 11 | +
|
| 12 | +## What it does |
| 13 | + |
| 14 | +For a selected swing, `detect_level_events()` scans the bars **after the leg's end** |
| 15 | +(the retracement window) and, for each Fibonacci level, emits events classified as: |
| 16 | + |
| 17 | +| candidate | meaning | |
| 18 | +|--------------------------|----------------------------------------------------------| |
| 19 | +| `continuation_candidate` | broke through the level and continued | |
| 20 | +| `rejection_candidate` | touched the level and rejected back to the approach side | |
| 21 | +| `failure_candidate` | accepted beyond the level, then reversed back across it | |
| 22 | +| `reaction_candidate` | reacted at the level without a clear breakout/rejection | |
| 23 | + |
| 24 | +Each event records `touch_type` (`wick_below` / `wick_above` / `close_above` / |
| 25 | +`close_below`), `approach_side` (`above` / `below`), and `evidence` |
| 26 | +(`forward_bars`, `closes_beyond`, `closes_back`, `max_penetration_atr`). |
| 27 | + |
| 28 | +## Guardrails |
| 29 | + |
| 30 | +- **Candidates, never facts.** The `*_candidate` naming is deliberate — events are inputs |
| 31 | + to human review, never auto-accepted. |
| 32 | +- **Look-ahead is intentional.** Classification inspects a forward window of bars after a |
| 33 | + touch, so this is strictly **post-hoc annotation, never a live trading signal**. |
| 34 | +- **Additive only.** It does not change swing selection, fib anchors/prices, evaluation, |
| 35 | + recall or promotion. Output goes to a new file; no existing artifacts are mutated. |
| 36 | + |
| 37 | +## Configuration (`config/settings.yaml` → `level_events`) |
| 38 | + |
| 39 | +| key | default | meaning | |
| 40 | +|----------------------------|---------|---------------------------------------------------------------| |
| 41 | +| `levels` | `[]` | fib ratios to scan; empty inherits `fib.levels` | |
| 42 | +| `touch_tolerance_atr` | `0.10` | band half-width around a level = this × ATR at the bar | |
| 43 | +| `forward_window` | `5` | bars after a touch used for classification | |
| 44 | +| `acceptance_closes` | `2` | closes beyond the level required to count as "accepted" | |
| 45 | +| `immediate_rejection_bars` | `2` | window for a quick close back to the approach side | |
| 46 | +| `debounce_bars` | `3` | bars price must leave the band before a new event is counted | |
| 47 | + |
| 48 | +## Run |
| 49 | + |
| 50 | +```sh |
| 51 | +uv run python -m fibengine.research.level_events # single snapshot |
| 52 | +uv run python -m fibengine.research.level_events --mode walk-forward |
| 53 | +uv run python -m fibengine.research.level_events --mode walk-forward --dedupe |
| 54 | +``` |
| 55 | + |
| 56 | +**`single`** selects one swing on the full series and detects events after its leg. |
| 57 | +Appends a record to `experiments/results/level_events.jsonl` (`run_id`, config/symbol |
| 58 | +metadata, the selected `swing`, the per-level event streams, and `n_events`). |
| 59 | + |
| 60 | +Note: a single live "as-of-now" run usually picks a leg ending at the present, leaving no |
| 61 | +forward window — so it often reports **0 events**. The interactions the issue cares about |
| 62 | +require a leg that has had time to "live". That is what walk-forward mode provides. |
| 63 | + |
| 64 | +## Walk-forward mode (answers research Q4) |
| 65 | + |
| 66 | +**`walk-forward`** steps the cursor through history (`backtest.warmup_bars` / `backtest.step`), |
| 67 | +selecting swings *causally* (no future leaks into selection), and aggregates level events |
| 68 | +across every distinct **confirmed** leg via `walk_forward_level_events()`. It reuses |
| 69 | +`backtest.stability.walk_forward_selection()`. Output goes to |
| 70 | +`experiments/results/level_events_walkforward.jsonl`: |
| 71 | + |
| 72 | +```json |
| 73 | +{ |
| 74 | + "n_legs": 224, "n_events": 4835, "events_per_leg": 21.58, |
| 75 | + "per_level": [{"level": "0.382", "events": 964, |
| 76 | + "by_candidate": {"continuation": 308, "failure": 113, |
| 77 | + "reaction": 214, "rejection": 329}}, ...], |
| 78 | + "legs": [{"first_confirmed_t": ..., "start_bar": ..., "end_bar": ..., |
| 79 | + "direction": ..., "n_events": ...}, ...] |
| 80 | +} |
| 81 | +``` |
| 82 | + |
| 83 | +**Caveat — overlapping legs inflate absolute totals.** With `step=1` nearly every bar |
| 84 | +yields a (slightly drifted) confirmed leg, and in the default (`forward`) attribution each |
| 85 | +leg's events are counted over the full forward history, so the same price action is counted |
| 86 | +under many overlapping legs. The absolute `n_events` is then sensitive to `step`. |
| 87 | + |
| 88 | +**Use `--dedupe` (non-overlapping attribution) for the trustworthy census.** Each bar is |
| 89 | +attributed to exactly one leg — the one that was the live confirmed selection at that bar |
| 90 | +(window `[confirmation cursor t, next leg's t)`) — so no event is double-counted. This |
| 91 | +matters: on Kraken BTC/USD daily the `forward` mode shows a misleadingly *flat* per-level |
| 92 | +distribution (~19-22% each, 4835 events), while `--dedupe` reveals the real gradient — |
| 93 | +shallow levels dominate (0.236/0.382 ≈ 28% each) and deep levels are rare |
| 94 | +(0.786 ≈ 10%), across 142 distinct interactions. Prefer `--dedupe` when answering |
| 95 | +"how many events per level". |
| 96 | + |
| 97 | +## Data / running |
| 98 | + |
| 99 | +Candles are fetched on demand and cached locally by `load_candles()` (under `data/raw/`, |
| 100 | +which is **not** versioned — see the repo data policy). The first run for a symbol/timeframe |
| 101 | +needs network; subsequent runs read the local cache. Point the config at any symbol: |
| 102 | + |
| 103 | +```python |
| 104 | +from fibengine.core.config import load_settings |
| 105 | +from fibengine.research.level_events import run_walk_forward_level_events |
| 106 | + |
| 107 | +s = load_settings() |
| 108 | +s = s.model_copy(update={"data": s.data.model_copy(update={ |
| 109 | + "exchange": "kraken", "symbol": "BTC/USD", "timeframe": "1d"})}) |
| 110 | +run_walk_forward_level_events(s, non_overlapping=True) |
| 111 | +``` |
| 112 | + |
| 113 | +Config is supplied via `LevelEventConfig` (defaults are used unless you pass your own); |
| 114 | +it is intentionally **not** part of canonical `Settings`, so `Settings.config_hash()` and |
| 115 | +the Promotion surface stay untouched. |
| 116 | + |
| 117 | +Note: the repo default exchange is Binance, which is geo-restricted from some hosted |
| 118 | +sandboxes (HTTP 451); Kraken/Coinbase/Bitstamp/Bitfinex are reachable alternatives there. |
| 119 | +On a normal machine the Binance default works as usual. Tests rely only on synthetic data, |
| 120 | +so they need no network. |
0 commit comments