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