2023-12-10 11:52:49 +01:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
from datetime import timedelta, datetime
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2025-08-24 12:46:56 +02:00
|
|
|
import requests
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
import pandas as pd
|
2025-08-24 12:46:56 +02:00
|
|
|
import pandas.api.types as ptypes
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
from service.App import *
|
|
|
|
|
from common.utils import *
|
2025-06-16 17:52:46 +02:00
|
|
|
from common.model_store import *
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
log = logging.getLogger('notifier')
|
|
|
|
|
|
|
|
|
|
|
2025-06-16 17:52:46 +02:00
|
|
|
async def send_score_notification(df, model: dict, config: dict, model_store: ModelStore):
|
2025-03-25 19:56:20 +01:00
|
|
|
symbol = config["symbol"]
|
|
|
|
|
freq = config["freq"]
|
2025-08-24 12:46:56 +02:00
|
|
|
time_column = config["time_column"]
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
score_column_names = model.get("score_column_names")
|
|
|
|
|
if not score_column_names:
|
|
|
|
|
log.error(f"Empty list of score columns in score notifier. At least one column name with a score has to be provided in config. Ignore")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
row = df.iloc[-1] # Last row stores the latest values we need
|
|
|
|
|
|
2024-09-18 13:25:20 +02:00
|
|
|
interval_length = pd.Timedelta(freq).to_pytimedelta()
|
2025-08-24 12:46:56 +02:00
|
|
|
|
2025-11-02 13:08:09 +01:00
|
|
|
if ptypes.is_datetime64_any_dtype(df.index): # Alternatively df.index.inferred_type == "datetime64"
|
2025-08-24 12:46:56 +02:00
|
|
|
close_time = row.name
|
2025-11-02 13:08:09 +01:00
|
|
|
elif time_column in df.columns and ptypes.is_datetime64_any_dtype(df[time_column]):
|
2025-08-24 12:46:56 +02:00
|
|
|
close_time = row[time_column]
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError(f"Neither index nor time columns '{time_column}' are of datetime type")
|
|
|
|
|
close_time += interval_length # Add interval length because timestamp is start of the interval
|
|
|
|
|
|
2023-12-10 11:52:49 +01:00
|
|
|
close_price = row["close"]
|
|
|
|
|
trade_scores = [row[col] for col in score_column_names]
|
|
|
|
|
trade_score_primary = trade_scores[0]
|
|
|
|
|
trade_score_secondary = trade_scores[1] if len(trade_scores) > 1 else None
|
|
|
|
|
|
2025-03-30 14:07:33 +02:00
|
|
|
#
|
2023-12-10 11:52:49 +01:00
|
|
|
# Determine the band for the current score
|
2025-03-30 14:07:33 +02:00
|
|
|
#
|
|
|
|
|
band_no, band = _find_score_band(trade_score_primary, model)
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
#
|
|
|
|
|
# To message or not to message depending on score value and time
|
|
|
|
|
#
|
|
|
|
|
|
2025-03-30 14:07:33 +02:00
|
|
|
# Determine if the band was changed since the last time. Essentially, this means absolute signal strength increased
|
|
|
|
|
# We store the previous band no as the model attribute
|
2023-12-10 11:52:49 +01:00
|
|
|
prev_band_no = model.get("prev_band_no")
|
2025-04-06 11:03:34 +02:00
|
|
|
if prev_band_no is not None:
|
2025-03-30 14:07:33 +02:00
|
|
|
band_up = abs(band_no) > abs(prev_band_no) # Examples: 0 -> 1, 1 -> 2, -1 -> 2
|
|
|
|
|
band_dn = abs(band_no) < abs(prev_band_no) # Examples: -2 -> 0, 2 -> -1, -2 -> -1
|
|
|
|
|
else:
|
2025-04-06 11:03:34 +02:00
|
|
|
band_up = True
|
|
|
|
|
band_dn = True
|
2025-03-30 14:07:33 +02:00
|
|
|
model["prev_band_no"] = band_no # Store for the next time as an additional run-time attribute
|
2023-12-10 11:52:49 +01:00
|
|
|
|
2025-03-30 20:26:03 +02:00
|
|
|
if band and band.get("frequency"):
|
2023-12-10 11:52:49 +01:00
|
|
|
new_to_time_interval = close_time.minute % band.get("frequency") == 0
|
|
|
|
|
else:
|
|
|
|
|
new_to_time_interval = False
|
|
|
|
|
|
2025-03-30 14:07:33 +02:00
|
|
|
# Send only if one of these conditions is true or entered new time interval (current time)
|
2023-12-10 11:52:49 +01:00
|
|
|
notification_is_needed = (
|
2025-03-30 14:07:33 +02:00
|
|
|
(model.get("notify_band_up") and band_up) or # entered a higher band (absolute score increased). always notify when band changed
|
|
|
|
|
(model.get("notify_band_dn") and band_dn) or # returned to a lower band (absolute score decreased). always notify when band changed
|
|
|
|
|
new_to_time_interval # new time interval is started like 10 minutes (minimum frequency independent of the band changes)
|
2023-12-10 11:52:49 +01:00
|
|
|
)
|
2025-03-30 14:07:33 +02:00
|
|
|
# We might also exclude any notifications in case of no band (neutral zone)
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
if not notification_is_needed:
|
|
|
|
|
return # Nothing important happened: within the same band and same time interval
|
|
|
|
|
|
|
|
|
|
#
|
|
|
|
|
# Build a message with parameters from the current band
|
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
# Crypto Currency Symbols: https://github.com/yonilevy/crypto-currency-symbols
|
|
|
|
|
if symbol == "BTCUSDT":
|
|
|
|
|
symbol_char = "₿"
|
|
|
|
|
elif symbol == "ETHUSDT":
|
|
|
|
|
symbol_char = "Ξ"
|
|
|
|
|
else:
|
|
|
|
|
symbol_char = symbol
|
|
|
|
|
|
|
|
|
|
if band_up:
|
|
|
|
|
band_change_char = "↑"
|
|
|
|
|
elif band_dn:
|
|
|
|
|
band_change_char = "↓"
|
|
|
|
|
else:
|
|
|
|
|
band_change_char = ""
|
|
|
|
|
|
|
|
|
|
primary_score_str = f"{trade_score_primary:+.2f} {band_change_char} "
|
|
|
|
|
secondary_score_str = f"{trade_score_secondary:+.2f}" if trade_score_secondary is not None else ''
|
|
|
|
|
|
2025-03-30 14:07:33 +02:00
|
|
|
if band:
|
2025-04-06 11:03:34 +02:00
|
|
|
message = f"{band.get('sign', '')} {symbol_char} {int(close_price):,} Indicator: {primary_score_str} {secondary_score_str} {band.get('text', '')} {freq}"
|
2025-03-30 14:07:33 +02:00
|
|
|
if band.get("bold"):
|
|
|
|
|
message = "*" + message + "*"
|
|
|
|
|
else:
|
|
|
|
|
# Default message if the score in the neutral (very weak) zone which is not covered by the config bands
|
2025-04-06 11:03:34 +02:00
|
|
|
message = f"{symbol_char} {int(close_price):,} Indicator: {primary_score_str} {secondary_score_str} {freq}"
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
message = message.replace("+", "%2B") # For Telegram to display plus sign
|
|
|
|
|
|
|
|
|
|
#
|
|
|
|
|
# Send notification
|
|
|
|
|
#
|
2025-03-25 19:56:20 +01:00
|
|
|
bot_token = config["telegram_bot_token"]
|
|
|
|
|
chat_id = config["telegram_chat_id"]
|
2023-12-10 11:52:49 +01:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
url = 'https://api.telegram.org/bot' + bot_token + '/sendMessage?chat_id=' + chat_id + '&parse_mode=markdown&text=' + message
|
|
|
|
|
response = requests.get(url)
|
|
|
|
|
response_json = response.json()
|
|
|
|
|
if not response_json.get('ok'):
|
|
|
|
|
log.error(f"Error sending notification.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log.error(f"Error sending notification: {e}")
|
|
|
|
|
|
|
|
|
|
|
2025-03-30 14:07:33 +02:00
|
|
|
def _find_score_band(score_value, model):
|
|
|
|
|
"""
|
|
|
|
|
Find band number and the band object given two lists with thresholds.
|
|
|
|
|
|
|
|
|
|
The first list specifies lower bounds for the score and the function returns the first largest band which is less
|
|
|
|
|
than or equal to the score. Band number is positive: 1, 2,...
|
|
|
|
|
|
|
|
|
|
The second list specifies upper bounds for the score and the function returns the first smallest band which greater
|
|
|
|
|
than the score. Band number is negative: -1, -2,---
|
|
|
|
|
|
|
|
|
|
If the score does not fit into any band, then band number is 0 and None for the band object are returned.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# First, check if the score falls within some positive thresholds (with greater than condition)
|
|
|
|
|
bands = model.get("positive_bands", [])
|
|
|
|
|
bands = sorted(bands, key=lambda x: x.get("edge"), reverse=True) # Large thresholds first
|
|
|
|
|
# Find first entry with the edge equal or less than the score
|
|
|
|
|
band_no, band = next(((i, x) for i, x in enumerate(bands) if score_value >= x.get("edge")), (len(bands), None))
|
|
|
|
|
band_no = len(bands) - band_no
|
|
|
|
|
if not band: # Score is too small - smaller than all thresholds
|
|
|
|
|
bands = model.get("negative_bands", [])
|
|
|
|
|
bands = sorted(bands, key=lambda x: x.get("edge"), reverse=False) # Small thresholds first
|
|
|
|
|
band_no, band = next(((i, x) for i, x in enumerate(bands) if score_value < x.get("edge")), (len(bands), None))
|
|
|
|
|
band_no = -(len(bands) - band_no)
|
|
|
|
|
|
|
|
|
|
return band_no, band
|