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>
488 lines
14 KiB
Python
488 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import logging
|
|
from datetime import timedelta, timezone
|
|
from typing import Literal
|
|
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
import matplotlib.pyplot as plt
|
|
import mplfinance as mpf
|
|
import pandas as pd
|
|
from matplotlib.patches import Rectangle
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ActionSide = Literal["long", "short"]
|
|
RIGHT_PAD_CANDLES = 15
|
|
UTC_PLUS_2 = timezone(timedelta(hours=2))
|
|
|
|
COLORS = {
|
|
"bg": "#0f1115",
|
|
"panel": "#0f1115",
|
|
"grid": "#1e222d",
|
|
"text": "#d1d4dc",
|
|
"up": "#26a69a",
|
|
"down": "#ef5350",
|
|
"entry": "#42a5f5",
|
|
"price": "#ffca28",
|
|
"sl": "#ef5350",
|
|
"tp1": "#66bb6a",
|
|
"tp2": "#43a047",
|
|
"tp3": "#2e7d32",
|
|
"reward_fill": (0.15, 0.65, 0.45),
|
|
"risk_fill": (0.85, 0.25, 0.25, 0.10),
|
|
"label_bg": "#0f1115",
|
|
"vol_up": "#26a69a",
|
|
"vol_down": "#ef5350",
|
|
}
|
|
|
|
# Reward zone opacities: Entry→TP1, TP1→TP2, TP2→TP3 (decreasing)
|
|
REWARD_ALPHAS = (0.16, 0.10, 0.06)
|
|
|
|
|
|
def _parse_price(raw: str) -> float:
|
|
return float(raw.strip().replace(" ", "").replace(",", "").replace("$", ""))
|
|
|
|
|
|
def _to_utc_plus_2(df: pd.DataFrame) -> pd.DataFrame:
|
|
out = df.copy()
|
|
idx = out.index
|
|
if idx.tz is None:
|
|
idx = idx.tz_localize("UTC")
|
|
out.index = idx.tz_convert(UTC_PLUS_2)
|
|
return out
|
|
|
|
|
|
def _pad_right(df: pd.DataFrame, candles: int = RIGHT_PAD_CANDLES) -> pd.DataFrame:
|
|
"""Append empty (NaN) candles so there is free space to the right of price action."""
|
|
if len(df) < 2:
|
|
delta = pd.Timedelta(minutes=15)
|
|
else:
|
|
delta = df.index[-1] - df.index[-2]
|
|
if not isinstance(delta, pd.Timedelta) or delta <= pd.Timedelta(0):
|
|
delta = pd.Timedelta(minutes=15)
|
|
|
|
future_index = pd.date_range(
|
|
start=df.index[-1] + delta,
|
|
periods=candles,
|
|
freq=delta,
|
|
tz=df.index.tz,
|
|
)
|
|
pad = pd.DataFrame(index=future_index, columns=df.columns, dtype=float)
|
|
return pd.concat([df, pad])
|
|
|
|
|
|
def _volume_overlay(
|
|
df: pd.DataFrame,
|
|
*,
|
|
y_low: float,
|
|
y_high: float,
|
|
fraction: float = 0.18,
|
|
) -> tuple[pd.Series, list[str]]:
|
|
"""Scale volume into the bottom of the price pane (TradingView-style)."""
|
|
span = y_high - y_low
|
|
height = span * fraction
|
|
base = y_low - span * 0.01
|
|
vol = df["Volume"].astype(float)
|
|
# Right-pad / empty candles: NaN so mplfinance skips the bar entirely
|
|
# (zero height still draws a stub from y=0 → base, often as black).
|
|
empty = df["Open"].isna() | df["Close"].isna() | vol.isna()
|
|
vol_filled = vol.fillna(0.0)
|
|
real = vol_filled[~empty]
|
|
vmax = float(real.max()) if len(real) else 1.0
|
|
if vmax <= 0:
|
|
vmax = 1.0
|
|
scaled = base + (vol_filled / vmax) * height
|
|
scaled = scaled.mask(empty)
|
|
|
|
colors: list[str] = []
|
|
for _, row in df.iterrows():
|
|
if pd.isna(row["Close"]) or pd.isna(row["Open"]):
|
|
colors.append(COLORS["bg"])
|
|
elif row["Close"] >= row["Open"]:
|
|
colors.append(COLORS["vol_up"])
|
|
else:
|
|
colors.append(COLORS["vol_down"])
|
|
return scaled, colors
|
|
|
|
|
|
def _position_start_x(df: pd.DataFrame, signal_time: int | None) -> int:
|
|
"""Integer x of the candle where the seq==1 position starts (fallback: last)."""
|
|
last_x = len(df) - 1
|
|
if signal_time is None or last_x < 0:
|
|
return max(last_x, 0)
|
|
ts = pd.Timestamp(int(signal_time), unit="s", tz="UTC")
|
|
if df.index.tz is not None:
|
|
ts = ts.tz_convert(df.index.tz)
|
|
# Last candle whose open time is <= signal time
|
|
pos = int(df.index.searchsorted(ts, side="right") - 1)
|
|
if pos < 0:
|
|
return 0
|
|
return min(pos, last_x)
|
|
|
|
|
|
def _draw_previous_trade(
|
|
ax,
|
|
df: pd.DataFrame,
|
|
*,
|
|
prev_entry: float,
|
|
prev_side: str,
|
|
prev_signal_time: int,
|
|
exit_price: float,
|
|
exit_x: int,
|
|
) -> None:
|
|
"""Entry → exit of the trade that this reversal closes."""
|
|
prev_x = _position_start_x(df, prev_signal_time)
|
|
if prev_x >= exit_x:
|
|
return
|
|
profitable = (
|
|
exit_price >= prev_entry if prev_side == "long" else exit_price <= prev_entry
|
|
)
|
|
color = COLORS["up"] if profitable else COLORS["down"]
|
|
y_low = min(prev_entry, exit_price)
|
|
y_high = max(prev_entry, exit_price)
|
|
height = y_high - y_low
|
|
if height <= 0:
|
|
height = abs(prev_entry) * 1e-6 or 1e-8
|
|
ax.add_patch(
|
|
Rectangle(
|
|
(prev_x, y_low),
|
|
exit_x - prev_x,
|
|
height,
|
|
facecolor=color,
|
|
edgecolor="none",
|
|
alpha=0.04,
|
|
zorder=1,
|
|
)
|
|
)
|
|
closes = df["Close"].iloc[prev_x : exit_x + 1].astype(float)
|
|
xs = list(range(prev_x, prev_x + len(closes)))
|
|
ax.plot(
|
|
xs,
|
|
closes.to_numpy(),
|
|
color=color,
|
|
linewidth=0.9,
|
|
linestyle=(0, (3, 4)),
|
|
alpha=0.28,
|
|
zorder=5,
|
|
solid_capstyle="round",
|
|
)
|
|
ax.plot(
|
|
[prev_x, exit_x],
|
|
[prev_entry, exit_price],
|
|
color=color,
|
|
linewidth=1.05,
|
|
linestyle=(0, (4, 5)),
|
|
alpha=0.45,
|
|
zorder=6,
|
|
)
|
|
ax.scatter(
|
|
[prev_x],
|
|
[prev_entry],
|
|
s=22,
|
|
c=color,
|
|
marker="o",
|
|
zorder=7,
|
|
edgecolors="#ffffff",
|
|
linewidths=0.7,
|
|
)
|
|
ax.annotate(
|
|
"Prev",
|
|
xy=(prev_x, prev_entry),
|
|
xytext=(6, 8),
|
|
textcoords="offset points",
|
|
va="bottom",
|
|
ha="left",
|
|
fontsize=7,
|
|
color=color,
|
|
zorder=8,
|
|
bbox={
|
|
"boxstyle": "round,pad=0.2",
|
|
"facecolor": COLORS["label_bg"],
|
|
"edgecolor": color,
|
|
"linewidth": 0.6,
|
|
"alpha": 0.88,
|
|
},
|
|
)
|
|
|
|
|
|
def render_setup_chart(
|
|
df: pd.DataFrame,
|
|
*,
|
|
ticker: str,
|
|
action: ActionSide,
|
|
entry: str,
|
|
stop_loss: str,
|
|
tp1: str,
|
|
timeframe: str,
|
|
tp2: str | None = None,
|
|
tp3: str | None = None,
|
|
current_price: str | None = None,
|
|
signal_time: int | None = None,
|
|
right_pad: int | None = None,
|
|
prev_entry: str | None = None,
|
|
prev_side: str | None = None,
|
|
prev_signal_time: int | None = None,
|
|
) -> bytes:
|
|
entry_p = _parse_price(entry)
|
|
sl_p = _parse_price(stop_loss)
|
|
tp1_p = _parse_price(tp1)
|
|
tp2_p = _parse_price(tp2) if tp2 else None
|
|
tp3_p = _parse_price(tp3) if tp3 else None
|
|
current_p = _parse_price(current_price) if current_price is not None else None
|
|
prev_entry_p = _parse_price(prev_entry) if prev_entry else None
|
|
|
|
is_long = action == "long"
|
|
reward_rgb = COLORS["reward_fill"]
|
|
|
|
df = _to_utc_plus_2(df)
|
|
|
|
# Position tool starts at seq==1 candle; "now" is the last real candle
|
|
now_x = len(df) - 1
|
|
entry_x = _position_start_x(df, signal_time)
|
|
plot_df = _pad_right(df, RIGHT_PAD_CANDLES if right_pad is None else right_pad)
|
|
|
|
level_prices = [entry_p, sl_p, tp1_p]
|
|
if tp2_p is not None:
|
|
level_prices.append(tp2_p)
|
|
if tp3_p is not None:
|
|
level_prices.append(tp3_p)
|
|
if current_p is not None:
|
|
level_prices.append(current_p)
|
|
if prev_entry_p is not None:
|
|
level_prices.append(prev_entry_p)
|
|
y_min = min(float(df["Low"].min()), *level_prices)
|
|
y_max = max(float(df["High"].max()), *level_prices)
|
|
hold_remainder = tp2_p is None and tp3_p is None
|
|
price_pad = (y_max - y_min) * 0.06 or y_max * 0.002
|
|
|
|
vol_scaled, vol_colors = _volume_overlay(plot_df, y_low=y_min, y_high=y_max)
|
|
|
|
addplots = [
|
|
mpf.make_addplot(
|
|
vol_scaled,
|
|
type="bar",
|
|
panel=0,
|
|
color=vol_colors,
|
|
width=0.8,
|
|
alpha=0.15, # ~85% transparent
|
|
secondary_y=False,
|
|
),
|
|
]
|
|
|
|
mc = mpf.make_marketcolors(
|
|
up=COLORS["up"],
|
|
down=COLORS["down"],
|
|
edge="inherit",
|
|
wick="inherit",
|
|
volume="in",
|
|
)
|
|
style = mpf.make_mpf_style(
|
|
base_mpf_style="nightclouds",
|
|
marketcolors=mc,
|
|
facecolor=COLORS["bg"],
|
|
figcolor=COLORS["bg"],
|
|
gridcolor=COLORS["grid"],
|
|
gridstyle="--",
|
|
y_on_right=True,
|
|
rc={
|
|
"axes.labelcolor": COLORS["text"],
|
|
"xtick.color": COLORS["text"],
|
|
"ytick.color": COLORS["text"],
|
|
"axes.edgecolor": COLORS["grid"],
|
|
"figure.facecolor": COLORS["bg"],
|
|
"axes.facecolor": COLORS["panel"],
|
|
"font.size": 9,
|
|
},
|
|
)
|
|
|
|
fig, axes = mpf.plot(
|
|
plot_df,
|
|
type="candle",
|
|
style=style,
|
|
volume=False,
|
|
addplot=addplots,
|
|
returnfig=True,
|
|
figsize=(12, 7),
|
|
tight_layout=True,
|
|
datetime_format="%m-%d\n%H:%M",
|
|
warn_too_much_data=10_000,
|
|
xrotation=0,
|
|
ylabel="",
|
|
)
|
|
ax = axes[0]
|
|
ax.set_ylabel("")
|
|
for label in ax.get_xticklabels():
|
|
label.set_horizontalalignment("center")
|
|
label.set_fontsize(8)
|
|
label.set_linespacing(1.35)
|
|
|
|
# Room for volume bars under candles
|
|
vol_floor = float(vol_scaled.dropna().min()) if vol_scaled.notna().any() else y_min
|
|
ax.set_ylim(min(y_min - price_pad, vol_floor) - price_pad * 0.3, y_max + price_pad)
|
|
ylim_lo, ylim_hi = ax.get_ylim()
|
|
remainder_end = (ylim_hi if is_long else ylim_lo) if hold_remainder else None
|
|
|
|
x_right = ax.get_xlim()[1]
|
|
zone_width = x_right - entry_x
|
|
|
|
# Levels + zones start at the entry (last real) candle, not full chart width
|
|
level_specs: list[tuple[float, str, str, float]] = [
|
|
(entry_p, COLORS["entry"], "--", 1.2),
|
|
(sl_p, COLORS["sl"], "-", 1.2),
|
|
(tp1_p, COLORS["tp1"], ":", 1.0),
|
|
]
|
|
if tp2_p is not None:
|
|
level_specs.append((tp2_p, COLORS["tp2"], ":", 1.0))
|
|
if tp3_p is not None:
|
|
level_specs.append((tp3_p, COLORS["tp3"], ":", 1.0))
|
|
for price, color, ls, lw in level_specs:
|
|
ax.hlines(
|
|
price,
|
|
xmin=entry_x,
|
|
xmax=x_right,
|
|
colors=color,
|
|
linestyles=ls,
|
|
linewidths=lw,
|
|
alpha=0.95,
|
|
zorder=4,
|
|
)
|
|
|
|
risk_low = min(entry_p, sl_p)
|
|
risk_high = max(entry_p, sl_p)
|
|
ax.add_patch(
|
|
Rectangle(
|
|
(entry_x, risk_low),
|
|
zone_width,
|
|
risk_high - risk_low,
|
|
facecolor=COLORS["risk_fill"],
|
|
edgecolor="none",
|
|
zorder=0,
|
|
)
|
|
)
|
|
|
|
reward_bands: list[tuple[float, float, float]] = [
|
|
(entry_p, tp1_p, REWARD_ALPHAS[0]),
|
|
]
|
|
if tp2_p is not None:
|
|
reward_bands.append((tp1_p, tp2_p, REWARD_ALPHAS[1]))
|
|
if tp2_p is not None and tp3_p is not None:
|
|
reward_bands.append((tp2_p, tp3_p, REWARD_ALPHAS[2]))
|
|
elif tp3_p is not None:
|
|
reward_bands.append((tp1_p, tp3_p, REWARD_ALPHAS[2]))
|
|
elif remainder_end is not None:
|
|
reward_bands.append((tp1_p, remainder_end, REWARD_ALPHAS[1]))
|
|
for price_a, price_b, alpha in reward_bands:
|
|
band_low = min(price_a, price_b)
|
|
band_high = max(price_a, price_b)
|
|
ax.add_patch(
|
|
Rectangle(
|
|
(entry_x, band_low),
|
|
zone_width,
|
|
band_high - band_low,
|
|
facecolor=(*reward_rgb, alpha),
|
|
edgecolor="none",
|
|
zorder=0,
|
|
)
|
|
)
|
|
|
|
ax.scatter(
|
|
[entry_x],
|
|
[entry_p],
|
|
s=22,
|
|
c=COLORS["entry"],
|
|
marker="o",
|
|
zorder=7,
|
|
edgecolors="#ffffff",
|
|
linewidths=0.7,
|
|
)
|
|
|
|
if (
|
|
prev_entry_p is not None
|
|
and prev_side is not None
|
|
and prev_signal_time is not None
|
|
and prev_signal_time > 0
|
|
):
|
|
_draw_previous_trade(
|
|
ax,
|
|
df,
|
|
prev_entry=prev_entry_p,
|
|
prev_side=prev_side,
|
|
prev_signal_time=prev_signal_time,
|
|
exit_price=entry_p,
|
|
exit_x=entry_x,
|
|
)
|
|
|
|
if current_p is not None:
|
|
ax.hlines(
|
|
current_p,
|
|
xmin=now_x,
|
|
xmax=x_right,
|
|
colors=COLORS["price"],
|
|
linestyles="-.",
|
|
linewidths=1.0,
|
|
alpha=0.95,
|
|
zorder=5,
|
|
)
|
|
ax.scatter(
|
|
[now_x],
|
|
[current_p],
|
|
s=28,
|
|
c=COLORS["price"],
|
|
marker="D",
|
|
zorder=7,
|
|
edgecolors="#ffffff",
|
|
linewidths=0.7,
|
|
)
|
|
|
|
labels = [
|
|
(entry_p, f"Entry {entry}", COLORS["entry"]),
|
|
(sl_p, f"SL {stop_loss}", COLORS["sl"]),
|
|
(tp1_p, f"TP1 {tp1}", COLORS["tp1"]),
|
|
]
|
|
if tp2_p is not None and tp2 is not None:
|
|
labels.append((tp2_p, f"TP2 {tp2}", COLORS["tp2"]))
|
|
if tp3_p is not None and tp3 is not None:
|
|
labels.append((tp3_p, f"TP3 {tp3}", COLORS["tp3"]))
|
|
if current_p is not None and current_price is not None:
|
|
labels.append((current_p, f"Price {current_price}", COLORS["price"]))
|
|
for price, text, color in labels:
|
|
ax.annotate(
|
|
text,
|
|
xy=(x_right, price),
|
|
xytext=(6, 0),
|
|
textcoords="offset points",
|
|
va="center",
|
|
ha="left",
|
|
fontsize=8,
|
|
color=color,
|
|
clip_on=False,
|
|
zorder=8,
|
|
bbox={
|
|
"boxstyle": "round,pad=0.28",
|
|
"facecolor": COLORS["label_bg"],
|
|
"edgecolor": color,
|
|
"linewidth": 0.8,
|
|
"alpha": 0.92,
|
|
},
|
|
)
|
|
|
|
side = "LONG" if is_long else "SHORT"
|
|
ax.set_title(
|
|
f"{ticker} · {timeframe} · {side}",
|
|
color=COLORS["text"],
|
|
fontsize=12,
|
|
pad=12,
|
|
)
|
|
|
|
fig.subplots_adjust(right=0.82)
|
|
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, format="png", dpi=140, facecolor=COLORS["bg"], bbox_inches="tight")
|
|
plt.close(fig)
|
|
buf.seek(0)
|
|
return buf.read()
|