mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
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>
384 lines
13 KiB
Python
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
|