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

149 lines
4.4 KiB
Python

from __future__ import annotations
from app.models import Action, SignalPayload
def _parse_price_number(raw: str) -> float:
text = raw.strip().replace(" ", "").replace(",", "")
if text.startswith("$"):
text = text[1:]
return float(text)
def format_sl_distance_pct(entry_raw: str, sl_raw: str, *, is_long: bool) -> str:
"""Percent move from entry to SL; shown as risk (negative) for both sides."""
entry = _parse_price_number(entry_raw)
if entry == 0:
raise ValueError("entry price is zero")
sl = _parse_price_number(sl_raw)
pct = (sl - entry) / entry * 100
if not is_long:
pct = -pct
return f"({pct:.2f}%)"
def format_current_profit(entry_raw: str, current_raw: str, sl_raw: str, *, is_long: bool) -> str:
"""Signed profit % vs entry and RR vs original SL distance (e.g. +1.6% (RR 1:1.2))."""
entry = _parse_price_number(entry_raw)
if entry == 0:
raise ValueError("entry price is zero")
current = _parse_price_number(current_raw)
sl = _parse_price_number(sl_raw)
if is_long:
profit_pct = (current - entry) / entry * 100
else:
profit_pct = (entry - current) / entry * 100
sl_dist = abs(entry - sl)
if sl_dist == 0:
raise ValueError("stop loss equals entry")
move = abs(current - entry)
rr = move / sl_dist
if profit_pct < 0:
rr = -rr
return f"{profit_pct:+.1f}% (RR 1:{rr:.1f})"
def format_price(raw: str) -> str:
"""Insert thousand spaces and prefix with $; preserve decimal precision from TV."""
text = raw.strip().replace(" ", "").replace(",", "")
if text.startswith("$"):
text = text[1:]
negative = text.startswith("-")
if negative:
text = text[1:]
if "." in text:
whole, frac = text.split(".", 1)
else:
whole, frac = text, None
whole = whole.lstrip("0") or "0"
grouped = _group_thousands(whole)
if frac is not None:
formatted = f"{grouped}.{frac}"
else:
formatted = grouped
if negative:
formatted = f"-{formatted}"
return f"${formatted}"
def _group_thousands(digits: str) -> str:
if len(digits) <= 3:
return digits
parts: list[str] = []
while digits:
parts.append(digits[-3:])
digits = digits[:-3]
return " ".join(reversed(parts))
def format_caption(signal: SignalPayload) -> str:
is_long = signal.action == Action.LONG
seq = signal.signal_sequence
if seq == 1:
label = "💚 Buy" if is_long else "💔 Sell"
else:
label = f"🌱 Buy Seq: {seq}" if is_long else f"🥀 Sell Seq: {seq}"
if seq > 1:
entry = format_price(signal.entry_price)
price = format_price(signal.current_price)
profit = format_current_profit(
signal.entry_price,
signal.current_price,
signal.stop_loss_price,
is_long=is_long,
)
text = (
f"<b>{signal.ticker}</b> {label}\n"
f"\n"
f"Entry price: {entry}\n"
f"Price: {price}\n"
f"Current profit: {profit}"
)
if signal.is_reversal and signal.realized_pnl_pct is not None:
text += (
f"\n\n<i>reversal, realized PnL {signal.realized_pnl_pct:+.2f}%</i>"
)
return text
price = format_price(signal.entry_price)
sl = format_price(signal.stop_loss_price)
sl_pct = format_sl_distance_pct(
signal.entry_price, signal.stop_loss_price, is_long=is_long
)
tp1 = format_price(signal.take_profit_1_price)
hold_remainder = not signal.take_profit_2_price and not signal.take_profit_3_price
lines = [
f"<b>{signal.ticker}</b> {label}\n",
f"Price: {price}",
f"SL: {sl} {sl_pct}",
"",
f"TP1+BE: {tp1}" if hold_remainder else f"TP1: {tp1}",
]
if signal.take_profit_2_price:
lines.append(f"TP2: {format_price(signal.take_profit_2_price)}")
if signal.take_profit_3_price:
lines.append(f"TP3: {format_price(signal.take_profit_3_price)}")
if hold_remainder:
lines.extend(
[
"",
"Fix 30%, hold remainder until reversal signal.",
]
)
if signal.is_reversal and signal.realized_pnl_pct is not None:
lines.extend(
[
"",
f"<i>reversal, realized PnL {signal.realized_pnl_pct:+.2f}%</i>",
]
)
return "\n".join(lines)