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>
236 lines
8.2 KiB
Python
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(".")
|