Skip to content

Commit 2ce5933

Browse files
committed
Add internal and external liquidity zones indicator live mode
1 parent 3a8b5b5 commit 2ce5933

4 files changed

Lines changed: 340 additions & 0 deletions

File tree

docs/content/indicators/support-resistance/internal-external-liquidity-zones.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,46 @@ print(f"Internal sweeps: {stats['total_int_sweeps']}")
122122
print(f"Bullish sweep ratio: {stats['bullish_sweep_ratio']}")
123123
```
124124

125+
## Live Trading Signals (No Lookahead)
126+
127+
:::warning[Lookahead Bias]
128+
The standard `internal_external_liquidity_zones_signal()` function signals sweeps at the **pivot bar**, but pivots are only confirmed after `pivot_length` bars into the future. This creates **lookahead bias** that inflates backtest results.
129+
:::
130+
131+
For **live trading** and **realistic backtesting**, use `internal_external_liquidity_zones_signal_live()` instead. This function delays zone activation until pivots are confirmed:
132+
133+
- **External zones**: delayed by `external_pivot_length` bars (default: 10)
134+
- **Internal zones**: delayed by `internal_pivot_length` bars (default: 3)
135+
136+
```python
137+
from pyindicators import (
138+
internal_external_liquidity_zones,
139+
internal_external_liquidity_zones_signal_live,
140+
)
141+
142+
# First compute standard IELZ zones
143+
df = internal_external_liquidity_zones(
144+
df,
145+
internal_pivot_length=3,
146+
external_pivot_length=10,
147+
)
148+
149+
# Then compute live signals (no lookahead)
150+
df = internal_external_liquidity_zones_signal_live(
151+
df,
152+
internal_pivot_length=3, # Must match original!
153+
external_pivot_length=10, # Must match original!
154+
)
155+
156+
# Use the live signal for trading
157+
live_signals = df[df['ielz_live_signal'] != 0]
158+
```
159+
160+
The live signal function returns:
161+
- `ielz_live_ext_sweep_bull` / `ielz_live_ext_sweep_bear`: External zone sweeps (no lookahead)
162+
- `ielz_live_int_sweep_bull` / `ielz_live_int_sweep_bear`: Internal zone sweeps (no lookahead)
163+
- `ielz_live_signal`: Combined signal (1 = bullish, -1 = bearish, 0 = none)
164+
125165
The function returns:
126166
- `ielz_ext_high` / `ielz_ext_low`: 1 on bars where an external high/low zone is created
127167
- `ielz_ext_high_price` / `ielz_ext_low_price`: Price level of the external pivot (NaN otherwise)

pyindicators/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
get_premium_discount_zones_stats,
4646
internal_external_liquidity_zones,
4747
internal_external_liquidity_zones_signal,
48+
internal_external_liquidity_zones_signal_live,
4849
get_internal_external_liquidity_zones_stats,
4950
volume_weighted_trend,
5051
volume_weighted_trend_signal,
@@ -205,6 +206,7 @@ def get_version():
205206
'get_swing_structure_stats',
206207
'internal_external_liquidity_zones',
207208
'internal_external_liquidity_zones_signal',
209+
'internal_external_liquidity_zones_signal_live',
208210
'get_internal_external_liquidity_zones_stats',
209211
'premium_discount_zones',
210212
'premium_discount_zones_signal',

pyindicators/indicators/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
from .internal_external_liquidity_zones import (
8787
internal_external_liquidity_zones,
8888
internal_external_liquidity_zones_signal,
89+
internal_external_liquidity_zones_signal_live,
8990
get_internal_external_liquidity_zones_stats
9091
)
9192
from .volume_weighted_trend import (
@@ -247,6 +248,7 @@
247248
'get_premium_discount_zones_stats',
248249
'internal_external_liquidity_zones',
249250
'internal_external_liquidity_zones_signal',
251+
'internal_external_liquidity_zones_signal_live',
250252
'get_internal_external_liquidity_zones_stats',
251253
'volume_weighted_trend',
252254
'volume_weighted_trend_signal',

pyindicators/indicators/internal_external_liquidity_zones.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,302 @@ def internal_external_liquidity_zones_signal(
327327
)
328328

329329

330+
def internal_external_liquidity_zones_signal_live(
331+
data: Union[PdDataFrame, PlDataFrame],
332+
internal_pivot_length: int = 3,
333+
external_pivot_length: int = 10,
334+
atr_length: int = 14,
335+
zone_size_atr: float = 0.40,
336+
high_column: str = "High",
337+
low_column: str = "Low",
338+
ext_high_column: str = "ielz_ext_high",
339+
ext_low_column: str = "ielz_ext_low",
340+
ext_high_price_column: str = "ielz_ext_high_price",
341+
ext_low_price_column: str = "ielz_ext_low_price",
342+
int_high_column: str = "ielz_int_high",
343+
int_low_column: str = "ielz_int_low",
344+
int_high_price_column: str = "ielz_int_high_price",
345+
int_low_price_column: str = "ielz_int_low_price",
346+
ext_sweep_bull_column: str = "ielz_live_ext_sweep_bull",
347+
ext_sweep_bear_column: str = "ielz_live_ext_sweep_bear",
348+
int_sweep_bull_column: str = "ielz_live_int_sweep_bull",
349+
int_sweep_bear_column: str = "ielz_live_int_sweep_bear",
350+
signal_column: str = "ielz_live_signal",
351+
) -> Union[PdDataFrame, PlDataFrame]:
352+
"""
353+
Generate trading signals from IELZ zones **without lookahead bias**.
354+
355+
Unlike :func:`internal_external_liquidity_zones_signal`, this
356+
function delays zone activation by the pivot confirmation window.
357+
Pivots are only confirmed ``pivot_length`` bars after the pivot
358+
bar, so zones cannot trigger sweeps until they are confirmed.
359+
360+
This makes the signals suitable for **live trading** and
361+
**realistic backtesting**.
362+
363+
Args:
364+
data: DataFrame with IELZ columns (output of
365+
:func:`internal_external_liquidity_zones`).
366+
internal_pivot_length: The internal pivot length used in the
367+
original calculation (must match). Zones are delayed by
368+
this many bars.
369+
external_pivot_length: The external pivot length used in the
370+
original calculation (must match). External zones are
371+
delayed by this many bars.
372+
atr_length: ATR period used for zone sizing
373+
(default: 14).
374+
zone_size_atr: Half-height of zones as ATR fraction
375+
(default: 0.40). Must match original calculation.
376+
high_column: Column name for highs.
377+
low_column: Column name for lows.
378+
ext_high_column: Column with external high zone flags.
379+
ext_low_column: Column with external low zone flags.
380+
ext_high_price_column: Column with external high prices.
381+
ext_low_price_column: Column with external low prices.
382+
int_high_column: Column with internal high zone flags.
383+
int_low_column: Column with internal low zone flags.
384+
int_high_price_column: Column with internal high prices.
385+
int_low_price_column: Column with internal low prices.
386+
ext_sweep_bull_column: Output column for live external
387+
bullish sweeps.
388+
ext_sweep_bear_column: Output column for live external
389+
bearish sweeps.
390+
int_sweep_bull_column: Output column for live internal
391+
bullish sweeps.
392+
int_sweep_bear_column: Output column for live internal
393+
bearish sweeps.
394+
signal_column: Output column for combined signal.
395+
396+
Returns:
397+
DataFrame with live sweep columns and combined signal:
398+
399+
- ``{ext_sweep_bull_column}`` - 1 on external low zone sweep
400+
- ``{ext_sweep_bear_column}`` - 1 on external high zone sweep
401+
- ``{int_sweep_bull_column}`` - 1 on internal low zone sweep
402+
- ``{int_sweep_bear_column}`` - 1 on internal high zone sweep
403+
- ``{signal_column}`` - 1 (bullish), -1 (bearish), or 0
404+
405+
Example:
406+
>>> df = internal_external_liquidity_zones(df, ...)
407+
>>> df = internal_external_liquidity_zones_signal_live(
408+
... df,
409+
... internal_pivot_length=3,
410+
... external_pivot_length=10,
411+
... )
412+
>>> # Use ielz_live_signal for trading
413+
"""
414+
if isinstance(data, PlDataFrame):
415+
import polars as pl
416+
417+
pd_data = data.to_pandas()
418+
result = _ielz_signal_live_pandas(
419+
pd_data,
420+
internal_pivot_length=internal_pivot_length,
421+
external_pivot_length=external_pivot_length,
422+
atr_length=atr_length,
423+
zone_size_atr=zone_size_atr,
424+
high_column=high_column,
425+
low_column=low_column,
426+
ext_high_column=ext_high_column,
427+
ext_low_column=ext_low_column,
428+
ext_high_price_column=ext_high_price_column,
429+
ext_low_price_column=ext_low_price_column,
430+
int_high_column=int_high_column,
431+
int_low_column=int_low_column,
432+
int_high_price_column=int_high_price_column,
433+
int_low_price_column=int_low_price_column,
434+
ext_sweep_bull_column=ext_sweep_bull_column,
435+
ext_sweep_bear_column=ext_sweep_bear_column,
436+
int_sweep_bull_column=int_sweep_bull_column,
437+
int_sweep_bear_column=int_sweep_bear_column,
438+
signal_column=signal_column,
439+
)
440+
return pl.from_pandas(result)
441+
elif isinstance(data, PdDataFrame):
442+
return _ielz_signal_live_pandas(
443+
data,
444+
internal_pivot_length=internal_pivot_length,
445+
external_pivot_length=external_pivot_length,
446+
atr_length=atr_length,
447+
zone_size_atr=zone_size_atr,
448+
high_column=high_column,
449+
low_column=low_column,
450+
ext_high_column=ext_high_column,
451+
ext_low_column=ext_low_column,
452+
ext_high_price_column=ext_high_price_column,
453+
ext_low_price_column=ext_low_price_column,
454+
int_high_column=int_high_column,
455+
int_low_column=int_low_column,
456+
int_high_price_column=int_high_price_column,
457+
int_low_price_column=int_low_price_column,
458+
ext_sweep_bull_column=ext_sweep_bull_column,
459+
ext_sweep_bear_column=ext_sweep_bear_column,
460+
int_sweep_bull_column=int_sweep_bull_column,
461+
int_sweep_bear_column=int_sweep_bear_column,
462+
signal_column=signal_column,
463+
)
464+
else:
465+
raise PyIndicatorException(
466+
"Input data must be a pandas or polars DataFrame."
467+
)
468+
469+
470+
def _ielz_signal_live_pandas(
471+
data: PdDataFrame,
472+
*,
473+
internal_pivot_length: int,
474+
external_pivot_length: int,
475+
atr_length: int,
476+
zone_size_atr: float,
477+
high_column: str,
478+
low_column: str,
479+
ext_high_column: str,
480+
ext_low_column: str,
481+
ext_high_price_column: str,
482+
ext_low_price_column: str,
483+
int_high_column: str,
484+
int_low_column: str,
485+
int_high_price_column: str,
486+
int_low_price_column: str,
487+
ext_sweep_bull_column: str,
488+
ext_sweep_bear_column: str,
489+
int_sweep_bull_column: str,
490+
int_sweep_bear_column: str,
491+
signal_column: str,
492+
) -> PdDataFrame:
493+
"""Core pandas implementation for live signal generation."""
494+
data = data.copy()
495+
n = len(data)
496+
497+
high_arr = data[high_column].values.astype(float)
498+
low_arr = data[low_column].values.astype(float)
499+
500+
# Compute ATR for zone sizing
501+
close_arr = data["Close"].values.astype(float)
502+
atr_arr = _compute_atr(high_arr, low_arr, close_arr, atr_length)
503+
504+
# Zone detection columns (these have lookahead in their detection)
505+
ext_high_flags = data[ext_high_column].values
506+
ext_low_flags = data[ext_low_column].values
507+
ext_high_prices = data[ext_high_price_column].values
508+
ext_low_prices = data[ext_low_price_column].values
509+
510+
int_high_flags = data[int_high_column].values
511+
int_low_flags = data[int_low_column].values
512+
int_high_prices = data[int_high_price_column].values
513+
int_low_prices = data[int_low_price_column].values
514+
515+
# Output arrays
516+
out_ext_sweep_bull = np.zeros(n, dtype=int)
517+
out_ext_sweep_bear = np.zeros(n, dtype=int)
518+
out_int_sweep_bull = np.zeros(n, dtype=int)
519+
out_int_sweep_bear = np.zeros(n, dtype=int)
520+
521+
# Pending zones: detected but not yet confirmed
522+
pending_zones: List[dict] = []
523+
# Active zones: confirmed and ready for sweep detection
524+
active_zones: List[dict] = []
525+
526+
for i in range(n):
527+
cur_atr = atr_arr[i] if not np.isnan(atr_arr[i]) else 0.0
528+
half_zone = cur_atr * zone_size_atr * 0.5
529+
530+
# ── Detect new zones and add to pending ──────────────────
531+
zone_defs = [
532+
(ext_high_flags, ext_high_prices,
533+
external_pivot_length, True, True),
534+
(ext_low_flags, ext_low_prices,
535+
external_pivot_length, False, True),
536+
(int_high_flags, int_high_prices,
537+
internal_pivot_length, True, False),
538+
(int_low_flags, int_low_prices,
539+
internal_pivot_length, False, False),
540+
]
541+
542+
for flags, prices, delay, is_high, is_external in zone_defs:
543+
if flags[i] == 1:
544+
price = prices[i]
545+
if not np.isnan(price):
546+
# Zone detected at bar i, confirmed at bar i + delay
547+
pending_zones.append({
548+
"detected": i,
549+
"confirmed": i + delay,
550+
"price": price,
551+
"top": price + half_zone,
552+
"bottom": price - half_zone,
553+
"is_high": is_high,
554+
"is_external": is_external,
555+
"state": 0, # 0 = active, 1 = swept
556+
})
557+
558+
# ── Move confirmed zones to active ───────────────────────
559+
newly_confirmed = [z for z in pending_zones if z["confirmed"] <= i]
560+
active_zones.extend(newly_confirmed)
561+
pending_zones = [z for z in pending_zones if z["confirmed"] > i]
562+
563+
# ── Check for sweeps on active zones ─────────────────────
564+
h_i = high_arr[i]
565+
lo_i = low_arr[i]
566+
567+
for zone in active_zones:
568+
if zone["state"] != 0:
569+
continue
570+
571+
swept = False
572+
if zone["is_high"]:
573+
# High zone: swept when price wicks into zone from below
574+
if h_i >= zone["bottom"]:
575+
swept = True
576+
else:
577+
# Low zone: swept when price wicks into zone from above
578+
if lo_i <= zone["top"]:
579+
swept = True
580+
581+
if swept:
582+
zone["state"] = 1
583+
if zone["is_external"]:
584+
if zone["is_high"]:
585+
out_ext_sweep_bear[i] = 1
586+
else:
587+
out_ext_sweep_bull[i] = 1
588+
else:
589+
if zone["is_high"]:
590+
out_int_sweep_bear[i] = 1
591+
else:
592+
out_int_sweep_bull[i] = 1
593+
594+
# ── Remove swept zones to keep list manageable ───────────
595+
active_zones = [z for z in active_zones if z["state"] == 0]
596+
597+
# ── Build combined signal ────────────────────────────────────
598+
signal = np.where(
599+
out_ext_sweep_bull == 1,
600+
1,
601+
np.where(
602+
out_ext_sweep_bear == 1,
603+
-1,
604+
np.where(
605+
out_int_sweep_bull == 1,
606+
1,
607+
np.where(
608+
out_int_sweep_bear == 1,
609+
-1,
610+
0,
611+
),
612+
),
613+
),
614+
)
615+
616+
# ── Write output columns ─────────────────────────────────────
617+
data[ext_sweep_bull_column] = out_ext_sweep_bull
618+
data[ext_sweep_bear_column] = out_ext_sweep_bear
619+
data[int_sweep_bull_column] = out_int_sweep_bull
620+
data[int_sweep_bear_column] = out_int_sweep_bear
621+
data[signal_column] = signal
622+
623+
return data
624+
625+
330626
def get_internal_external_liquidity_zones_stats(
331627
data: Union[PdDataFrame, PlDataFrame],
332628
ext_high_column: str = "ielz_ext_high",

0 commit comments

Comments
 (0)