tvsignals-to-tg/app/indicators/ltf.py
Artemii Peretiachenko cdbda8fea3 Replace TradingView polling with a local LTF/FVG scanner that posts Telegram cards and Heryon webhooks.
Per-strategy Heryon accounts, reversal captions with previous-trade path on charts, and Telegram replies chained by ticker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:37:16 +02:00

384 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from app.indicators.common import (
IndicatorSignal,
alma,
calc_size,
compute_fractals,
epoch_ms,
ha_rsi,
ohlc_frame,
)
@dataclass(frozen=True)
class LtfParams:
fractal_n: int = 5
alma_length: int = 500
engulf_threshold: float = 0.9
rsi_6h_overbought: float = 75.0
rsi_6h_oversold: float = 20.0
tp1_rr: float = 1.75
tp2_rr: float = 2.0
tp3_rr: float = 4.0
risk_usd: float = 40.0
cooldown_ms: int = 360 * 60 * 1000
engulf_reset_ms: int = 12 * 60 * 60 * 1000
tf_minutes: int = 15
visual_timeframe: str = "15"
def _merge_asof(df_15m: pd.DataFrame, df_6h: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
left = pd.DataFrame({"ts": df_15m.index})
right = pd.DataFrame({"ts": df_6h.index})
for col in columns:
right[col] = df_6h[col].to_numpy()
left = left.sort_values("ts")
right = right.sort_values("ts")
merged = pd.merge_asof(left, right, on="ts", direction="backward")
merged = merged.set_index("ts")
return merged.reindex(df_15m.index)
def _compute_6h(
df_6h: pd.DataFrame,
*,
engulf_threshold: float,
forming: pd.Series | None,
) -> pd.DataFrame:
df = ohlc_frame(df_6h)
df["ha_rsi"] = ha_rsi(df["open"], df["high"], df["low"], df["close"], 14)
df["h6_close"] = df["close"]
df["h6_prev_close"] = df["close"].shift(1)
df["h6_open"] = df["open"]
df["h6_prev_open"] = df["open"].shift(1)
body2 = (df["h6_prev_open"] - df["h6_prev_close"]).abs()
bull_min = df["h6_prev_close"] + body2 * engulf_threshold
bear_min = df["h6_prev_close"] - body2 * engulf_threshold
bull_engulf = (
(df["h6_close"] > df["h6_open"])
& (df["h6_close"] >= bull_min)
& (df["h6_prev_close"] < df["h6_prev_open"])
)
bear_engulf = (
(df["h6_close"] < df["h6_open"])
& (df["h6_close"] <= bear_min)
& (df["h6_prev_close"] > df["h6_prev_open"])
)
n = len(df)
is_forming = np.zeros(n, dtype=bool)
if forming is not None:
is_forming = forming.reindex(df.index).fillna(False).to_numpy(dtype=bool)
state = np.empty(n, dtype=object)
state[:] = ""
engulf_time = np.full(n, np.nan)
cur_state = ""
cur_time = np.nan
ts_ms = epoch_ms(df.index)
bull_a = bull_engulf.fillna(False).to_numpy(dtype=bool)
bear_a = bear_engulf.fillna(False).to_numpy(dtype=bool)
for i in range(n):
ts = ts_ms[i]
if not np.isnan(cur_time) and (ts - cur_time) > 12 * 60 * 60 * 1000:
cur_state = ""
cur_time = np.nan
if is_forming[i]:
state[i] = cur_state
engulf_time[i] = cur_time
continue
if bull_a[i]:
cur_state = "bull"
cur_time = ts
elif bear_a[i]:
cur_state = "bear"
cur_time = ts
else:
cur_state = ""
cur_time = np.nan
state[i] = cur_state
engulf_time[i] = cur_time
df["engulf_state"] = state
df["engulf_time"] = engulf_time
return df
def attach_forming_6h(df_6h_closed: pd.DataFrame, df_15m: pd.DataFrame) -> pd.DataFrame:
"""Rebuild the in-progress 6h candle from closed 15m bars (Pine request.security)."""
closed = ohlc_frame(df_6h_closed)
ltf = ohlc_frame(df_15m)
if ltf.empty:
closed["_forming"] = False
return closed
period = ltf.index[-1].floor("6h")
window = ltf.loc[ltf.index >= period]
if window.empty:
closed["_forming"] = False
return closed
# 6h just closed and is already in the closed frame.
if not closed.empty and closed.index[-1] == period:
closed["_forming"] = False
return closed
row = pd.DataFrame(
{
"open": [float(window["open"].iloc[0])],
"high": [float(window["high"].max())],
"low": [float(window["low"].min())],
"close": [float(window["close"].iloc[-1])],
},
index=pd.DatetimeIndex([period], tz="UTC"),
)
if "volume" in window.columns:
row["volume"] = float(window["volume"].sum())
row["_forming"] = True
base = closed[closed.index < period].copy()
base["_forming"] = False
return pd.concat([base, row])
def evaluate_ltf(
df_15m: pd.DataFrame,
df_6h: pd.DataFrame,
params: LtfParams | None = None,
*,
forming_6h: pd.Series | None = None,
) -> pd.DataFrame:
"""Compute LTF columns. Last row is the latest closed 15m bar."""
p = params or LtfParams()
df = ohlc_frame(df_15m)
forming_flag = forming_6h
if forming_flag is None and "_forming" in df_6h.columns:
forming_flag = df_6h["_forming"].astype(bool)
h6 = _compute_6h(df_6h, engulf_threshold=p.engulf_threshold, forming=forming_flag)
if forming_flag is not None:
closed_mask = ~forming_flag.reindex(h6.index).fillna(False)
h6_closed = h6.loc[closed_mask]
else:
h6_closed = h6
n = int(p.fractal_n)
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
up_frac, down_frac = compute_fractals(high, low, n=n)
df["up_fractal"] = up_frac
df["down_fractal"] = down_frac
up_level = np.full(len(df), np.nan)
down_level = np.full(len(df), np.nan)
last_up = np.nan
last_down = np.nan
for i in range(len(df)):
if up_frac[i]:
last_up = high[i - n]
if down_frac[i]:
last_down = low[i - n]
up_level[i] = last_up
down_level[i] = last_down
df["up_fractal_level"] = up_level
df["down_fractal_level"] = down_level
prev_up = df["up_fractal_level"].shift(1)
prev_down = df["down_fractal_level"].shift(1)
df["buy_crossover"] = (
(df["close"] > prev_up) & (df["close"].shift(1) <= prev_up.shift(1)) & prev_up.notna()
)
df["sell_crossover"] = (
(df["close"] < prev_down) & (df["close"].shift(1) >= prev_down.shift(1)) & prev_down.notna()
)
bars_16h = max(1, int(round(1440 / p.tf_minutes)))
high_shift = df["high"].shift(1)
low_shift = df["low"].shift(1)
df["high_16h"] = high_shift.rolling(bars_16h, min_periods=bars_16h).max()
df["low_16h"] = low_shift.rolling(bars_16h, min_periods=bars_16h).min()
df["bull_breakout"] = df["high"] > df["high_16h"]
df["bear_breakout"] = df["low"] < df["low_16h"]
df["alma"] = alma(df["close"], length=p.alma_length, offset=0.85, sigma=5.0)
rsi_m = _merge_asof(df, h6, ["ha_rsi"])
closed_src = h6_closed if not h6_closed.empty else h6
closed_m = _merge_asof(
df, closed_src, ["engulf_state", "engulf_time", "h6_close", "h6_prev_close"]
)
df["rsi_6h"] = rsi_m["ha_rsi"]
df["h6_engulf_state"] = closed_m["engulf_state"].fillna("").astype(str)
df["h6_closed_close"] = closed_m["h6_close"]
df["h6_prev_close"] = closed_m["h6_prev_close"]
engulf_time = closed_m["engulf_time"].to_numpy(dtype=float)
ts_ms = epoch_ms(df.index)
engulf_state = df["h6_engulf_state"].to_numpy()
for i in range(len(df)):
et = engulf_time[i]
if not np.isnan(et) and (ts_ms[i] - et) > p.engulf_reset_ms:
engulf_state[i] = ""
df["h6_engulf_state"] = engulf_state
bull_arr = (df["bull_breakout"] | (df["h6_engulf_state"] == "bull")).to_numpy(dtype=bool)
bear_arr = (df["bear_breakout"] | (df["h6_engulf_state"] == "bear")).to_numpy(dtype=bool)
last_eng = np.empty(len(df), dtype=object)
last_eng[:] = ""
cur = ""
for i in range(len(df)):
b = bull_arr[i]
s = bear_arr[i]
if b and not s:
cur = "bull"
elif s and not b:
cur = "bear"
last_eng[i] = cur
df["last_eng"] = last_eng
alma_up = (df["alma"] > df["alma"].shift(1)) & (df["alma"].shift(1) > df["alma"].shift(2))
alma_down = (df["alma"] < df["alma"].shift(1)) & (df["alma"].shift(1) < df["alma"].shift(2))
alma_6h_long = (df["h6_closed_close"] > df["alma"]) & (
df["h6_prev_close"] > df["alma"].shift(1)
)
alma_6h_short = (df["h6_closed_close"] < df["alma"]) & (
df["h6_prev_close"] < df["alma"].shift(1)
)
buy_6h_ok = df["rsi_6h"] < p.rsi_6h_overbought
sell_6h_ok = df["rsi_6h"] > p.rsi_6h_oversold
buy_sig = np.zeros(len(df), dtype=bool)
sell_sig = np.zeros(len(df), dtype=bool)
last_buy_time = np.nan
last_sell_time = np.nan
open_side = ""
long_sl_px = np.nan
short_sl_px = np.nan
close_a = df["close"].to_numpy(dtype=float)
low_a = df["low"].to_numpy(dtype=float)
high_a = df["high"].to_numpy(dtype=float)
buy_x = df["buy_crossover"].fillna(False).to_numpy(dtype=bool)
sell_x = df["sell_crossover"].fillna(False).to_numpy(dtype=bool)
alma_up_a = alma_up.fillna(False).to_numpy(dtype=bool)
alma_down_a = alma_down.fillna(False).to_numpy(dtype=bool)
alma_6h_long_a = alma_6h_long.fillna(False).to_numpy(dtype=bool)
alma_6h_short_a = alma_6h_short.fillna(False).to_numpy(dtype=bool)
buy_6h_a = buy_6h_ok.fillna(False).to_numpy(dtype=bool)
sell_6h_a = sell_6h_ok.fillna(False).to_numpy(dtype=bool)
long_sl_arr = df["low_16h"].to_numpy(dtype=float)
short_sl_arr = df["high_16h"].to_numpy(dtype=float)
for i in range(len(df)):
if not np.isnan(long_sl_px) and (close_a[i] < long_sl_px or low_a[i] <= long_sl_px):
long_sl_px = np.nan
if open_side == "buy":
open_side = ""
if not np.isnan(short_sl_px) and (close_a[i] > short_sl_px or high_a[i] >= short_sl_px):
short_sl_px = np.nan
if open_side == "sell":
open_side = ""
ts = ts_ms[i]
buy_cd = np.isnan(last_buy_time) or (ts - last_buy_time >= p.cooldown_ms)
sell_cd = np.isnan(last_sell_time) or (ts - last_sell_time >= p.cooldown_ms)
is_buy = (
buy_x[i]
and open_side != "buy"
and last_eng[i] == "bull"
and (alma_up_a[i] or alma_6h_long_a[i])
and buy_cd
and buy_6h_a[i]
)
is_sell = (
sell_x[i]
and open_side != "sell"
and last_eng[i] == "bear"
and (alma_down_a[i] or alma_6h_short_a[i])
and sell_cd
and sell_6h_a[i]
)
if is_buy:
buy_sig[i] = True
last_buy_time = ts
open_side = "buy"
long_sl_px = long_sl_arr[i]
short_sl_px = np.nan
if is_sell:
sell_sig[i] = True
last_sell_time = ts
open_side = "sell"
short_sl_px = short_sl_arr[i]
long_sl_px = np.nan
df["buy_sig"] = buy_sig
df["sell_sig"] = sell_sig
df["long_sl"] = df["low_16h"]
df["short_sl"] = df["high_16h"]
df["long_tp1"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp1_rr
df["long_tp2"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp2_rr
df["long_tp3"] = df["close"] + (df["close"] - df["long_sl"]) * p.tp3_rr
df["short_tp1"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp1_rr
df["short_tp2"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp2_rr
df["short_tp3"] = df["close"] - (df["short_sl"] - df["close"]) * p.tp3_rr
return df
def last_ltf_signal(df: pd.DataFrame, params: LtfParams | None = None) -> IndicatorSignal | None:
if df.empty:
return None
p = params or LtfParams()
last = df.iloc[-1]
ts = int(df.index[-1].timestamp())
close = float(last["close"])
if bool(last["buy_sig"]):
sl = float(last["long_sl"])
if np.isnan(sl) or np.isnan(close):
return None
size = calc_size(close, sl, p.risk_usd)
if np.isnan(size):
return None
return IndicatorSignal(
strategy_id="ltf",
side="long",
entry=close,
close=close,
sl=sl,
tp1=float(last["long_tp1"]),
tp2=float(last["long_tp2"]),
tp3=float(last["long_tp3"]),
size_usd=size,
bar_open_ts=ts,
visual_timeframe=p.visual_timeframe,
)
if bool(last["sell_sig"]):
sl = float(last["short_sl"])
if np.isnan(sl) or np.isnan(close):
return None
size = calc_size(close, sl, p.risk_usd)
if np.isnan(size):
return None
return IndicatorSignal(
strategy_id="ltf",
side="short",
entry=close,
close=close,
sl=sl,
tp1=float(last["short_tp1"]),
tp2=float(last["short_tp2"]),
tp3=float(last["short_tp3"]),
size_usd=size,
bar_open_ts=ts,
visual_timeframe=p.visual_timeframe,
)
return None