tvsignals-to-tg/app/indicators/common.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

236 lines
8.2 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
import numpy as np
import pandas as pd
StrategyId = Literal["ltf", "fvg"]
Side = Literal["long", "short"]
@dataclass(frozen=True)
class IndicatorSignal:
strategy_id: StrategyId
side: Side
entry: float
close: float
sl: float
tp1: float
tp2: float | None
tp3: float | None
size_usd: float
bar_open_ts: int
visual_timeframe: str
def ohlc_frame(df: pd.DataFrame) -> pd.DataFrame:
"""Normalize OHLC frame to lowercase columns and UTC DatetimeIndex."""
out = df.copy()
out.columns = [str(c).lower() for c in out.columns]
required = {"open", "high", "low", "close"}
missing = required - set(out.columns)
if missing:
raise ValueError(f"OHLC frame missing columns: {sorted(missing)}")
if not isinstance(out.index, pd.DatetimeIndex):
raise ValueError("OHLC frame must be indexed by timestamp")
if out.index.tz is None:
out.index = out.index.tz_localize("UTC")
else:
out.index = out.index.tz_convert("UTC")
cols = ["open", "high", "low", "close"]
if "volume" in out.columns:
cols.append("volume")
return out[cols]
def epoch_ms(index: pd.DatetimeIndex) -> np.ndarray:
utc = index.tz_convert("UTC") if index.tz is not None else index.tz_localize("UTC")
return utc.asi8.astype(np.float64) / 1_000_000.0
def alma(
series: pd.Series, length: int = 500, offset: float = 0.85, sigma: float = 5.0
) -> pd.Series:
"""Arnaud Legoux Moving Average (Pine ta.alma compatible)."""
if length < 1:
raise ValueError("ALMA length must be >= 1")
m = offset * (length - 1)
s = length / sigma
idx = np.arange(length, dtype=float)
weights = np.exp(-((idx - m) ** 2) / (2 * s * s))
weights /= weights.sum()
values = series.to_numpy(dtype=float)
out = np.full(len(values), np.nan, dtype=float)
if len(values) < length:
return pd.Series(out, index=series.index)
valid = np.convolve(values, weights[::-1], mode="valid")
out[length - 1 :] = valid
nan_in_window = np.convolve(np.isnan(values).astype(float), np.ones(length), mode="valid") > 0
out[length - 1 :][nan_in_window] = np.nan
return pd.Series(out, index=series.index)
def rma(series: pd.Series, length: int) -> pd.Series:
"""Wilder's RMA (Pine ta.rma)."""
return series.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
def ha_rsi(
open_: pd.Series,
high: pd.Series,
low: pd.Series,
close: pd.Series,
length: int = 14,
) -> pd.Series:
"""RSI on Heikin-Ashi close (Pine f_ha_rsi)."""
ha_close = (open_ + high + low + close) / 4.0
delta = ha_close.diff()
up = rma(delta.clip(lower=0), length)
down = rma((-delta).clip(lower=0), length)
rs = up / down.replace(0, np.nan)
rsi = 100 - (100 / (1 + rs))
rsi = rsi.where(down != 0, 100.0)
rsi = rsi.where(up != 0, 0.0)
both_zero = (up == 0) & (down == 0)
return rsi.where(~both_zero, 100.0)
def true_range(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
prev_close = close.shift(1)
return pd.concat(
[high - low, (high - prev_close).abs(), (low - prev_close).abs()],
axis=1,
).max(axis=1)
def atr(high: pd.Series, low: pd.Series, close: pd.Series, length: int) -> pd.Series:
"""Pine ta.atr(length)."""
return rma(true_range(high, low, close), length)
def compute_fractals(high: np.ndarray, low: np.ndarray, n: int = 5) -> tuple[np.ndarray, np.ndarray]:
"""Pine upFractal / downFractal flags on the confirmation bar (pivot at i - n)."""
size = len(high)
up = np.zeros(size, dtype=bool)
down = np.zeros(size, dtype=bool)
for c in range(n, size):
p = c - n
hp = high[p]
lp = low[p]
if np.isnan(hp) or np.isnan(lp):
continue
up_prefix1 = (p + 1 < size) and (high[p + 1] <= hp)
up_prefix2 = up_prefix1 and (p + 2 < size) and (high[p + 2] <= hp)
up_prefix3 = up_prefix2 and (p + 3 < size) and (high[p + 3] <= hp)
up_prefix4 = up_prefix3 and (p + 4 < size) and (high[p + 4] <= hp)
down_prefix1 = (p + 1 < size) and (low[p + 1] >= lp)
down_prefix2 = down_prefix1 and (p + 2 < size) and (low[p + 2] >= lp)
down_prefix3 = down_prefix2 and (p + 3 < size) and (low[p + 3] >= lp)
down_prefix4 = down_prefix3 and (p + 4 < size) and (low[p + 4] >= lp)
upflag_down = True
upflag0 = True
upflag1 = True
upflag2 = True
upflag3 = True
upflag4 = True
for i in range(1, n + 1):
if p - i < 0 or not (high[p - i] < hp):
upflag_down = False
if p + i >= size or not (high[p + i] < hp):
upflag0 = False
if p + i + 1 >= size or not (high[p + i + 1] < hp):
upflag1 = False
if p + i + 2 >= size or not (high[p + i + 2] < hp):
upflag2 = False
if p + i + 3 >= size or not (high[p + i + 3] < hp):
upflag3 = False
if p + i + 4 >= size or not (high[p + i + 4] < hp):
upflag4 = False
upflag1 = upflag1 and up_prefix1
upflag2 = upflag2 and up_prefix2
upflag3 = upflag3 and up_prefix3
upflag4 = upflag4 and up_prefix4
up[c] = upflag_down and (upflag0 or upflag1 or upflag2 or upflag3 or upflag4)
downflag_down = True
downflag0 = True
downflag1 = True
downflag2 = True
downflag3 = True
downflag4 = True
for i in range(1, n + 1):
if p - i < 0 or not (low[p - i] > lp):
downflag_down = False
if p + i >= size or not (low[p + i] > lp):
downflag0 = False
if p + i + 1 >= size or not (low[p + i + 1] > lp):
downflag1 = False
if p + i + 2 >= size or not (low[p + i + 2] > lp):
downflag2 = False
if p + i + 3 >= size or not (low[p + i + 3] > lp):
downflag3 = False
if p + i + 4 >= size or not (low[p + i + 4] > lp):
downflag4 = False
downflag1 = downflag1 and down_prefix1
downflag2 = downflag2 and down_prefix2
downflag3 = downflag3 and down_prefix3
downflag4 = downflag4 and down_prefix4
down[c] = downflag_down and (downflag0 or downflag1 or downflag2 or downflag3 or downflag4)
return up, down
def tick_decimals(tick: float) -> int:
exponent = Decimal(str(tick)).normalize().as_tuple().exponent
if isinstance(exponent, int):
return max(0, -exponent)
return 0
def round_to_mintick(price: float, tick: float) -> float:
"""Pine math.round_to_mintick (half-up to exchange tick)."""
step = Decimal(str(tick))
if step <= 0:
return price
quantized = (Decimal(str(price)) / step).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return float(quantized * step)
def realized_pnl_pct(prev_side: str, prev_entry: float, exit_price: float) -> float:
"""Signed percent from previous entry to exit; long/short from the closed side."""
if prev_entry == 0:
raise ValueError("entry price is zero")
if prev_side == "long":
return (exit_price - prev_entry) / prev_entry * 100
return (prev_entry - exit_price) / prev_entry * 100
def calc_size(entry: float, sl: float, risk_usd: float) -> float:
"""Pine calc_size: round(risk / abs(entry-sl)/entry)."""
if entry == 0 or np.isnan(entry) or np.isnan(sl):
return float("nan")
stop_pct = abs(entry - sl) / entry
if stop_pct <= 0:
return float("nan")
return float(round(risk_usd / stop_pct))
def format_px(value: float | None, tick: float | None = None) -> str:
if value is None:
return ""
number = float(value)
if np.isnan(number):
return ""
if tick is not None and tick > 0:
rounded = round_to_mintick(number, tick)
return f"{rounded:.{tick_decimals(tick)}f}"
if abs(number) >= 1000:
text = f"{number:.4f}"
elif abs(number) >= 1:
text = f"{number:.6f}"
else:
text = f"{number:.8f}"
return text.rstrip("0").rstrip(".")