From e2e853434e31d2f34ddc085f1f4cc4a654f2f946 Mon Sep 17 00:00:00 2001 From: Yarik Briukhovetskyi Date: Thu, 27 Nov 2025 00:12:27 +0100 Subject: [PATCH 1/2] feat: Add multi-language support (i18n) for 6 languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create i18n module with YAML-based translation system - Add translation files for English, Russian, Ukrainian, German, French, Dutch - Add language preference to database schema - Create /language command for users to switch languages - Update all user-facing strings across handlers, tutorial, help, errors - Add PyYAML dependency Supported languages: 🇬🇧 EN | 🇷🇺 RU | 🇺🇦 UK | 🇩🇪 DE | 🇫🇷 FR | 🇳🇱 NL --- requirements.txt | 1 + src/core/bot.py | 12 + src/core/error_handler.py | 60 +-- src/database/models.py | 4 +- src/database/operations.py | 52 ++- src/i18n/__init__.py | 232 +++++++++++ src/i18n/locales/de.yaml | 364 +++++++++++++++++ src/i18n/locales/en.yaml | 433 +++++++++++++++++++++ src/i18n/locales/fr.yaml | 364 +++++++++++++++++ src/i18n/locales/nl.yaml | 364 +++++++++++++++++ src/i18n/locales/ru.yaml | 408 +++++++++++++++++++ src/i18n/locales/uk.yaml | 364 +++++++++++++++++ src/telegram/commands/db_commands.py | 49 ++- src/telegram/commands/help_commands.py | 140 ++----- src/telegram/commands/history_commands.py | 82 ++-- src/telegram/commands/language_commands.py | 110 ++++++ src/telegram/commands/signal_commands.py | 17 +- src/telegram/commands/status_commands.py | 19 +- src/telegram/handlers.py | 180 ++++----- src/telegram/tutorial.py | 196 ++++------ 20 files changed, 3009 insertions(+), 442 deletions(-) create mode 100644 src/i18n/__init__.py create mode 100644 src/i18n/locales/de.yaml create mode 100644 src/i18n/locales/en.yaml create mode 100644 src/i18n/locales/fr.yaml create mode 100644 src/i18n/locales/nl.yaml create mode 100644 src/i18n/locales/ru.yaml create mode 100644 src/i18n/locales/uk.yaml create mode 100644 src/telegram/commands/language_commands.py diff --git a/requirements.txt b/requirements.txt index 3685001..b2b8c19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ python-dotenv==1.0.0 python-json-logger==2.0.7 python-multipart==0.0.6 python-telegram-bot==21.9 +PyYAML==6.0.1 requests==2.31.0 requests-mock==1.12.1 requests-oauthlib==1.3.1 diff --git a/src/core/bot.py b/src/core/bot.py index aef21e7..359265c 100644 --- a/src/core/bot.py +++ b/src/core/bot.py @@ -55,6 +55,10 @@ describe_table_command, ) from src.telegram.commands.status_commands import rate_limit_command +from src.telegram.commands.language_commands import ( + language_command, + handle_language_callback, +) from src.telegram.tutorial import ( start_command, tutorial_command, @@ -62,6 +66,7 @@ CHOOSING_TUTORIAL_ACTION, ) from src.core.utils import logger +from src.i18n import load_translations from src.core.error_handler import global_error_handler from src.core.logging_utils import log_message_decorator from src.core.rate_limiter import rate_limit @@ -201,6 +206,10 @@ def setup_handlers(app): app.add_handler(CommandHandler("start", rate_limit()(start_command))) app.add_handler(CommandHandler("tutorial", rate_limit()(tutorial_command))) + # Language selection command + app.add_handler(CommandHandler("language", rate_limit()(language_command))) + app.add_handler(CallbackQueryHandler(handle_language_callback, pattern=r"^lang_set:")) + tutorial_conv_handler = ConversationHandler( entry_points=[ CallbackQueryHandler(handle_tutorial_callback, pattern=r"^tutorial_") @@ -225,6 +234,9 @@ def main(): # Initialize database init_db() + # Load translations for i18n support + load_translations() + # Get token from environment TOKEN = os.getenv("API_TELEGRAM_KEY") diff --git a/src/core/error_handler.py b/src/core/error_handler.py index a7f49e3..36ab80a 100644 --- a/src/core/error_handler.py +++ b/src/core/error_handler.py @@ -10,20 +10,22 @@ from typing import Any, Callable, Dict, Optional, TypeVar, Union, cast from telegram import Update +from src.i18n import t, get_user_language, DEFAULT_LANGUAGE + logger = logging.getLogger(__name__) -# Error message templates -ERROR_MESSAGES = { - "network": "Network error occurred. Please try again later.", - "data_fetch": "Failed to fetch data. Please check the symbol and try again.", - "data_processing": "Error processing data. Please try with different parameters.", - "chart_generation": "Error generating chart. Please try again with different settings.", - "invalid_input": "Invalid input provided. Please check your command syntax.", - "database": "Database operation failed. Please try again.", - "timeout": "Operation timed out. Please try again.", - "api_limit": "API rate limit reached. Please try again later.", - "permission": "You don't have permission to perform this action.", - "unknown": "An unexpected error occurred. Please try again later.", +# Error type to translation key mapping +ERROR_TYPE_KEYS = { + "network": "errors.network", + "data_fetch": "errors.data_fetch", + "data_processing": "errors.data_processing", + "chart_generation": "errors.chart_generation", + "invalid_input": "errors.invalid_input", + "database": "errors.database", + "timeout": "errors.timeout", + "api_limit": "errors.api_limit", + "permission": "errors.permission", + "unknown": "errors.unknown", } @@ -39,24 +41,34 @@ async def handle_error( Args: update: The Telegram update object to respond to - error_type: Type of error from ERROR_MESSAGES dictionary + error_type: Type of error from ERROR_TYPE_KEYS dictionary custom_message: Optional custom message to override the template exception: The exception object if available notify_user: Whether to send a message to the user """ + # Get user language for translations + lang = DEFAULT_LANGUAGE + if update and update.effective_user: + try: + lang = get_user_language(update.effective_user.id) + except Exception: + pass # Use default language if we can't get user preferences + + # Get the translated error message + error_key = ERROR_TYPE_KEYS.get(error_type, ERROR_TYPE_KEYS["unknown"]) + default_message = t(error_key, lang) + # Log the error with details if exception: logger.error(f"Error: {error_type} - {str(exception)}") logger.debug(traceback.format_exc()) else: logger.error( - f"Error: {error_type} - {custom_message or ERROR_MESSAGES.get(error_type, ERROR_MESSAGES['unknown'])}" + f"Error: {error_type} - {custom_message or default_message}" ) if notify_user and update and update.effective_chat: - message = custom_message or ERROR_MESSAGES.get( - error_type, ERROR_MESSAGES["unknown"] - ) + message = custom_message or default_message try: await update.message.reply_text(f"❌ {message}") except Exception as e: @@ -90,12 +102,16 @@ async def global_error_handler(update: object, context): # Ensure we have a valid update object if isinstance(update, Update) and update.effective_chat: + # Get user language for translations + lang = DEFAULT_LANGUAGE + if update.effective_user: + try: + lang = get_user_language(update.effective_user.id) + except Exception: + pass # Use default language if we can't get user preferences + try: - await update.effective_chat.send_message( - "❌ An unexpected error occurred while processing your request. " - "Please try again later.\n\n" - "If you think this is a bug, please report it here: https://github.com/yariks5s/analysis_telegram_bot/issues" - ) + await update.effective_chat.send_message(t("errors.global", lang)) except Exception as e: logger.error(f"Failed to send global error message: {e}") from src.core.logging_utils import log_error_with_stacktrace diff --git a/src/database/models.py b/src/database/models.py index a63134d..bd62bef 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -27,6 +27,7 @@ class UserPreference: atr_period: int = 14 fvg_min_size: float = 0.0005 tutorial_stage: int = 0 # 0 = not started, 1+ = tutorial progress + language: str = "en" # User's preferred language (en, ru, uk, de, fr, nl) @dataclass @@ -89,7 +90,8 @@ def create_tables(): dark_mode BOOLEAN DEFAULT 0, atr_period INTEGER DEFAULT 14, fvg_min_size REAL DEFAULT 0.0005, - tutorial_stage INTEGER DEFAULT 0 + tutorial_stage INTEGER DEFAULT 0, + language VARCHAR DEFAULT 'en' ) """ ) diff --git a/src/database/operations.py b/src/database/operations.py index c641a77..6d5f3f5 100644 --- a/src/database/operations.py +++ b/src/database/operations.py @@ -20,7 +20,7 @@ def init_db() -> None: create_tables() -def get_user_preferences(user_id: int) -> Dict[str, Union[bool, int, float]]: +def get_user_preferences(user_id: int) -> Dict[str, Union[bool, int, float, str]]: """ Retrieve the user's indicator preferences from the database. @@ -47,6 +47,7 @@ def get_user_preferences(user_id: int) -> Dict[str, Union[bool, int, float]]: "liquidity_pools": bool(row[7]) if len(row) > 7 else True, "dark_mode": bool(row[8]) if len(row) > 8 else False, "tutorial_stage": int(row[11]) if len(row) > 11 else 0, + "language": str(row[12]) if len(row) > 12 and row[12] else "en", } # Handle indicator parameters if they exist in the database @@ -77,6 +78,7 @@ def get_user_preferences(user_id: int) -> Dict[str, Union[bool, int, float]]: "liquidity_pools": False, "dark_mode": False, "tutorial_stage": 0, + "language": "en", } prefs["atr_period"] = INDICATOR_PARAMS["atr_period"]["default"] @@ -124,7 +126,7 @@ def update_user_preferences(user_id: int, preferences: Dict[str, Any]) -> None: UPDATE user_preferences SET order_blocks = ?, fvgs = ?, liquidity_levels = ?, breaker_blocks = ?, show_legend = ?, show_volume = ?, liquidity_pools = ?, dark_mode = ?, - atr_period = ?, fvg_min_size = ?, tutorial_stage = ? + atr_period = ?, fvg_min_size = ?, tutorial_stage = ?, language = ? WHERE user_id = ? """, ( @@ -139,6 +141,7 @@ def update_user_preferences(user_id: int, preferences: Dict[str, Any]) -> None: preferences["atr_period"], preferences["fvg_min_size"], preferences.get("tutorial_stage", 0), + preferences.get("language", "en"), user_id, ), ) @@ -148,8 +151,8 @@ def update_user_preferences(user_id: int, preferences: Dict[str, Any]) -> None: INSERT INTO user_preferences ( user_id, order_blocks, fvgs, liquidity_levels, breaker_blocks, show_legend, show_volume, liquidity_pools, dark_mode, - atr_period, fvg_min_size, tutorial_stage - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + atr_period, fvg_min_size, tutorial_stage, language + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, @@ -164,6 +167,7 @@ def update_user_preferences(user_id: int, preferences: Dict[str, Any]) -> None: preferences["atr_period"], preferences["fvg_min_size"], preferences.get("tutorial_stage", 0), + preferences.get("language", "en"), ), ) @@ -174,6 +178,46 @@ def update_user_preferences(user_id: int, preferences: Dict[str, Any]) -> None: conn.close() +def update_user_language(user_id: int, language: str) -> None: + """ + Update the user's language preference. + + Args: + user_id: Telegram user ID + language: Language code (en, ru, uk, de, fr, nl) + """ + try: + conn = sqlite3.connect(DATABASE_PATH) + cursor = conn.cursor() + + cursor.execute("SELECT 1 FROM user_preferences WHERE user_id = ?", (user_id,)) + exists = cursor.fetchone() + + if exists: + cursor.execute( + "UPDATE user_preferences SET language = ? WHERE user_id = ?", + (language, user_id), + ) + else: + # Create default preferences with the specified language + cursor.execute( + """ + INSERT INTO user_preferences ( + user_id, order_blocks, fvgs, liquidity_levels, breaker_blocks, + show_legend, show_volume, liquidity_pools, dark_mode, + atr_period, fvg_min_size, tutorial_stage, language + ) VALUES (?, 0, 0, 0, 0, 1, 1, 0, 0, 14, 0.0005, 0, ?) + """, + (user_id, language), + ) + + conn.commit() + except sqlite3.Error as e: + logger.error(f"Database error while updating language: {e}") + finally: + conn.close() + + def get_all_user_signal_requests(user_id: int) -> List[Dict[str, any]]: """ Retrieve all signal request preferences for a user from the database. diff --git a/src/i18n/__init__.py b/src/i18n/__init__.py new file mode 100644 index 0000000..5845f80 --- /dev/null +++ b/src/i18n/__init__.py @@ -0,0 +1,232 @@ +""" +Internationalization (i18n) module for CryptoBot. + +This module provides multi-language support with: +- Translation loading from YAML files +- Language preference per user +- Placeholder interpolation +- Fallback to English for missing translations +""" + +import os +import logging +from typing import Dict, Any, Optional +from functools import lru_cache + +import yaml + +logger = logging.getLogger(__name__) + +# Supported languages with their native names +SUPPORTED_LANGUAGES = { + "en": "English", + "ru": "Русский", + "uk": "Українська", + "de": "Deutsch", + "fr": "Français", + "nl": "Nederlands", +} + +# Default language +DEFAULT_LANGUAGE = "en" + +# Cache for loaded translations +_translations: Dict[str, Dict[str, Any]] = {} + +# Path to locales directory +LOCALES_DIR = os.path.join(os.path.dirname(__file__), "locales") + + +def load_translations() -> None: + """ + Load all translation files from the locales directory. + Should be called once at application startup. + """ + global _translations + _translations = {} + + for lang_code in SUPPORTED_LANGUAGES.keys(): + file_path = os.path.join(LOCALES_DIR, f"{lang_code}.yaml") + try: + if os.path.exists(file_path): + with open(file_path, "r", encoding="utf-8") as f: + _translations[lang_code] = yaml.safe_load(f) or {} + logger.info(f"Loaded translations for {lang_code}") + else: + logger.warning(f"Translation file not found: {file_path}") + _translations[lang_code] = {} + except Exception as e: + logger.error(f"Error loading translations for {lang_code}: {e}") + _translations[lang_code] = {} + + # Ensure English is always available as fallback + if "en" not in _translations: + _translations["en"] = {} + + +def _get_nested_value(data: Dict[str, Any], key: str) -> Optional[str]: + """ + Get a nested value from a dictionary using dot notation. + + Args: + data: The dictionary to search + key: The key in dot notation (e.g., "tutorial.welcome.title") + + Returns: + The value if found, None otherwise + """ + keys = key.split(".") + value = data + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return None + + return value if isinstance(value, str) else None + + +def t(key: str, lang: str = DEFAULT_LANGUAGE, **kwargs) -> str: + """ + Get a translated string for the given key and language. + + Args: + key: Translation key in dot notation (e.g., "tutorial.welcome.title") + lang: Language code (e.g., "en", "ru", "uk", "de", "fr", "nl") + **kwargs: Placeholder values to interpolate + + Returns: + Translated string with placeholders replaced, or the key if not found + + Example: + t("common.welcome", "ru", name="Иван") + # Returns "Привет, Иван!" if translation is "Привет, {name}!" + """ + # Ensure translations are loaded + if not _translations: + load_translations() + + # Validate language code + if lang not in SUPPORTED_LANGUAGES: + lang = DEFAULT_LANGUAGE + + # Try to get translation in requested language + translation = _get_nested_value(_translations.get(lang, {}), key) + + # Fallback to English if not found + if translation is None and lang != DEFAULT_LANGUAGE: + translation = _get_nested_value(_translations.get(DEFAULT_LANGUAGE, {}), key) + + # If still not found, return the key + if translation is None: + logger.warning(f"Missing translation for key '{key}' in language '{lang}'") + return key + + # Interpolate placeholders + try: + return translation.format(**kwargs) + except KeyError as e: + logger.error(f"Missing placeholder {e} for key '{key}'") + return translation + + +def get_user_language(user_id: int) -> str: + """ + Get the language preference for a user from the database. + + Args: + user_id: Telegram user ID + + Returns: + Language code (defaults to English if not set) + """ + # Lazy import to avoid circular dependency + from src.database.operations import get_user_preferences + + try: + prefs = get_user_preferences(user_id) + lang = prefs.get("language", DEFAULT_LANGUAGE) + return lang if lang in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE + except Exception as e: + logger.error(f"Error getting user language: {e}") + return DEFAULT_LANGUAGE + + +def set_user_language(user_id: int, lang: str) -> bool: + """ + Set the language preference for a user. + + Args: + user_id: Telegram user ID + lang: Language code + + Returns: + True if successful, False otherwise + """ + if lang not in SUPPORTED_LANGUAGES: + return False + + # Lazy import to avoid circular dependency + from src.database.operations import update_user_language + + try: + update_user_language(user_id, lang) + return True + except Exception as e: + logger.error(f"Error setting user language: {e}") + return False + + +def get_language_keyboard_data() -> list: + """ + Get data for building a language selection keyboard. + + Returns: + List of tuples (lang_code, native_name, flag_emoji) + """ + flags = { + "en": "🇬🇧", + "ru": "🇷🇺", + "uk": "🇺🇦", + "de": "🇩🇪", + "fr": "🇫🇷", + "nl": "🇳🇱", + } + + return [ + (code, name, flags.get(code, "🌐")) + for code, name in SUPPORTED_LANGUAGES.items() + ] + + +# Convenience function for handler usage +def tr(update, key: str, **kwargs) -> str: + """ + Convenience function to get translation based on update's user. + + Args: + update: Telegram Update object + key: Translation key + **kwargs: Placeholder values + + Returns: + Translated string + """ + user_id = update.effective_user.id if update.effective_user else 0 + lang = get_user_language(user_id) + return t(key, lang, **kwargs) + + +# Export public API +__all__ = [ + "SUPPORTED_LANGUAGES", + "DEFAULT_LANGUAGE", + "load_translations", + "t", + "tr", + "get_user_language", + "set_user_language", + "get_language_keyboard_data", +] + diff --git a/src/i18n/locales/de.yaml b/src/i18n/locales/de.yaml new file mode 100644 index 0000000..e926252 --- /dev/null +++ b/src/i18n/locales/de.yaml @@ -0,0 +1,364 @@ +# German translations for CryptoBot +# Deutsche Übersetzungen für CryptoBot + +common: + done: "Fertig" + cancel: "Abbrechen" + save: "Speichern" + back: "Zurück" + continue: "Weiter" + skip: "Überspringen" + finish: "Beenden" + yes: "Ja" + no: "Nein" + error: "Fehler" + success: "Erfolgreich" + loading: "Laden..." + no_data: "Keine Daten verfügbar" + +language: + select_prompt: "🌐 Wählen Sie Ihre bevorzugte Sprache:" + changed: "✅ Sprache auf Deutsch geändert" + current: "Aktuelle Sprache: Deutsch 🇩🇪" + +tutorial: + welcome: + title: "🎉 Willkommen bei CryptoBot!" + content: | + Ich bin Ihr persönlicher Assistent für Krypto-Marktanalysen. Ich kann Ihnen helfen, Kryptowährungs-Charts mit fortgeschrittenen technischen Indikatoren zu analysieren. + + Dieses kurze Tutorial zeigt Ihnen, wie Sie meine Hauptfunktionen nutzen können. Sie können das Tutorial überspringen oder jederzeit mit dem Befehl /tutorial darauf zurückkommen. + + chart: + title: "📊 Chart-Analyse" + content: | + Der grundlegende Befehl für eine Chart-Analyse ist: + + /chart SYMBOL LÄNGE INTERVALL TOLERANZ + + Zum Beispiel: /chart BTCUSDT 48 1h 0.05 + + Dies gibt Ihnen ein 48-Kerzen, 1-Stunden-Chart für BTC/USDT mit einer Toleranz von 0.05 für die Erkennung von Liquiditätsniveaus. + + Probieren Sie es aus oder fahren Sie fort, um mehr zu erfahren! + + preferences: + title: "⚙️ Indikatoren anpassen" + content: | + Sie können mit dem Befehl /preferences anpassen, welche Indikatoren auf Ihren Charts erscheinen. + + Dies öffnet ein interaktives Menü, in dem Sie aktivieren oder deaktivieren können: + • Order Blocks + • Fair Value Gaps (FVGs) + • Liquiditätsniveaus + • Breaker Blocks + • Und Anzeigeoptionen ändern + + Ihre Einstellungen werden für zukünftige Anfragen gespeichert. + + signals: + title: "🔔 Automatische Signale" + content: | + Möchten Sie automatische Benachrichtigungen erhalten? Richten Sie Signal-Alarme ein mit: + + /create_signal SYMBOL MINUTEN [CHART] + + Zum Beispiel: /create_signal BTCUSDT 60 true + + Dies sendet Ihnen alle 60 Minuten eine Benachrichtigung mit Chart für BTC/USDT. + + Sie können Ihre Signale mit dem Befehl /manage_signals verwalten. + + parameters: + title: "🔧 Erweiterte Parameter" + content: | + Feinabstimmung der Indikator-Parameter mit dem Befehl /parameters. + + Sie können anpassen: + • ATR-Periode: für die Berechnung der Average True Range + • FVG Min. Größe: minimale Größe für Fair Value Gaps + + Diese Einstellungen beeinflussen, wie Indikatoren berechnet und angezeigt werden. + + completed: + title: "🚀 Tutorial abgeschlossen!" + content: | + Großartig! Sie haben das CryptoBot-Tutorial abgeschlossen. + + Denken Sie daran, Sie können jederzeit Hilfe bekommen mit: + • /help - Allgemeine Hilfe + • /help BEFEHL - Hilfe für einen bestimmten Befehl + + Bereit anzufangen? Probieren Sie /chart BTCUSDT 48 1h 0.05 für eine BTC/USDT-Analyse! + + buttons: + continue: "Weiter" + skip_tutorial: "Tutorial überspringen" + skip_to_end: "Zum Ende springen" + complete: "Tutorial abschließen" + view_commands: "Befehle anzeigen" + back_to_tutorial: "Zurück zum Tutorial" + + welcome_back: + title: "Willkommen zurück bei CryptoBot!" + content: | + Sie haben das Tutorial bereits abgeschlossen. Was möchten Sie tun? + + • Ein Chart erhalten: /chart BTCUSDT 48 1h 0.05 + • Einstellungen festlegen: /preferences + • Signale erstellen: /create_signal + • Tutorial wiederholen: /tutorial + + commands_list: + title: "Verfügbare Befehle:" + content: | + /chart - Chart mit Indikatoren erhalten + /text_result - Textanalyse erhalten + /preferences - Indikator-Einstellungen + /parameters - Indikator-Feineinstellungen + /create_signal - Automatische Signale einrichten + /manage_signals - Signale verwalten + /help - Hilfe erhalten + /tutorial - Tutorial wiederholen + /language - Sprache ändern + + happy_trading: "Viel Erfolg beim Trading! 📈\n\nVerwenden Sie /help für Hilfe." + +signals: + manage_title: "Signale verwalten:" + no_signals: "Keine Signale gefunden" + add_new: "➕ Neues Signal hinzufügen" + delete: "{pair} löschen" + deleted: "Signal gelöscht. Aktualisierte Liste:" + created: "Signal erstellt! Aktuelle Signale:" + active_count: "Sie haben {count} aktive(s) Signal(e)." + + add_prompt: | + Bitte geben Sie Ihr gewünschtes Signal im Format ein: + + `SYMBOL INTERVALL [with_chart]` + + Beispiel: `BTCUSDT 60` oder `ETHUSDT 15 with_chart` + + SYMBOL sollte ein gültiges Handelspaar sein (z.B. BTCUSDT). + INTERVALL sollte in Minuten angegeben werden (z.B. 5, 15, 60). + + invalid_input: "Ungültige Eingabe: {error}. Versuchen Sie es erneut oder /cancel." + limit_reached: "Sie haben die maximale Anzahl von Signalen erreicht ({limit})." + + usage: + create: "Verwendung: /create_signal [], Sie haben {count} Argument(e) gesendet." + delete: "Verwendung: /delete_signal " + + errors: + unexpected: "❌ Ein unerwarteter Fehler ist aufgetreten." + invalid_symbol: "❌ Ungültiges Symbol: {symbol}" + +preferences: + select_prompt: "Bitte wählen Sie die Indikatoren, die Sie einschließen möchten:" + selected: "Sie haben gewählt: {indicators}" + none_selected: "Keine" + + indicators: + order_blocks: "Order Blocks" + fvgs: "FVGs" + liquidity_levels: "Liquiditätsniveaus" + breaker_blocks: "Breaker Blocks" + liquidity_pools: "Liquiditätspools" + show_legend: "Legende anzeigen" + show_volume: "Volumen anzeigen" + dark_mode: "Dunkelmodus" + light_mode: "Hellmodus" + +parameters: + configure_title: "Indikator-Parameter konfigurieren:" + saved: "Parameter-Einstellungen erfolgreich gespeichert!" + value_set: "{param} auf {value} gesetzt." + + edit_prompt: | + Geben Sie einen neuen Wert für {param} ein: + + Aktueller Wert: {current} + Gültiger Bereich: {min} bis {max} (Schritt: {step}) + + Antworten Sie mit einer Zahl, um den neuen Wert festzulegen. + + errors: + invalid_number: "Ungültige Eingabe. Bitte geben Sie eine gültige Zahl für {param} ein." + out_of_range: "Der Wert muss zwischen {min} und {max} liegen. Bitte versuchen Sie es erneut." + no_session: "Entschuldigung, ich habe keine aktive Bearbeitungssitzung. Bitte verwenden Sie /parameters, um neu zu beginnen." + process_failed: "Entschuldigung, ich konnte diese Aktion nicht verarbeiten. Bitte versuchen Sie es erneut mit /parameters." + +help: + title: "CryptoBot Hilfe" + main: | + Hier sind die Hauptbefehle, die Sie verwenden können: + + /chart <symbol> <länge> <intervall> <toleranz> + /text_result <symbol> <länge> <intervall> <toleranz> + /history <symbol> <länge> <intervall> <toleranz> <zeitstempel> + /preferences + /create_signal <symbol> <minuten> [<mit_chart>] + /delete_signal <symbol> + /manage_signals + /sql <abfrage> + /tables + /schema <tabellenname> + /language + /help <befehl> + + Geben Sie /help <befehl> für detaillierte Hilfe ein. + + Beispiel: /help chart + + Für mehr Details siehe README oder kontaktieren Sie den Maintainer (@yarik_is_working). + + commands: + parameters: | + /parameters + Öffnen Sie ein interaktives Menü zur Anpassung der Indikator-Parameter. + + chart: | + /chart <symbol> <länge> <intervall> <toleranz> + Erhalten Sie ein Kerzen-Chart mit allen aktivierten Indikatoren. + + Beispiel: /chart BTCUSDT 48 1h 0.05 + + text_result: | + /text_result <symbol> <länge> <intervall> <toleranz> + Erhalten Sie eine Textzusammenfassung aller erkannten Indikatoren. + + Beispiel: /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <symbol> <länge> <intervall> <toleranz> <zeitstempel> + Erhalten Sie ein Kerzen-Chart für historische Daten zu einem bestimmten Zeitstempel. + + Beispiel: /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Öffnen Sie ein interaktives Menü zur Auswahl der Indikatoren und Chart-Optionen. + + create_signal: | + /create_signal <symbol> <minuten> [<mit_chart>] + Beginnen Sie, automatische Signale für ein Paar mit einer bestimmten Frequenz zu erhalten. + + Beispiel: /create_signal BTCUSDT 60 true + + delete_signal: | + /delete_signal <symbol> + Stoppen Sie automatische Signale für ein Paar. + + manage_signals: | + /manage_signals + Öffnen Sie ein interaktives Menü zum Anzeigen, Hinzufügen oder Löschen Ihrer Signale. + + help: | + /help + Zeigen Sie die allgemeine Hilfemeldung an. + + sql: | + /sql <abfrage> + Führen Sie eine SQL-Abfrage gegen die Datenbank aus. + + tables: | + /tables + Zeigen Sie eine Liste aller verfügbaren Tabellen in der Datenbank. + + schema: | + /schema <tabellenname> + Zeigen Sie das Schema einer bestimmten Tabelle. + + language: | + /language + Ändern Sie die Sprache des Bots. + +errors: + network: "Netzwerkfehler aufgetreten. Bitte versuchen Sie es später erneut." + data_fetch: "Daten konnten nicht abgerufen werden. Bitte überprüfen Sie das Symbol und versuchen Sie es erneut." + data_processing: "Fehler bei der Datenverarbeitung. Bitte versuchen Sie es mit anderen Parametern." + chart_generation: "Fehler bei der Chart-Generierung. Bitte versuchen Sie es mit anderen Einstellungen." + invalid_input: "Ungültige Eingabe. Bitte überprüfen Sie die Befehlssyntax." + database: "Datenbankoperation fehlgeschlagen. Bitte versuchen Sie es erneut." + timeout: "Zeitüberschreitung. Bitte versuchen Sie es erneut." + api_limit: "API-Ratenlimit erreicht. Bitte versuchen Sie es später erneut." + permission: "Sie haben keine Berechtigung für diese Aktion." + unknown: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut." + + global: | + ❌ Bei der Verarbeitung Ihrer Anfrage ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut. + + Wenn Sie denken, dass dies ein Fehler ist, melden Sie ihn hier: https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Einstellungen konnten nicht abgerufen werden. Bitte setzen Sie sie erneut über /preferences." + analyze_failed: "Daten konnten nicht analysiert werden. Bitte überprüfen Sie Ihre Eingaben und versuchen Sie es erneut." + chart_send_failed: "Chart konnte nicht gesendet werden. Bitte versuchen Sie es erneut." + signal_generation_failed: "Preisvorhersage-Signal konnte nicht generiert werden. Sende Chart ohne Analyse." + analysis_not_available: "Analyse nicht verfügbar" + invalid_timestamp: "❌ Ungültiger Zeitstempel. Muss ein Unix-Zeitstempel in der Vergangenheit sein." + historical_usage: "Ungültige Parameter. Verwendung: /history " + +rate_limit: + title: "**Ratenlimit-Status**" + remaining: "• Verbleibend: {remaining}/{limit} Anfragen{suffix}" + reset_in: "• Zurücksetzung in: {seconds} Sekunden" + reduced_notice: "ℹ️ Ihr Ratenlimit wurde aufgrund erkannter hochfrequenter Aktivität vorübergehend reduziert. Es wird nach einer Abkühlphase automatisch wieder normal." + footer: "Das System verwendet Ratenlimits, um eine faire API-Nutzung für alle Benutzer zu gewährleisten." + suspicious_suffix: " (reduziert wegen verdächtiger Aktivität)" + +database: + sql: + no_query: | + Bitte geben Sie eine SQL-Abfrage an. + Verwendung: /sql + Beispiel: /sql SELECT * FROM trades LIMIT 5 + results: "Abfrageergebnisse:\n\n{results}" + success_no_results: "Abfrage erfolgreich ausgeführt. Keine Ergebnisse zurückgegeben." + error: "❌ Fehler bei der Abfrageausführung: {error}" + + tables: + title: "Verfügbare Tabellen:\n\n" + empty: "Keine Tabellen in der Datenbank gefunden." + error: "❌ Fehler: {error}" + + schema: + no_table: | + Bitte geben Sie einen Tabellennamen an. + Verwendung: /schema + Beispiel: /schema trades + title: "Schema für Tabelle '{table}':\n\n" + not_found: "Kein Schema für Tabelle '{table}' gefunden." + error: "❌ Fehler: {error}" + +history: + title: "📜 *Ihre Signalhistorie*" + filter_prompt: "\n\nFiltern nach Zeitraum oder Währungspaar:" + no_signals: "Keine Signalhistorie gefunden. Signale werden bei ihrer Generierung aufgezeichnet." + + periods: + last_24h: "Letzte 24h" + last_7d: "Letzte 7T" + last_30d: "Letzte 30T" + + filtered_title: "📜 *Signalhistorie - Letzte {period}*" + pair_title: "📜 *Signalhistorie - {pair}*" + + no_signals_period: "Keine Signale in den letzten {period} gefunden." + no_signals_pair: "Keine Signale für {pair} gefunden." + + show_all: "Alle anzeigen" + showing_recent: "_Zeige {count} neueste Signale._" + + export: + csv: "CSV exportieren" + json: "JSON exportieren" + preparing: "Bereite {format}-Export vor..." + no_data: "Keine Signaldaten {description} zum Exportieren verfügbar." + caption: "📊 Signalhistorie {description} als {format} exportiert" + for_pair: "für {pair}" + for_all: "für alle Paare" + error: "Signalhistorie konnte nicht als {format} exportiert werden" + + update_failed: "Nachricht konnte nicht aktualisiert werden" + diff --git a/src/i18n/locales/en.yaml b/src/i18n/locales/en.yaml new file mode 100644 index 0000000..6b79fe1 --- /dev/null +++ b/src/i18n/locales/en.yaml @@ -0,0 +1,433 @@ +# English translations for CryptoBot +# This is the primary language file - all keys should be defined here + +common: + done: "Done" + cancel: "Cancel" + save: "Save" + back: "Back" + continue: "Continue" + skip: "Skip" + finish: "Finish" + yes: "Yes" + no: "No" + error: "Error" + success: "Success" + loading: "Loading..." + no_data: "No data available" + +language: + select_prompt: "🌐 Select your preferred language:" + changed: "✅ Language changed to English" + current: "Current language: English 🇬🇧" + +# Tutorial messages +tutorial: + welcome: + title: "🎉 Welcome to CryptoBot!" + content: | + I'm your personal crypto market analysis assistant. I can help you analyze cryptocurrency charts with advanced technical indicators. + + This short tutorial will show you how to use my main features. You can skip the tutorial or come back to it anytime with the /tutorial command. + + chart: + title: "📊 Chart Analysis" + content: | + The basic command to get a chart analysis is: + + /chart SYMBOL LENGTH INTERVAL TOLERANCE + + For example: /chart BTCUSDT 48 1h 0.05 + + This gives you a 48-candle, 1-hour chart for BTC/USDT with a tolerance of 0.05 for detecting liquidity levels. + + Try it out or continue to learn more features! + + preferences: + title: "⚙️ Customizing Indicators" + content: | + You can customize which indicators appear on your charts with the /preferences command. + + This opens an interactive menu where you can enable or disable: + • Order Blocks + • Fair Value Gaps (FVGs) + • Liquidity Levels + • Breaker Blocks + • And change display options + + Your preferences will be saved for future chart requests. + + signals: + title: "🔔 Automated Signals" + content: | + Want to receive automatic notifications? Set up signal alerts with: + + /create_signal SYMBOL MINUTES [CHART] + + For example: /create_signal BTCUSDT 60 true + + This will send you a notification with a chart every 60 minutes for BTC/USDT. + + You can manage your signals with the /manage_signals command. + + parameters: + title: "🔧 Advanced Parameters" + content: | + Fine-tune indicator parameters with the /parameters command. + + You can adjust: + • ATR Period: for calculating Average True Range + • FVG Min Size: minimum size for Fair Value Gaps + + These settings affect how indicators are calculated and displayed. + + completed: + title: "🚀 Tutorial Completed!" + content: | + Great job! You've completed the CryptoBot tutorial. + + Remember, you can always get help with: + • /help - General help information + • /help COMMAND - Help for a specific command + + Ready to start? Try /chart BTCUSDT 48 1h 0.05 to see BTC/USDT analysis! + + buttons: + continue: "Continue" + skip_tutorial: "Skip Tutorial" + skip_to_end: "Skip to End" + complete: "Complete Tutorial" + view_commands: "View Available Commands" + back_to_tutorial: "Back to Tutorial" + + welcome_back: + title: "Welcome back to CryptoBot!" + content: | + You've already completed the tutorial. What would you like to do? + + • Get a chart with /chart BTCUSDT 48 1h 0.05 + • Set preferences with /preferences + • Create signals with /create_signal + • Review the tutorial with /tutorial + + commands_list: + title: "Available Commands:" + content: | + /chart - Get a chart with indicators + /text_result - Get a text analysis + /preferences - Set indicator preferences + /parameters - Fine-tune indicator settings + /create_signal - Set up automatic signals + /manage_signals - Manage your signal alerts + /help - Get help with commands + /tutorial - Revisit this tutorial + /language - Change language + + happy_trading: "Happy trading! 📈\n\nUse /help anytime you need assistance." + +# Signal management +signals: + manage_title: "Manage your signals:" + no_signals: "No signals found" + add_new: "➕ Add New Signal" + delete: "Delete {pair}" + deleted: "Signal deleted. Updated list:" + created: "Signal created! Current signals:" + active_count: "You have {count} active signal(s)." + + add_prompt: | + Please enter your desired signal in the format: + + `SYMBOL INTERVAL [with_chart]` + + Example: `BTCUSDT 60` or `ETHUSDT 15 with_chart` + + SYMBOL should be a valid trading pair (e.g., BTCUSDT). + INTERVAL should be in minutes (e.g., 5, 15, 60). + + invalid_input: "Invalid input: {error}. Try again or /cancel." + limit_reached: "You have reached the maximum number of signals ({limit})." + + usage: + create: "Usage: /create_signal [], you've sent {count} argument(s)." + delete: "Usage: /delete_signal " + + errors: + unexpected: "❌ An unexpected error occurred." + invalid_symbol: "❌ Invalid symbol: {symbol}" + +# Indicator preferences +preferences: + select_prompt: "Please choose the indicators you'd like to include:" + selected: "You selected: {indicators}" + none_selected: "None" + + indicators: + order_blocks: "Order Blocks" + fvgs: "FVGs" + liquidity_levels: "Liquidity Levels" + breaker_blocks: "Breaker Blocks" + liquidity_pools: "Liquidity Pools" + show_legend: "Show Legend" + show_volume: "Show Volume" + dark_mode: "Dark Mode" + light_mode: "Light Mode" + +# Parameters +parameters: + configure_title: "Configure indicator parameters:" + saved: "Parameter settings saved successfully!" + value_set: "{param} set to {value}." + + edit_prompt: | + Enter a new value for {param}: + + Current value: {current} + Valid range: {min} to {max} (step: {step}) + + Reply with a number to set the new value. + + errors: + invalid_number: "Invalid input. Please enter a valid number for {param}." + out_of_range: "Value must be between {min} and {max}. Please try again." + no_session: "Sorry, I don't have an active parameter editing session. Please use /parameters to start over." + process_failed: "Sorry, I couldn't process that action. Please try again with /parameters." + +# Help messages +help: + title: "CryptoBot Help" + main: | + Here are the main commands you can use: + + /chart <symbol> <length> <interval> <tolerance> + /text_result <symbol> <length> <interval> <tolerance> + /history <symbol> <length> <interval> <tolerance> <timestamp> + /preferences + /create_signal <symbol> <minutes> [<show_chart>] + /delete_signal <symbol> + /manage_signals + /sql <query> + /tables + /schema <table_name> + /language + /help <command> + + Type /help <command> to get detailed help for a specific command. + + Example: /help chart + + For more details, see the README or contact the maintainer (@yarik_is_working). + + commands: + parameters: | + /parameters + Open an interactive menu to customize indicator parameters. + + Available parameters: + - ATR Period: Adjust the period used for Average True Range calculations (1-50) + - FVG Min Size: Set the minimum size ratio for Fair Value Gaps (0.0001-0.01) + + Click on any parameter to modify its value. + + chart: | + /chart <symbol> <length> <interval> <tolerance> + Get a candlestick chart with all enabled indicators. + + - symbol: The trading pair, e.g. BTCUSDT + - length: Amount of candles to analyze (e.g. 48) + - interval: Candle interval (e.g. 1h, 15m) + - tolerance: Sensitivity for liquidity level detection (0-1, e.g. 0.05 = more levels, 0.2 = fewer, only strongest) + + Example: /chart BTCUSDT 48 1h 0.05 + + The chart will include all indicators you have enabled in your preferences. + + text_result: | + /text_result <symbol> <length> <interval> <tolerance> + Get a text summary of all detected indicators for the specified symbol and timeframe. + + Example: /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <symbol> <length> <interval> <tolerance> <timestamp> + Get a candlestick chart for historical data at a specified timestamp. + + - symbol: The trading pair, e.g. BTCUSDT + - length: Number of candles to analyze + - interval: Candle interval (e.g., 1h, 15m) + - tolerance: Sensitivity for liquidity level detection (0-1) + - timestamp: Unix epoch seconds for the end of the period + + Example: /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Open an interactive menu to select which indicators to use and chart options. + + Available options: + - Technical indicators (Order Blocks, FVGs, Liquidity Levels, etc.) + - Display settings (Show Legend, Show Volume) + - Theme selection (Light/Dark mode) + + create_signal: | + /create_signal <symbol> <minutes> [<show_chart>] + Start receiving auto-signals for a pair at a given frequency (in minutes). + + - symbol: The trading pair, e.g. BTCUSDT + - minutes: Frequency in minutes to receive signals + - show_chart (optional): true/false, whether to include a chart with each signal (default: false) + + Example: /create_signal BTCUSDT 60 true + + You can have up to 10 active signal jobs per user. + + delete_signal: | + /delete_signal <symbol> + Stop auto-signals for a pair. + + Example: /delete_signal BTCUSDT + + manage_signals: | + /manage_signals + Open an interactive menu to view, add, or delete your signal jobs. + + You can have up to 10 active signal jobs per user. + + help: | + /help + Show the general help message. + + /help <command> + Show detailed help for a specific command. + + Example: /help chart + + sql: | + /sql <query> + Execute a custom SQL query against the database. + + Example queries: + - /sql SELECT * FROM trades LIMIT 5 + - /sql SELECT symbol, COUNT(*) as trades FROM trades GROUP BY symbol + - /sql SELECT AVG(profit_loss) as avg_profit FROM trades WHERE symbol = 'BTCUSDT' + + Note: Results are limited to 50 rows for readability. + + tables: | + /tables + Show a list of all available tables in the database. + + Use this command to see what tables you can query with /sql. + + schema: | + /schema <table_name> + Show the schema (structure) of a specific table. + + Example: /schema trades + + This will show you all columns and their data types in the specified table. + + language: | + /language + Change the bot's language. + + Supported languages: + - 🇬🇧 English + - 🇷🇺 Русский (Russian) + - 🇺🇦 Українська (Ukrainian) + - 🇩🇪 Deutsch (German) + - 🇫🇷 Français (French) + - 🇳🇱 Nederlands (Dutch) + +# Error messages +errors: + network: "Network error occurred. Please try again later." + data_fetch: "Failed to fetch data. Please check the symbol and try again." + data_processing: "Error processing data. Please try with different parameters." + chart_generation: "Error generating chart. Please try again with different settings." + invalid_input: "Invalid input provided. Please check your command syntax." + database: "Database operation failed. Please try again." + timeout: "Operation timed out. Please try again." + api_limit: "API rate limit reached. Please try again later." + permission: "You don't have permission to perform this action." + unknown: "An unexpected error occurred. Please try again later." + + global: | + ❌ An unexpected error occurred while processing your request. Please try again later. + + If you think this is a bug, please report it here: https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Failed to retrieve user preferences. Please try setting your preferences again using /preferences." + analyze_failed: "Failed to analyze data. Please check your inputs and try again." + chart_send_failed: "Failed to send chart. Please try again." + signal_generation_failed: "Could not generate price prediction signal. Sending chart without analysis." + analysis_not_available: "Analysis not available" + invalid_timestamp: "❌ Invalid timestamp. Must be a Unix epoch timestamp in the past." + historical_usage: "Invalid command parameters. Usage: /history " + +# Rate limit status +rate_limit: + title: "**Rate Limit Status**" + remaining: "• Remaining: {remaining}/{limit} requests{suffix}" + reset_in: "• Reset in: {seconds} seconds" + reduced_notice: "ℹ️ Your rate limit is temporarily reduced due to detected high-frequency activity. This will automatically return to normal after a cooling period." + footer: "The system uses rate limiting to ensure fair API usage for all users." + suspicious_suffix: " (reduced due to suspicious activity)" + +# Database commands +database: + sql: + no_query: | + Please provide a SQL query. + Usage: /sql + Example: /sql SELECT * FROM trades LIMIT 5 + results: "Query Results:\n\n{results}" + success_no_results: "Query executed successfully. No results returned." + error: "❌ Error executing query: {error}" + + tables: + title: "Available tables:\n\n" + empty: "No tables found in the database." + error: "❌ Error: {error}" + + schema: + no_table: | + Please provide a table name. + Usage: /schema + Example: /schema trades + title: "Schema for table '{table}':\n\n" + not_found: "No schema found for table '{table}'." + error: "❌ Error: {error}" + +# Signal history +history: + title: "📜 *Your Signal History*" + filter_prompt: "\n\nFilter by time period or currency pair:" + no_signals: "No signal history found. Signals will be recorded when they are generated." + + periods: + last_24h: "Last 24h" + last_7d: "Last 7d" + last_30d: "Last 30d" + + filtered_title: "📜 *Signal History - Last {period}*" + pair_title: "📜 *Signal History - {pair}*" + + no_signals_period: "No signals found in the last {period}." + no_signals_pair: "No signals found for {pair}." + + show_all: "Show All" + showing_recent: "_Showing {count} most recent signals._" + + export: + csv: "Export CSV" + json: "Export JSON" + preparing: "Preparing {format} export..." + no_data: "No signal data available {description} to export." + caption: "📊 Signal history {description} exported as {format}" + for_pair: "for {pair}" + for_all: "for all pairs" + error: "Failed to export signal history as {format}" + + update_failed: "Couldn't update message" + diff --git a/src/i18n/locales/fr.yaml b/src/i18n/locales/fr.yaml new file mode 100644 index 0000000..f34f233 --- /dev/null +++ b/src/i18n/locales/fr.yaml @@ -0,0 +1,364 @@ +# French translations for CryptoBot +# Traductions françaises pour CryptoBot + +common: + done: "Terminé" + cancel: "Annuler" + save: "Enregistrer" + back: "Retour" + continue: "Continuer" + skip: "Passer" + finish: "Terminer" + yes: "Oui" + no: "Non" + error: "Erreur" + success: "Succès" + loading: "Chargement..." + no_data: "Aucune donnée disponible" + +language: + select_prompt: "🌐 Sélectionnez votre langue préférée :" + changed: "✅ Langue changée en Français" + current: "Langue actuelle : Français 🇫🇷" + +tutorial: + welcome: + title: "🎉 Bienvenue sur CryptoBot !" + content: | + Je suis votre assistant personnel d'analyse du marché crypto. Je peux vous aider à analyser les graphiques de cryptomonnaies avec des indicateurs techniques avancés. + + Ce court tutoriel vous montrera comment utiliser mes fonctionnalités principales. Vous pouvez passer le tutoriel ou y revenir à tout moment avec la commande /tutorial. + + chart: + title: "📊 Analyse de graphique" + content: | + La commande de base pour obtenir une analyse de graphique est : + + /chart SYMBOLE LONGUEUR INTERVALLE TOLÉRANCE + + Par exemple : /chart BTCUSDT 48 1h 0.05 + + Cela vous donne un graphique de 48 bougies sur 1 heure pour BTC/USDT avec une tolérance de 0.05 pour la détection des niveaux de liquidité. + + Essayez-le ou continuez pour en savoir plus ! + + preferences: + title: "⚙️ Personnalisation des indicateurs" + content: | + Vous pouvez personnaliser les indicateurs qui apparaissent sur vos graphiques avec la commande /preferences. + + Cela ouvre un menu interactif où vous pouvez activer ou désactiver : + • Order Blocks + • Fair Value Gaps (FVGs) + • Niveaux de liquidité + • Breaker Blocks + • Et modifier les options d'affichage + + Vos préférences seront sauvegardées pour les futures requêtes. + + signals: + title: "🔔 Signaux automatiques" + content: | + Vous voulez recevoir des notifications automatiques ? Configurez des alertes de signal avec : + + /create_signal SYMBOLE MINUTES [GRAPHIQUE] + + Par exemple : /create_signal BTCUSDT 60 true + + Cela vous enverra une notification avec graphique toutes les 60 minutes pour BTC/USDT. + + Vous pouvez gérer vos signaux avec la commande /manage_signals. + + parameters: + title: "🔧 Paramètres avancés" + content: | + Affinez les paramètres des indicateurs avec la commande /parameters. + + Vous pouvez ajuster : + • Période ATR : pour le calcul de l'Average True Range + • Taille min. FVG : taille minimale pour les Fair Value Gaps + + Ces paramètres affectent le calcul et l'affichage des indicateurs. + + completed: + title: "🚀 Tutoriel terminé !" + content: | + Excellent ! Vous avez terminé le tutoriel CryptoBot. + + N'oubliez pas, vous pouvez toujours obtenir de l'aide avec : + • /help - Aide générale + • /help COMMANDE - Aide pour une commande spécifique + + Prêt à commencer ? Essayez /chart BTCUSDT 48 1h 0.05 pour une analyse BTC/USDT ! + + buttons: + continue: "Continuer" + skip_tutorial: "Passer le tutoriel" + skip_to_end: "Aller à la fin" + complete: "Terminer le tutoriel" + view_commands: "Voir les commandes" + back_to_tutorial: "Retour au tutoriel" + + welcome_back: + title: "Bon retour sur CryptoBot !" + content: | + Vous avez déjà terminé le tutoriel. Que souhaitez-vous faire ? + + • Obtenir un graphique : /chart BTCUSDT 48 1h 0.05 + • Définir les préférences : /preferences + • Créer des signaux : /create_signal + • Revoir le tutoriel : /tutorial + + commands_list: + title: "Commandes disponibles :" + content: | + /chart - Obtenir un graphique avec indicateurs + /text_result - Obtenir une analyse textuelle + /preferences - Paramètres des indicateurs + /parameters - Réglages fins des indicateurs + /create_signal - Configurer des signaux automatiques + /manage_signals - Gérer les signaux + /help - Obtenir de l'aide + /tutorial - Revoir le tutoriel + /language - Changer de langue + + happy_trading: "Bon trading ! 📈\n\nUtilisez /help pour obtenir de l'aide." + +signals: + manage_title: "Gérer vos signaux :" + no_signals: "Aucun signal trouvé" + add_new: "➕ Ajouter un nouveau signal" + delete: "Supprimer {pair}" + deleted: "Signal supprimé. Liste mise à jour :" + created: "Signal créé ! Signaux actuels :" + active_count: "Vous avez {count} signal(aux) actif(s)." + + add_prompt: | + Veuillez entrer votre signal souhaité au format : + + `SYMBOLE INTERVALLE [with_chart]` + + Exemple : `BTCUSDT 60` ou `ETHUSDT 15 with_chart` + + SYMBOLE doit être une paire de trading valide (ex : BTCUSDT). + INTERVALLE doit être en minutes (ex : 5, 15, 60). + + invalid_input: "Entrée invalide : {error}. Réessayez ou /cancel." + limit_reached: "Vous avez atteint le nombre maximum de signaux ({limit})." + + usage: + create: "Utilisation : /create_signal [], vous avez envoyé {count} argument(s)." + delete: "Utilisation : /delete_signal " + + errors: + unexpected: "❌ Une erreur inattendue s'est produite." + invalid_symbol: "❌ Symbole invalide : {symbol}" + +preferences: + select_prompt: "Veuillez choisir les indicateurs à inclure :" + selected: "Vous avez sélectionné : {indicators}" + none_selected: "Aucun" + + indicators: + order_blocks: "Order Blocks" + fvgs: "FVGs" + liquidity_levels: "Niveaux de liquidité" + breaker_blocks: "Breaker Blocks" + liquidity_pools: "Pools de liquidité" + show_legend: "Afficher la légende" + show_volume: "Afficher le volume" + dark_mode: "Mode sombre" + light_mode: "Mode clair" + +parameters: + configure_title: "Configurer les paramètres des indicateurs :" + saved: "Paramètres enregistrés avec succès !" + value_set: "{param} défini à {value}." + + edit_prompt: | + Entrez une nouvelle valeur pour {param} : + + Valeur actuelle : {current} + Plage valide : {min} à {max} (pas : {step}) + + Répondez avec un nombre pour définir la nouvelle valeur. + + errors: + invalid_number: "Entrée invalide. Veuillez entrer un nombre valide pour {param}." + out_of_range: "La valeur doit être entre {min} et {max}. Veuillez réessayer." + no_session: "Désolé, je n'ai pas de session d'édition active. Veuillez utiliser /parameters pour recommencer." + process_failed: "Désolé, je n'ai pas pu traiter cette action. Veuillez réessayer avec /parameters." + +help: + title: "Aide CryptoBot" + main: | + Voici les commandes principales que vous pouvez utiliser : + + /chart <symbole> <longueur> <intervalle> <tolérance> + /text_result <symbole> <longueur> <intervalle> <tolérance> + /history <symbole> <longueur> <intervalle> <tolérance> <timestamp> + /preferences + /create_signal <symbole> <minutes> [<avec_graphique>] + /delete_signal <symbole> + /manage_signals + /sql <requête> + /tables + /schema <nom_table> + /language + /help <commande> + + Tapez /help <commande> pour une aide détaillée. + + Exemple : /help chart + + Pour plus de détails, voir le README ou contacter le mainteneur (@yarik_is_working). + + commands: + parameters: | + /parameters + Ouvrir un menu interactif pour personnaliser les paramètres des indicateurs. + + chart: | + /chart <symbole> <longueur> <intervalle> <tolérance> + Obtenir un graphique en chandeliers avec tous les indicateurs activés. + + Exemple : /chart BTCUSDT 48 1h 0.05 + + text_result: | + /text_result <symbole> <longueur> <intervalle> <tolérance> + Obtenir un résumé textuel de tous les indicateurs détectés. + + Exemple : /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <symbole> <longueur> <intervalle> <tolérance> <timestamp> + Obtenir un graphique en chandeliers pour les données historiques à un timestamp spécifié. + + Exemple : /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Ouvrir un menu interactif pour sélectionner les indicateurs et les options de graphique. + + create_signal: | + /create_signal <symbole> <minutes> [<avec_graphique>] + Commencer à recevoir des signaux automatiques pour une paire à une fréquence donnée. + + Exemple : /create_signal BTCUSDT 60 true + + delete_signal: | + /delete_signal <symbole> + Arrêter les signaux automatiques pour une paire. + + manage_signals: | + /manage_signals + Ouvrir un menu interactif pour voir, ajouter ou supprimer vos signaux. + + help: | + /help + Afficher le message d'aide général. + + sql: | + /sql <requête> + Exécuter une requête SQL sur la base de données. + + tables: | + /tables + Afficher la liste de toutes les tables disponibles dans la base de données. + + schema: | + /schema <nom_table> + Afficher le schéma d'une table spécifique. + + language: | + /language + Changer la langue du bot. + +errors: + network: "Erreur réseau. Veuillez réessayer plus tard." + data_fetch: "Échec de la récupération des données. Veuillez vérifier le symbole et réessayer." + data_processing: "Erreur de traitement des données. Veuillez essayer avec d'autres paramètres." + chart_generation: "Erreur de génération du graphique. Veuillez essayer avec d'autres paramètres." + invalid_input: "Entrée invalide. Veuillez vérifier la syntaxe de la commande." + database: "Opération de base de données échouée. Veuillez réessayer." + timeout: "Délai d'attente dépassé. Veuillez réessayer." + api_limit: "Limite d'API atteinte. Veuillez réessayer plus tard." + permission: "Vous n'avez pas la permission d'effectuer cette action." + unknown: "Une erreur inattendue s'est produite. Veuillez réessayer plus tard." + + global: | + ❌ Une erreur inattendue s'est produite lors du traitement de votre demande. Veuillez réessayer plus tard. + + Si vous pensez qu'il s'agit d'un bug, signalez-le ici : https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Échec de la récupération des préférences. Veuillez les redéfinir via /preferences." + analyze_failed: "Échec de l'analyse des données. Veuillez vérifier vos entrées et réessayer." + chart_send_failed: "Échec de l'envoi du graphique. Veuillez réessayer." + signal_generation_failed: "Impossible de générer le signal de prévision de prix. Envoi du graphique sans analyse." + analysis_not_available: "Analyse non disponible" + invalid_timestamp: "❌ Timestamp invalide. Doit être un timestamp Unix dans le passé." + historical_usage: "Paramètres invalides. Utilisation : /history " + +rate_limit: + title: "**Statut de limite de taux**" + remaining: "• Restant : {remaining}/{limit} requêtes{suffix}" + reset_in: "• Réinitialisation dans : {seconds} secondes" + reduced_notice: "ℹ️ Votre limite de taux est temporairement réduite en raison d'une activité à haute fréquence détectée. Elle reviendra automatiquement à la normale après une période de refroidissement." + footer: "Le système utilise la limitation de taux pour assurer une utilisation équitable de l'API pour tous les utilisateurs." + suspicious_suffix: " (réduit en raison d'activité suspecte)" + +database: + sql: + no_query: | + Veuillez fournir une requête SQL. + Utilisation : /sql + Exemple : /sql SELECT * FROM trades LIMIT 5 + results: "Résultats de la requête :\n\n{results}" + success_no_results: "Requête exécutée avec succès. Aucun résultat retourné." + error: "❌ Erreur d'exécution de la requête : {error}" + + tables: + title: "Tables disponibles :\n\n" + empty: "Aucune table trouvée dans la base de données." + error: "❌ Erreur : {error}" + + schema: + no_table: | + Veuillez fournir un nom de table. + Utilisation : /schema + Exemple : /schema trades + title: "Schéma de la table '{table}' :\n\n" + not_found: "Aucun schéma trouvé pour la table '{table}'." + error: "❌ Erreur : {error}" + +history: + title: "📜 *Votre historique de signaux*" + filter_prompt: "\n\nFiltrer par période ou paire de devises :" + no_signals: "Aucun historique de signaux trouvé. Les signaux seront enregistrés lors de leur génération." + + periods: + last_24h: "Dernières 24h" + last_7d: "Derniers 7j" + last_30d: "Derniers 30j" + + filtered_title: "📜 *Historique des signaux - Dernières {period}*" + pair_title: "📜 *Historique des signaux - {pair}*" + + no_signals_period: "Aucun signal trouvé dans les dernières {period}." + no_signals_pair: "Aucun signal trouvé pour {pair}." + + show_all: "Afficher tout" + showing_recent: "_Affichage des {count} signaux les plus récents._" + + export: + csv: "Exporter CSV" + json: "Exporter JSON" + preparing: "Préparation de l'export {format}..." + no_data: "Aucune donnée de signal {description} à exporter." + caption: "📊 Historique des signaux {description} exporté en {format}" + for_pair: "pour {pair}" + for_all: "pour toutes les paires" + error: "Échec de l'export de l'historique des signaux en {format}" + + update_failed: "Impossible de mettre à jour le message" + diff --git a/src/i18n/locales/nl.yaml b/src/i18n/locales/nl.yaml new file mode 100644 index 0000000..d0a7992 --- /dev/null +++ b/src/i18n/locales/nl.yaml @@ -0,0 +1,364 @@ +# Dutch translations for CryptoBot +# Nederlandse vertalingen voor CryptoBot + +common: + done: "Gereed" + cancel: "Annuleren" + save: "Opslaan" + back: "Terug" + continue: "Doorgaan" + skip: "Overslaan" + finish: "Voltooien" + yes: "Ja" + no: "Nee" + error: "Fout" + success: "Succes" + loading: "Laden..." + no_data: "Geen gegevens beschikbaar" + +language: + select_prompt: "🌐 Selecteer uw voorkeurstaal:" + changed: "✅ Taal gewijzigd naar Nederlands" + current: "Huidige taal: Nederlands 🇳🇱" + +tutorial: + welcome: + title: "🎉 Welkom bij CryptoBot!" + content: | + Ik ben uw persoonlijke assistent voor crypto-marktanalyse. Ik kan u helpen bij het analyseren van cryptocurrency-grafieken met geavanceerde technische indicatoren. + + Deze korte tutorial laat u zien hoe u mijn belangrijkste functies kunt gebruiken. U kunt de tutorial overslaan of er op elk moment naar terugkeren met het /tutorial commando. + + chart: + title: "📊 Grafiekanalyse" + content: | + Het basiscommando voor een grafiekanalyse is: + + /chart SYMBOOL LENGTE INTERVAL TOLERANTIE + + Bijvoorbeeld: /chart BTCUSDT 48 1h 0.05 + + Dit geeft u een 48-kaarsen, 1-uurs grafiek voor BTC/USDT met een tolerantie van 0.05 voor het detecteren van liquiditeitsniveaus. + + Probeer het uit of ga verder om meer te leren! + + preferences: + title: "⚙️ Indicatoren aanpassen" + content: | + U kunt aanpassen welke indicatoren op uw grafieken verschijnen met het /preferences commando. + + Dit opent een interactief menu waar u kunt in- of uitschakelen: + • Order Blocks + • Fair Value Gaps (FVGs) + • Liquiditeitsniveaus + • Breaker Blocks + • En weergave-opties wijzigen + + Uw voorkeuren worden opgeslagen voor toekomstige verzoeken. + + signals: + title: "🔔 Automatische signalen" + content: | + Wilt u automatische meldingen ontvangen? Stel signaalalerts in met: + + /create_signal SYMBOOL MINUTEN [GRAFIEK] + + Bijvoorbeeld: /create_signal BTCUSDT 60 true + + Dit stuurt u elke 60 minuten een melding met grafiek voor BTC/USDT. + + U kunt uw signalen beheren met het /manage_signals commando. + + parameters: + title: "🔧 Geavanceerde parameters" + content: | + Verfijn indicatorparameters met het /parameters commando. + + U kunt aanpassen: + • ATR-periode: voor het berekenen van de Average True Range + • FVG min. grootte: minimale grootte voor Fair Value Gaps + + Deze instellingen beïnvloeden hoe indicatoren worden berekend en weergegeven. + + completed: + title: "🚀 Tutorial voltooid!" + content: | + Geweldig! U hebt de CryptoBot-tutorial voltooid. + + Onthoud, u kunt altijd hulp krijgen met: + • /help - Algemene hulp + • /help COMMANDO - Hulp voor een specifiek commando + + Klaar om te beginnen? Probeer /chart BTCUSDT 48 1h 0.05 voor een BTC/USDT-analyse! + + buttons: + continue: "Doorgaan" + skip_tutorial: "Tutorial overslaan" + skip_to_end: "Naar het einde" + complete: "Tutorial voltooien" + view_commands: "Commando's bekijken" + back_to_tutorial: "Terug naar tutorial" + + welcome_back: + title: "Welkom terug bij CryptoBot!" + content: | + U hebt de tutorial al voltooid. Wat wilt u doen? + + • Een grafiek krijgen: /chart BTCUSDT 48 1h 0.05 + • Voorkeuren instellen: /preferences + • Signalen maken: /create_signal + • Tutorial herhalen: /tutorial + + commands_list: + title: "Beschikbare commando's:" + content: | + /chart - Grafiek met indicatoren + /text_result - Tekstanalyse + /preferences - Indicatorvoorkeuren + /parameters - Indicatorinstellingen + /create_signal - Automatische signalen instellen + /manage_signals - Signalen beheren + /help - Hulp krijgen + /tutorial - Tutorial herhalen + /language - Taal wijzigen + + happy_trading: "Veel succes met handelen! 📈\n\nGebruik /help voor hulp." + +signals: + manage_title: "Uw signalen beheren:" + no_signals: "Geen signalen gevonden" + add_new: "➕ Nieuw signaal toevoegen" + delete: "{pair} verwijderen" + deleted: "Signaal verwijderd. Bijgewerkte lijst:" + created: "Signaal aangemaakt! Huidige signalen:" + active_count: "U hebt {count} actieve signaal(en)." + + add_prompt: | + Voer uw gewenste signaal in het formaat in: + + `SYMBOOL INTERVAL [with_chart]` + + Voorbeeld: `BTCUSDT 60` of `ETHUSDT 15 with_chart` + + SYMBOOL moet een geldig handelspaar zijn (bijv. BTCUSDT). + INTERVAL moet in minuten zijn (bijv. 5, 15, 60). + + invalid_input: "Ongeldige invoer: {error}. Probeer opnieuw of /cancel." + limit_reached: "U hebt het maximale aantal signalen bereikt ({limit})." + + usage: + create: "Gebruik: /create_signal [], u hebt {count} argument(en) verzonden." + delete: "Gebruik: /delete_signal " + + errors: + unexpected: "❌ Er is een onverwachte fout opgetreden." + invalid_symbol: "❌ Ongeldig symbool: {symbol}" + +preferences: + select_prompt: "Kies de indicatoren die u wilt opnemen:" + selected: "U hebt geselecteerd: {indicators}" + none_selected: "Geen" + + indicators: + order_blocks: "Order Blocks" + fvgs: "FVGs" + liquidity_levels: "Liquiditeitsniveaus" + breaker_blocks: "Breaker Blocks" + liquidity_pools: "Liquiditeitspools" + show_legend: "Legenda tonen" + show_volume: "Volume tonen" + dark_mode: "Donkere modus" + light_mode: "Lichte modus" + +parameters: + configure_title: "Indicatorparameters configureren:" + saved: "Parameterinstellingen succesvol opgeslagen!" + value_set: "{param} ingesteld op {value}." + + edit_prompt: | + Voer een nieuwe waarde in voor {param}: + + Huidige waarde: {current} + Geldig bereik: {min} tot {max} (stap: {step}) + + Antwoord met een getal om de nieuwe waarde in te stellen. + + errors: + invalid_number: "Ongeldige invoer. Voer een geldig getal in voor {param}." + out_of_range: "Waarde moet tussen {min} en {max} liggen. Probeer opnieuw." + no_session: "Sorry, ik heb geen actieve bewerkingssessie. Gebruik /parameters om opnieuw te beginnen." + process_failed: "Sorry, ik kon die actie niet verwerken. Probeer opnieuw met /parameters." + +help: + title: "CryptoBot Help" + main: | + Hier zijn de belangrijkste commando's die u kunt gebruiken: + + /chart <symbool> <lengte> <interval> <tolerantie> + /text_result <symbool> <lengte> <interval> <tolerantie> + /history <symbool> <lengte> <interval> <tolerantie> <timestamp> + /preferences + /create_signal <symbool> <minuten> [<met_grafiek>] + /delete_signal <symbool> + /manage_signals + /sql <query> + /tables + /schema <tabelnaam> + /language + /help <commando> + + Typ /help <commando> voor gedetailleerde hulp. + + Voorbeeld: /help chart + + Voor meer details, zie de README of neem contact op met de beheerder (@yarik_is_working). + + commands: + parameters: | + /parameters + Open een interactief menu om indicatorparameters aan te passen. + + chart: | + /chart <symbool> <lengte> <interval> <tolerantie> + Krijg een candlestick-grafiek met alle ingeschakelde indicatoren. + + Voorbeeld: /chart BTCUSDT 48 1h 0.05 + + text_result: | + /text_result <symbool> <lengte> <interval> <tolerantie> + Krijg een tekstsamenvatting van alle gedetecteerde indicatoren. + + Voorbeeld: /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <symbool> <lengte> <interval> <tolerantie> <timestamp> + Krijg een candlestick-grafiek voor historische gegevens op een opgegeven timestamp. + + Voorbeeld: /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Open een interactief menu om indicatoren en grafiekopties te selecteren. + + create_signal: | + /create_signal <symbool> <minuten> [<met_grafiek>] + Begin met het ontvangen van automatische signalen voor een paar met een bepaalde frequentie. + + Voorbeeld: /create_signal BTCUSDT 60 true + + delete_signal: | + /delete_signal <symbool> + Stop automatische signalen voor een paar. + + manage_signals: | + /manage_signals + Open een interactief menu om uw signalen te bekijken, toe te voegen of te verwijderen. + + help: | + /help + Toon het algemene helpbericht. + + sql: | + /sql <query> + Voer een SQL-query uit op de database. + + tables: | + /tables + Toon een lijst van alle beschikbare tabellen in de database. + + schema: | + /schema <tabelnaam> + Toon het schema van een specifieke tabel. + + language: | + /language + Wijzig de taal van de bot. + +errors: + network: "Netwerkfout opgetreden. Probeer het later opnieuw." + data_fetch: "Gegevens ophalen mislukt. Controleer het symbool en probeer opnieuw." + data_processing: "Fout bij gegevensverwerking. Probeer met andere parameters." + chart_generation: "Fout bij grafiekgeneratie. Probeer met andere instellingen." + invalid_input: "Ongeldige invoer. Controleer de commandosyntax." + database: "Databasebewerking mislukt. Probeer opnieuw." + timeout: "Time-out opgetreden. Probeer opnieuw." + api_limit: "API-limiet bereikt. Probeer het later opnieuw." + permission: "U hebt geen toestemming voor deze actie." + unknown: "Er is een onverwachte fout opgetreden. Probeer het later opnieuw." + + global: | + ❌ Er is een onverwachte fout opgetreden bij het verwerken van uw verzoek. Probeer het later opnieuw. + + Als u denkt dat dit een bug is, meld het hier: https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Voorkeuren ophalen mislukt. Stel ze opnieuw in via /preferences." + analyze_failed: "Gegevensanalyse mislukt. Controleer uw invoer en probeer opnieuw." + chart_send_failed: "Grafiek verzenden mislukt. Probeer opnieuw." + signal_generation_failed: "Prijsvoorspellingssignaal kon niet worden gegenereerd. Grafiek verzonden zonder analyse." + analysis_not_available: "Analyse niet beschikbaar" + invalid_timestamp: "❌ Ongeldige timestamp. Moet een Unix-timestamp in het verleden zijn." + historical_usage: "Ongeldige parameters. Gebruik: /history " + +rate_limit: + title: "**Limietstatus**" + remaining: "• Resterend: {remaining}/{limit} verzoeken{suffix}" + reset_in: "• Reset in: {seconds} seconden" + reduced_notice: "ℹ️ Uw limiet is tijdelijk verlaagd vanwege gedetecteerde hoogfrequente activiteit. Dit zal automatisch normaliseren na een afkoelperiode." + footer: "Het systeem gebruikt limieten om eerlijk API-gebruik voor alle gebruikers te garanderen." + suspicious_suffix: " (verlaagd vanwege verdachte activiteit)" + +database: + sql: + no_query: | + Geef een SQL-query op. + Gebruik: /sql + Voorbeeld: /sql SELECT * FROM trades LIMIT 5 + results: "Queryresultaten:\n\n{results}" + success_no_results: "Query succesvol uitgevoerd. Geen resultaten geretourneerd." + error: "❌ Fout bij uitvoeren query: {error}" + + tables: + title: "Beschikbare tabellen:\n\n" + empty: "Geen tabellen gevonden in de database." + error: "❌ Fout: {error}" + + schema: + no_table: | + Geef een tabelnaam op. + Gebruik: /schema + Voorbeeld: /schema trades + title: "Schema voor tabel '{table}':\n\n" + not_found: "Geen schema gevonden voor tabel '{table}'." + error: "❌ Fout: {error}" + +history: + title: "📜 *Uw signaalgeschiedenis*" + filter_prompt: "\n\nFilter op tijdsperiode of valutapaar:" + no_signals: "Geen signaalgeschiedenis gevonden. Signalen worden geregistreerd wanneer ze worden gegenereerd." + + periods: + last_24h: "Laatste 24u" + last_7d: "Laatste 7d" + last_30d: "Laatste 30d" + + filtered_title: "📜 *Signaalgeschiedenis - Laatste {period}*" + pair_title: "📜 *Signaalgeschiedenis - {pair}*" + + no_signals_period: "Geen signalen gevonden in de laatste {period}." + no_signals_pair: "Geen signalen gevonden voor {pair}." + + show_all: "Alles tonen" + showing_recent: "_Toont {count} meest recente signalen._" + + export: + csv: "CSV exporteren" + json: "JSON exporteren" + preparing: "{format}-export voorbereiden..." + no_data: "Geen signaalgegevens {description} om te exporteren." + caption: "📊 Signaalgeschiedenis {description} geëxporteerd als {format}" + for_pair: "voor {pair}" + for_all: "voor alle paren" + error: "Signaalgeschiedenis exporteren als {format} mislukt" + + update_failed: "Bericht kon niet worden bijgewerkt" + diff --git a/src/i18n/locales/ru.yaml b/src/i18n/locales/ru.yaml new file mode 100644 index 0000000..7199f78 --- /dev/null +++ b/src/i18n/locales/ru.yaml @@ -0,0 +1,408 @@ +# Russian translations for CryptoBot +# Русские переводы для CryptoBot + +common: + done: "Готово" + cancel: "Отмена" + save: "Сохранить" + back: "Назад" + continue: "Продолжить" + skip: "Пропустить" + finish: "Завершить" + yes: "Да" + no: "Нет" + error: "Ошибка" + success: "Успешно" + loading: "Загрузка..." + no_data: "Нет данных" + +language: + select_prompt: "🌐 Выберите предпочтительный язык:" + changed: "✅ Язык изменён на Русский" + current: "Текущий язык: Русский 🇷🇺" + +tutorial: + welcome: + title: "🎉 Добро пожаловать в CryptoBot!" + content: | + Я ваш персональный помощник для анализа криптовалютного рынка. Я помогу вам анализировать графики криптовалют с продвинутыми техническими индикаторами. + + Это краткое обучение покажет вам, как использовать мои основные функции. Вы можете пропустить обучение или вернуться к нему в любое время с помощью команды /tutorial. + + chart: + title: "📊 Анализ графика" + content: | + Основная команда для получения анализа графика: + + /chart СИМВОЛ ДЛИНА ИНТЕРВАЛ ДОПУСК + + Например: /chart BTCUSDT 48 1h 0.05 + + Это даст вам 48-свечный часовой график для BTC/USDT с допуском 0.05 для определения уровней ликвидности. + + Попробуйте или продолжите, чтобы узнать больше! + + preferences: + title: "⚙️ Настройка индикаторов" + content: | + Вы можете настроить, какие индикаторы отображаются на графиках, с помощью команды /preferences. + + Откроется интерактивное меню, где вы можете включить или отключить: + • Ордер-блоки + • Зоны справедливой стоимости (FVG) + • Уровни ликвидности + • Брейкер-блоки + • И изменить настройки отображения + + Ваши настройки будут сохранены для будущих запросов. + + signals: + title: "🔔 Автоматические сигналы" + content: | + Хотите получать автоматические уведомления? Настройте сигнальные оповещения с помощью: + + /create_signal СИМВОЛ МИНУТЫ [ГРАФИК] + + Например: /create_signal BTCUSDT 60 true + + Это будет отправлять вам уведомление с графиком каждые 60 минут для BTC/USDT. + + Управлять сигналами можно командой /manage_signals. + + parameters: + title: "🔧 Расширенные параметры" + content: | + Тонкая настройка параметров индикаторов через команду /parameters. + + Вы можете настроить: + • Период ATR: для расчёта среднего истинного диапазона + • Мин. размер FVG: минимальный размер зон справедливой стоимости + + Эти настройки влияют на расчёт и отображение индикаторов. + + completed: + title: "🚀 Обучение завершено!" + content: | + Отлично! Вы завершили обучение CryptoBot. + + Помните, вы всегда можете получить помощь: + • /help - Общая справка + • /help КОМАНДА - Справка по конкретной команде + + Готовы начать? Попробуйте /chart BTCUSDT 48 1h 0.05 для анализа BTC/USDT! + + buttons: + continue: "Продолжить" + skip_tutorial: "Пропустить обучение" + skip_to_end: "Пропустить до конца" + complete: "Завершить обучение" + view_commands: "Показать команды" + back_to_tutorial: "Назад к обучению" + + welcome_back: + title: "С возвращением в CryptoBot!" + content: | + Вы уже завершили обучение. Что хотите сделать? + + • Получить график: /chart BTCUSDT 48 1h 0.05 + • Настроить предпочтения: /preferences + • Создать сигналы: /create_signal + • Повторить обучение: /tutorial + + commands_list: + title: "Доступные команды:" + content: | + /chart - Получить график с индикаторами + /text_result - Получить текстовый анализ + /preferences - Настроить предпочтения индикаторов + /parameters - Тонкая настройка индикаторов + /create_signal - Настроить автоматические сигналы + /manage_signals - Управление сигналами + /help - Получить справку + /tutorial - Повторить обучение + /language - Сменить язык + + happy_trading: "Удачной торговли! 📈\n\nИспользуйте /help для получения помощи." + +signals: + manage_title: "Управление сигналами:" + no_signals: "Сигналы не найдены" + add_new: "➕ Добавить сигнал" + delete: "Удалить {pair}" + deleted: "Сигнал удалён. Обновлённый список:" + created: "Сигнал создан! Текущие сигналы:" + active_count: "У вас {count} активных сигналов." + + add_prompt: | + Введите желаемый сигнал в формате: + + `СИМВОЛ ИНТЕРВАЛ [with_chart]` + + Пример: `BTCUSDT 60` или `ETHUSDT 15 with_chart` + + СИМВОЛ должен быть валидной торговой парой (например, BTCUSDT). + ИНТЕРВАЛ должен быть в минутах (например, 5, 15, 60). + + invalid_input: "Неверный ввод: {error}. Попробуйте снова или /cancel." + limit_reached: "Вы достигли максимального количества сигналов ({limit})." + + usage: + create: "Использование: /create_signal <символ> <период_в_минутах> [<с_графиком>], вы отправили {count} аргумент(ов)." + delete: "Использование: /delete_signal <символ>" + + errors: + unexpected: "❌ Произошла непредвиденная ошибка." + invalid_symbol: "❌ Неверный символ: {symbol}" + +preferences: + select_prompt: "Выберите индикаторы для отображения:" + selected: "Вы выбрали: {indicators}" + none_selected: "Ничего" + + indicators: + order_blocks: "Ордер-блоки" + fvgs: "FVG" + liquidity_levels: "Уровни ликвидности" + breaker_blocks: "Брейкер-блоки" + liquidity_pools: "Пулы ликвидности" + show_legend: "Показать легенду" + show_volume: "Показать объём" + dark_mode: "Тёмная тема" + light_mode: "Светлая тема" + +parameters: + configure_title: "Настройка параметров индикаторов:" + saved: "Настройки параметров сохранены!" + value_set: "{param} установлен на {value}." + + edit_prompt: | + Введите новое значение для {param}: + + Текущее значение: {current} + Допустимый диапазон: от {min} до {max} (шаг: {step}) + + Ответьте числом, чтобы установить новое значение. + + errors: + invalid_number: "Неверный ввод. Пожалуйста, введите число для {param}." + out_of_range: "Значение должно быть от {min} до {max}. Попробуйте снова." + no_session: "Извините, нет активной сессии редактирования. Используйте /parameters для начала." + process_failed: "Извините, не удалось обработать это действие. Попробуйте снова с /parameters." + +help: + title: "Справка CryptoBot" + main: | + Вот основные команды: + + /chart <символ> <длина> <интервал> <допуск> + /text_result <символ> <длина> <интервал> <допуск> + /history <символ> <длина> <интервал> <допуск> <время> + /preferences + /create_signal <символ> <минуты> [<с_графиком>] + /delete_signal <символ> + /manage_signals + /sql <запрос> + /tables + /schema <имя_таблицы> + /language + /help <команда> + + Введите /help <команда> для подробной справки. + + Пример: /help chart + + Для подробностей см. README или свяжитесь с разработчиком (@yarik_is_working). + + commands: + parameters: | + /parameters + Открыть интерактивное меню для настройки параметров индикаторов. + + Доступные параметры: + - Период ATR: Настройка периода для расчёта среднего истинного диапазона (1-50) + - Мин. размер FVG: Установка минимального соотношения размера для зон справедливой стоимости (0.0001-0.01) + + Нажмите на любой параметр, чтобы изменить его значение. + + chart: | + /chart <символ> <длина> <интервал> <допуск> + Получить свечной график со всеми включёнными индикаторами. + + - символ: Торговая пара, например BTCUSDT + - длина: Количество свечей для анализа (например, 48) + - интервал: Интервал свечей (например, 1h, 15m) + - допуск: Чувствительность для определения уровней ликвидности (0-1) + + Пример: /chart BTCUSDT 48 1h 0.05 + + text_result: | + /text_result <символ> <длина> <интервал> <допуск> + Получить текстовую сводку всех обнаруженных индикаторов. + + Пример: /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <символ> <длина> <интервал> <допуск> <время> + Получить свечной график для исторических данных на указанную метку времени. + + - время: Метка времени Unix в секундах + + Пример: /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Открыть интерактивное меню для выбора индикаторов и настроек графика. + + Доступные опции: + - Технические индикаторы (Ордер-блоки, FVG, Уровни ликвидности и т.д.) + - Настройки отображения (Показать легенду, Показать объём) + - Выбор темы (Светлая/Тёмная) + + create_signal: | + /create_signal <символ> <минуты> [<с_графиком>] + Начать получать автоматические сигналы для пары с заданной частотой (в минутах). + + Пример: /create_signal BTCUSDT 60 true + + Максимум 10 активных сигналов на пользователя. + + delete_signal: | + /delete_signal <символ> + Остановить автоматические сигналы для пары. + + Пример: /delete_signal BTCUSDT + + manage_signals: | + /manage_signals + Открыть интерактивное меню для просмотра, добавления или удаления сигналов. + + Максимум 10 активных сигналов на пользователя. + + help: | + /help + Показать общее сообщение справки. + + /help <команда> + Показать подробную справку для конкретной команды. + + Пример: /help chart + + sql: | + /sql <запрос> + Выполнить SQL-запрос к базе данных. + + Примеры: + - /sql SELECT * FROM trades LIMIT 5 + + Результаты ограничены 50 строками. + + tables: | + /tables + Показать список всех доступных таблиц в базе данных. + + schema: | + /schema <имя_таблицы> + Показать структуру конкретной таблицы. + + Пример: /schema trades + + language: | + /language + Изменить язык бота. + + Поддерживаемые языки: + - 🇬🇧 English + - 🇷🇺 Русский + - 🇺🇦 Українська + - 🇩🇪 Deutsch + - 🇫🇷 Français + - 🇳🇱 Nederlands + +errors: + network: "Ошибка сети. Пожалуйста, попробуйте позже." + data_fetch: "Не удалось получить данные. Проверьте символ и попробуйте снова." + data_processing: "Ошибка обработки данных. Попробуйте с другими параметрами." + chart_generation: "Ошибка генерации графика. Попробуйте с другими настройками." + invalid_input: "Неверный ввод. Проверьте синтаксис команды." + database: "Ошибка базы данных. Попробуйте снова." + timeout: "Время операции истекло. Попробуйте снова." + api_limit: "Достигнут лимит API. Попробуйте позже." + permission: "У вас нет разрешения на это действие." + unknown: "Произошла непредвиденная ошибка. Попробуйте позже." + + global: | + ❌ При обработке вашего запроса произошла непредвиденная ошибка. Попробуйте позже. + + Если вы считаете это ошибкой, сообщите здесь: https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Не удалось получить настройки. Попробуйте установить их снова через /preferences." + analyze_failed: "Не удалось проанализировать данные. Проверьте ввод и попробуйте снова." + chart_send_failed: "Не удалось отправить график. Попробуйте снова." + signal_generation_failed: "Не удалось сгенерировать сигнал прогноза цены. Отправка графика без анализа." + analysis_not_available: "Анализ недоступен" + invalid_timestamp: "❌ Неверная метка времени. Должна быть метка Unix в прошлом." + historical_usage: "Неверные параметры. Использование: /history <символ> <длина> <интервал> <допуск> <время>" + +rate_limit: + title: "**Статус ограничения запросов**" + remaining: "• Осталось: {remaining}/{limit} запросов{suffix}" + reset_in: "• Сброс через: {seconds} секунд" + reduced_notice: "ℹ️ Ваш лимит запросов временно снижен из-за высокой активности. Он автоматически восстановится после периода ожидания." + footer: "Система использует ограничение запросов для обеспечения справедливого использования API." + suspicious_suffix: " (снижен из-за подозрительной активности)" + +database: + sql: + no_query: | + Пожалуйста, укажите SQL-запрос. + Использование: /sql <запрос> + Пример: /sql SELECT * FROM trades LIMIT 5 + results: "Результаты запроса:\n\n{results}" + success_no_results: "Запрос выполнен успешно. Результатов нет." + error: "❌ Ошибка выполнения запроса: {error}" + + tables: + title: "Доступные таблицы:\n\n" + empty: "Таблицы в базе данных не найдены." + error: "❌ Ошибка: {error}" + + schema: + no_table: | + Пожалуйста, укажите имя таблицы. + Использование: /schema <имя_таблицы> + Пример: /schema trades + title: "Схема таблицы '{table}':\n\n" + not_found: "Схема для таблицы '{table}' не найдена." + error: "❌ Ошибка: {error}" + +history: + title: "📜 *История ваших сигналов*" + filter_prompt: "\n\nФильтр по периоду или валютной паре:" + no_signals: "История сигналов не найдена. Сигналы будут записываться при их генерации." + + periods: + last_24h: "За 24ч" + last_7d: "За 7д" + last_30d: "За 30д" + + filtered_title: "📜 *История сигналов - За {period}*" + pair_title: "📜 *История сигналов - {pair}*" + + no_signals_period: "Сигналов за последние {period} не найдено." + no_signals_pair: "Сигналов для {pair} не найдено." + + show_all: "Показать все" + showing_recent: "_Показано {count} последних сигналов._" + + export: + csv: "Экспорт CSV" + json: "Экспорт JSON" + preparing: "Подготовка экспорта {format}..." + no_data: "Нет данных сигналов {description} для экспорта." + caption: "📊 История сигналов {description} экспортирована как {format}" + for_pair: "для {pair}" + for_all: "для всех пар" + error: "Не удалось экспортировать историю сигналов как {format}" + + update_failed: "Не удалось обновить сообщение" + diff --git a/src/i18n/locales/uk.yaml b/src/i18n/locales/uk.yaml new file mode 100644 index 0000000..e547f15 --- /dev/null +++ b/src/i18n/locales/uk.yaml @@ -0,0 +1,364 @@ +# Ukrainian translations for CryptoBot +# Українські переклади для CryptoBot + +common: + done: "Готово" + cancel: "Скасувати" + save: "Зберегти" + back: "Назад" + continue: "Продовжити" + skip: "Пропустити" + finish: "Завершити" + yes: "Так" + no: "Ні" + error: "Помилка" + success: "Успішно" + loading: "Завантаження..." + no_data: "Немає даних" + +language: + select_prompt: "🌐 Оберіть бажану мову:" + changed: "✅ Мову змінено на Українську" + current: "Поточна мова: Українська 🇺🇦" + +tutorial: + welcome: + title: "🎉 Ласкаво просимо до CryptoBot!" + content: | + Я ваш персональний помічник для аналізу криптовалютного ринку. Я допоможу вам аналізувати графіки криптовалют з просунутими технічними індикаторами. + + Це коротке навчання покаже вам, як використовувати мої основні функції. Ви можете пропустити навчання або повернутися до нього будь-коли за допомогою команди /tutorial. + + chart: + title: "📊 Аналіз графіка" + content: | + Основна команда для отримання аналізу графіка: + + /chart СИМВОЛ ДОВЖИНА ІНТЕРВАЛ ДОПУСК + + Наприклад: /chart BTCUSDT 48 1h 0.05 + + Це дасть вам 48-свічковий годинний графік для BTC/USDT з допуском 0.05 для визначення рівнів ліквідності. + + Спробуйте або продовжуйте, щоб дізнатися більше! + + preferences: + title: "⚙️ Налаштування індикаторів" + content: | + Ви можете налаштувати, які індикатори відображаються на графіках, за допомогою команди /preferences. + + Відкриється інтерактивне меню, де ви можете увімкнути або вимкнути: + • Ордер-блоки + • Зони справедливої вартості (FVG) + • Рівні ліквідності + • Брейкер-блоки + • І змінити налаштування відображення + + Ваші налаштування будуть збережені для майбутніх запитів. + + signals: + title: "🔔 Автоматичні сигнали" + content: | + Хочете отримувати автоматичні сповіщення? Налаштуйте сигнальні оповіщення за допомогою: + + /create_signal СИМВОЛ ХВИЛИНИ [ГРАФІК] + + Наприклад: /create_signal BTCUSDT 60 true + + Це надсилатиме вам сповіщення з графіком кожні 60 хвилин для BTC/USDT. + + Керувати сигналами можна командою /manage_signals. + + parameters: + title: "🔧 Розширені параметри" + content: | + Тонке налаштування параметрів індикаторів через команду /parameters. + + Ви можете налаштувати: + • Період ATR: для розрахунку середнього істинного діапазону + • Мін. розмір FVG: мінімальний розмір зон справедливої вартості + + Ці налаштування впливають на розрахунок і відображення індикаторів. + + completed: + title: "🚀 Навчання завершено!" + content: | + Чудово! Ви завершили навчання CryptoBot. + + Пам'ятайте, ви завжди можете отримати допомогу: + • /help - Загальна довідка + • /help КОМАНДА - Довідка по конкретній команді + + Готові почати? Спробуйте /chart BTCUSDT 48 1h 0.05 для аналізу BTC/USDT! + + buttons: + continue: "Продовжити" + skip_tutorial: "Пропустити навчання" + skip_to_end: "Пропустити до кінця" + complete: "Завершити навчання" + view_commands: "Показати команди" + back_to_tutorial: "Назад до навчання" + + welcome_back: + title: "З поверненням до CryptoBot!" + content: | + Ви вже завершили навчання. Що хочете зробити? + + • Отримати графік: /chart BTCUSDT 48 1h 0.05 + • Налаштувати вподобання: /preferences + • Створити сигнали: /create_signal + • Повторити навчання: /tutorial + + commands_list: + title: "Доступні команди:" + content: | + /chart - Отримати графік з індикаторами + /text_result - Отримати текстовий аналіз + /preferences - Налаштувати вподобання індикаторів + /parameters - Тонке налаштування індикаторів + /create_signal - Налаштувати автоматичні сигнали + /manage_signals - Керування сигналами + /help - Отримати довідку + /tutorial - Повторити навчання + /language - Змінити мову + + happy_trading: "Вдалої торгівлі! 📈\n\nВикористовуйте /help для отримання допомоги." + +signals: + manage_title: "Керування сигналами:" + no_signals: "Сигнали не знайдені" + add_new: "➕ Додати сигнал" + delete: "Видалити {pair}" + deleted: "Сигнал видалено. Оновлений список:" + created: "Сигнал створено! Поточні сигнали:" + active_count: "У вас {count} активних сигналів." + + add_prompt: | + Введіть бажаний сигнал у форматі: + + `СИМВОЛ ІНТЕРВАЛ [with_chart]` + + Приклад: `BTCUSDT 60` або `ETHUSDT 15 with_chart` + + СИМВОЛ має бути валідною торговою парою (наприклад, BTCUSDT). + ІНТЕРВАЛ має бути в хвилинах (наприклад, 5, 15, 60). + + invalid_input: "Невірний ввід: {error}. Спробуйте знову або /cancel." + limit_reached: "Ви досягли максимальної кількості сигналів ({limit})." + + usage: + create: "Використання: /create_signal <символ> <період_в_хвилинах> [<з_графіком>], ви надіслали {count} аргумент(ів)." + delete: "Використання: /delete_signal <символ>" + + errors: + unexpected: "❌ Сталася непередбачена помилка." + invalid_symbol: "❌ Невірний символ: {symbol}" + +preferences: + select_prompt: "Оберіть індикатори для відображення:" + selected: "Ви обрали: {indicators}" + none_selected: "Нічого" + + indicators: + order_blocks: "Ордер-блоки" + fvgs: "FVG" + liquidity_levels: "Рівні ліквідності" + breaker_blocks: "Брейкер-блоки" + liquidity_pools: "Пули ліквідності" + show_legend: "Показати легенду" + show_volume: "Показати об'єм" + dark_mode: "Темна тема" + light_mode: "Світла тема" + +parameters: + configure_title: "Налаштування параметрів індикаторів:" + saved: "Налаштування параметрів збережено!" + value_set: "{param} встановлено на {value}." + + edit_prompt: | + Введіть нове значення для {param}: + + Поточне значення: {current} + Допустимий діапазон: від {min} до {max} (крок: {step}) + + Відповідайте числом, щоб встановити нове значення. + + errors: + invalid_number: "Невірний ввід. Будь ласка, введіть число для {param}." + out_of_range: "Значення має бути від {min} до {max}. Спробуйте знову." + no_session: "Вибачте, немає активної сесії редагування. Використовуйте /parameters для початку." + process_failed: "Вибачте, не вдалося обробити цю дію. Спробуйте знову з /parameters." + +help: + title: "Довідка CryptoBot" + main: | + Ось основні команди: + + /chart <символ> <довжина> <інтервал> <допуск> + /text_result <символ> <довжина> <інтервал> <допуск> + /history <символ> <довжина> <інтервал> <допуск> <час> + /preferences + /create_signal <символ> <хвилини> [<з_графіком>] + /delete_signal <символ> + /manage_signals + /sql <запит> + /tables + /schema <назва_таблиці> + /language + /help <команда> + + Введіть /help <команда> для детальної довідки. + + Приклад: /help chart + + Для деталей див. README або зв'яжіться з розробником (@yarik_is_working). + + commands: + parameters: | + /parameters + Відкрити інтерактивне меню для налаштування параметрів індикаторів. + + chart: | + /chart <символ> <довжина> <інтервал> <допуск> + Отримати свічковий графік з усіма увімкненими індикаторами. + + Приклад: /chart BTCUSDT 48 1h 0.05 + + text_result: | + /text_result <символ> <довжина> <інтервал> <допуск> + Отримати текстовий звіт всіх виявлених індикаторів. + + Приклад: /text_result ETHUSDT 24 15m 0.03 + + history: | + /history <символ> <довжина> <інтервал> <допуск> <час> + Отримати свічковий графік для історичних даних. + + Приклад: /history BTCUSDT 48 1h 0.05 1700000000 + + preferences: | + /preferences + Відкрити інтерактивне меню для вибору індикаторів та налаштувань графіка. + + create_signal: | + /create_signal <символ> <хвилини> [<з_графіком>] + Почати отримувати автоматичні сигнали для пари із заданою частотою. + + Приклад: /create_signal BTCUSDT 60 true + + delete_signal: | + /delete_signal <символ> + Зупинити автоматичні сигнали для пари. + + manage_signals: | + /manage_signals + Відкрити інтерактивне меню для перегляду, додавання або видалення сигналів. + + help: | + /help + Показати загальне повідомлення довідки. + + sql: | + /sql <запит> + Виконати SQL-запит до бази даних. + + tables: | + /tables + Показати список усіх доступних таблиць у базі даних. + + schema: | + /schema <назва_таблиці> + Показати структуру конкретної таблиці. + + language: | + /language + Змінити мову бота. + +errors: + network: "Помилка мережі. Будь ласка, спробуйте пізніше." + data_fetch: "Не вдалося отримати дані. Перевірте символ і спробуйте знову." + data_processing: "Помилка обробки даних. Спробуйте з іншими параметрами." + chart_generation: "Помилка генерації графіка. Спробуйте з іншими налаштуваннями." + invalid_input: "Невірний ввід. Перевірте синтаксис команди." + database: "Помилка бази даних. Спробуйте знову." + timeout: "Час операції вичерпано. Спробуйте знову." + api_limit: "Досягнуто ліміт API. Спробуйте пізніше." + permission: "У вас немає дозволу на цю дію." + unknown: "Сталася непередбачена помилка. Спробуйте пізніше." + + global: | + ❌ При обробці вашого запиту сталася непередбачена помилка. Спробуйте пізніше. + + Якщо ви вважаєте це помилкою, повідомте тут: https://github.com/yariks5s/analysis_telegram_bot/issues + + preferences_failed: "Не вдалося отримати налаштування. Спробуйте встановити їх знову через /preferences." + analyze_failed: "Не вдалося проаналізувати дані. Перевірте ввід і спробуйте знову." + chart_send_failed: "Не вдалося надіслати графік. Спробуйте знову." + signal_generation_failed: "Не вдалося згенерувати сигнал прогнозу ціни. Надсилання графіка без аналізу." + analysis_not_available: "Аналіз недоступний" + invalid_timestamp: "❌ Невірна мітка часу. Має бути мітка Unix у минулому." + historical_usage: "Невірні параметри. Використання: /history <символ> <довжина> <інтервал> <допуск> <час>" + +rate_limit: + title: "**Статус обмеження запитів**" + remaining: "• Залишилось: {remaining}/{limit} запитів{suffix}" + reset_in: "• Скидання через: {seconds} секунд" + reduced_notice: "ℹ️ Ваш ліміт запитів тимчасово знижено через високу активність. Він автоматично відновиться після періоду очікування." + footer: "Система використовує обмеження запитів для забезпечення справедливого використання API." + suspicious_suffix: " (знижено через підозрілу активність)" + +database: + sql: + no_query: | + Будь ласка, вкажіть SQL-запит. + Використання: /sql <запит> + Приклад: /sql SELECT * FROM trades LIMIT 5 + results: "Результати запиту:\n\n{results}" + success_no_results: "Запит виконано успішно. Результатів немає." + error: "❌ Помилка виконання запиту: {error}" + + tables: + title: "Доступні таблиці:\n\n" + empty: "Таблиці в базі даних не знайдені." + error: "❌ Помилка: {error}" + + schema: + no_table: | + Будь ласка, вкажіть назву таблиці. + Використання: /schema <назва_таблиці> + Приклад: /schema trades + title: "Схема таблиці '{table}':\n\n" + not_found: "Схема для таблиці '{table}' не знайдена." + error: "❌ Помилка: {error}" + +history: + title: "📜 *Історія ваших сигналів*" + filter_prompt: "\n\nФільтр за періодом або валютною парою:" + no_signals: "Історія сигналів не знайдена. Сигнали будуть записуватися при їх генерації." + + periods: + last_24h: "За 24г" + last_7d: "За 7д" + last_30d: "За 30д" + + filtered_title: "📜 *Історія сигналів - За {period}*" + pair_title: "📜 *Історія сигналів - {pair}*" + + no_signals_period: "Сигналів за останні {period} не знайдено." + no_signals_pair: "Сигналів для {pair} не знайдено." + + show_all: "Показати все" + showing_recent: "_Показано {count} останніх сигналів._" + + export: + csv: "Експорт CSV" + json: "Експорт JSON" + preparing: "Підготовка експорту {format}..." + no_data: "Немає даних сигналів {description} для експорту." + caption: "📊 Історія сигналів {description} експортована як {format}" + for_pair: "для {pair}" + for_all: "для всіх пар" + error: "Не вдалося експортувати історію сигналів як {format}" + + update_failed: "Не вдалося оновити повідомлення" + diff --git a/src/telegram/commands/db_commands.py b/src/telegram/commands/db_commands.py index 3258cc4..4ff03e9 100644 --- a/src/telegram/commands/db_commands.py +++ b/src/telegram/commands/db_commands.py @@ -3,6 +3,8 @@ from back_tester.db_operations import ClickHouseDB import logging +from src.i18n import t, get_user_language + logger = logging.getLogger(__name__) # Initialize database connection @@ -15,12 +17,11 @@ async def execute_sql_command(update: Update, context: ContextTypes.DEFAULT_TYPE Usage: /sql Example: /sql SELECT * FROM trades LIMIT 5 """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + if not context.args: - await update.message.reply_text( - "Please provide a SQL query.\n" - "Usage: /sql \n" - "Example: /sql SELECT * FROM trades LIMIT 5" - ) + await update.message.reply_text(t("database.sql.no_query", lang)) return # Join all arguments to form the complete query @@ -39,9 +40,7 @@ async def execute_sql_command(update: Update, context: ContextTypes.DEFAULT_TYPE return if not result: - await update.message.reply_text( - "Query executed successfully. No results returned." - ) + await update.message.reply_text(t("database.sql.success_no_results", lang)) return # Convert result to string @@ -54,14 +53,14 @@ async def execute_sql_command(update: Update, context: ContextTypes.DEFAULT_TYPE formatted_result = str(result) # Format the message - message = f"Query Results:\n\n{formatted_result}" + message = t("database.sql.results", lang, results=formatted_result) # Send the formatted message await update.message.reply_text(message) except Exception as e: logger.error(f"Error executing SQL query: {str(e)}") - await update.message.reply_text(f"❌ Error executing query: {str(e)}") + await update.message.reply_text(t("database.sql.error", lang, error=str(e))) async def show_tables_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -69,17 +68,20 @@ async def show_tables_command(update: Update, context: ContextTypes.DEFAULT_TYPE Command handler to show available tables. Usage: /tables """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + try: tables = db.get_available_tables() if isinstance(tables, str): # Error occurred - await update.message.reply_text(f"❌ Error: {tables}") + await update.message.reply_text(t("database.tables.error", lang, error=tables)) return if not tables: - await update.message.reply_text("No tables found in the database.") + await update.message.reply_text(t("database.tables.empty", lang)) return - message = "Available tables:\n\n" + message = t("database.tables.title", lang) for table in tables: message += f"• {table}\n" @@ -87,7 +89,7 @@ async def show_tables_command(update: Update, context: ContextTypes.DEFAULT_TYPE except Exception as e: logger.error(f"Error getting tables: {str(e)}") - await update.message.reply_text(f"❌ Error: {str(e)}") + await update.message.reply_text(t("database.tables.error", lang, error=str(e))) async def describe_table_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -96,12 +98,11 @@ async def describe_table_command(update: Update, context: ContextTypes.DEFAULT_T Usage: /schema Example: /schema trades """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + if not context.args: - await update.message.reply_text( - "Please provide a table name.\n" - "Usage: /schema \n" - "Example: /schema trades" - ) + await update.message.reply_text(t("database.schema.no_table", lang)) return table_name = context.args[0] @@ -109,16 +110,14 @@ async def describe_table_command(update: Update, context: ContextTypes.DEFAULT_T try: schema = db.get_table_schema(table_name) if isinstance(schema, str): # Error occurred - await update.message.reply_text(f"❌ Error: {schema}") + await update.message.reply_text(t("database.schema.error", lang, error=schema)) return if not schema: - await update.message.reply_text( - f"No schema found for table '{table_name}'." - ) + await update.message.reply_text(t("database.schema.not_found", lang, table=table_name)) return - message = f"Schema for table '{table_name}':\n\n" + message = t("database.schema.title", lang, table=table_name) for column in schema: message += f"• {column['name']}: {column['type']}\n" if column["default"]: @@ -128,4 +127,4 @@ async def describe_table_command(update: Update, context: ContextTypes.DEFAULT_T except Exception as e: logger.error(f"Error getting table schema: {str(e)}") - await update.message.reply_text(f"❌ Error: {str(e)}") + await update.message.reply_text(t("database.schema.error", lang, error=str(e))) diff --git a/src/telegram/commands/help_commands.py b/src/telegram/commands/help_commands.py index 0fcecf0..6f235ea 100644 --- a/src/telegram/commands/help_commands.py +++ b/src/telegram/commands/help_commands.py @@ -1,119 +1,45 @@ +""" +Help command handlers for the Telegram bot. + +This module provides the /help command with multi-language support. +""" + from telegram import Update from telegram.ext import ContextTypes -COMMAND_HELPS = { - "parameters": ( - "/parameters\n" - "Open an interactive menu to customize indicator parameters.\n\n" - "Available parameters:\n" - "- ATR Period: Adjust the period used for Average True Range calculations (1-50)\n" - "- FVG Min Size: Set the minimum size ratio for Fair Value Gaps (0.0001-0.01)\n\n" - "Click on any parameter to modify its value." - ), - "chart": ( - "/chart <symbol> <length> <interval> <tolerance>\n" - "Get a candlestick chart with all enabled indicators.\n\n" - "- symbol: The trading pair, e.g. BTCUSDT\n" - "- length: Amount of candles to analyze (e.g. 48)\n" - "- interval: Candle interval (e.g. 1h, 15m)\n" - "- tolerance: Sensitivity for liquidity level detection (0-1, e.g. 0.05 = more levels, 0.2 = fewer, only strongest)\n\n" - "Example: /chart BTCUSDT 48 1h 0.05\n\n" - "The chart will include all indicators you have enabled in your preferences." - ), - "text_result": ( - "/text_result <symbol> <length> <interval> <tolerance>\n" - "Get a text summary of all detected indicators for the specified symbol and timeframe.\n\n" - "Example: /text_result ETHUSDT 24 15m 0.03" - ), - "history": ( - "/history <symbol> <length> <interval> <tolerance> <timestamp>\n" - "Get a candlestick chart for historical data at a specified timestamp.\n\n" - "- symbol: The trading pair, e.g. BTCUSDT\n" - "- length: Number of candles to analyze\n" - "- interval: Candle interval (e.g., 1h, 15m)\n" - "- tolerance: Sensitivity for liquidity level detection (0-1)\n" - "- timestamp: Unix epoch seconds for the end of the period\n\n" - "Example: /history BTCUSDT 48 1h 0.05 1700000000" - ), - "preferences": ( - "/preferences\n" - "Open an interactive menu to select which indicators to use and chart options.\n\n" - "Available options:\n" - "- Technical indicators (Order Blocks, FVGs, Liquidity Levels, etc.)\n" - "- Display settings (Show Legend, Show Volume)\n" - "- Theme selection (Light/Dark mode)" - ), - "create_signal": ( - "/create_signal <symbol> <minutes> [<show_chart>]\n" - "Start receiving auto-signals for a pair at a given frequency (in minutes).\n\n" - "- symbol: The trading pair, e.g. BTCUSDT\n" - "- minutes: Frequency in minutes to receive signals\n" - "- show_chart (optional): true/false, whether to include a chart with each signal (default: false)\n\n" - "Example: /create_signal BTCUSDT 60 true\n\n" - "You can have up to 10 active signal jobs per user." - ), - "delete_signal": ( - "/delete_signal <symbol>\n" - "Stop auto-signals for a pair.\n\n" - "Example: /delete_signal BTCUSDT" - ), - "manage_signals": ( - "/manage_signals\n" - "Open an interactive menu to view, add, or delete your signal jobs.\n\n" - "You can have up to 10 active signal jobs per user." - ), - "help": ( - "/help\nShow the general help message.\n\n" - "/help <command>\nShow detailed help for a specific command.\n\n" - "Example: /help chart" - ), - "sql": ( - "/sql <query>\n" - "Execute a custom SQL query against the database.\n\n" - "Example queries:\n" - "- /sql SELECT * FROM trades LIMIT 5\n" - "- /sql SELECT symbol, COUNT(*) as trades FROM trades GROUP BY symbol\n" - "- /sql SELECT AVG(profit_loss) as avg_profit FROM trades WHERE symbol = 'BTCUSDT'\n\n" - "Note: Results are limited to 50 rows for readability." - ), - "tables": ( - "/tables\n" - "Show a list of all available tables in the database.\n\n" - "Use this command to see what tables you can query with /sql." - ), - "schema": ( - "/schema <table_name>\n" - "Show the schema (structure) of a specific table.\n\n" - "Example: /schema trades\n\n" - "This will show you all columns and their data types in the specified table." - ), +from src.i18n import t, get_user_language + +# Map command names to translation keys +COMMAND_HELP_KEYS = { + "parameters": "help.commands.parameters", + "chart": "help.commands.chart", + "text_result": "help.commands.text_result", + "history": "help.commands.history", + "preferences": "help.commands.preferences", + "create_signal": "help.commands.create_signal", + "delete_signal": "help.commands.delete_signal", + "manage_signals": "help.commands.manage_signals", + "help": "help.commands.help", + "sql": "help.commands.sql", + "tables": "help.commands.tables", + "schema": "help.commands.schema", + "language": "help.commands.language", } async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """ - Sends a help message with usage instructions for the bot, or detailed help for a specific command if provided. + Sends a help message with usage instructions for the bot, + or detailed help for a specific command if provided. """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + args = context.args - if args and args[0].lower() in COMMAND_HELPS: - help_text = COMMAND_HELPS[args[0].lower()] + if args and args[0].lower() in COMMAND_HELP_KEYS: + command = args[0].lower() + help_text = t(COMMAND_HELP_KEYS[command], lang) else: - help_text = ( - "CryptoBot Help\n\n" - "Here are the main commands you can use:\n\n" - "/chart <symbol> <length> <interval> <tolerance>\n" - "/text_result <symbol> <length> <interval> <tolerance>\n" - "/history <symbol> <length> <interval> <tolerance> <timestamp>\n" - "/preferences\n" - "/create_signal <symbol> <minutes> [<show_chart>]\n" - "/delete_signal <symbol>\n" - "/manage_signals\n" - "/sql <query>\n" - "/tables\n" - "/schema <table_name>\n" - "/help <command>\n\n" - "Type /help <command> to get detailed help for a specific command.\n\n" - "Example: /help chart\n\n" - "For more details, see the README or contact the maintainer (@yarik_is_working)." - ) + help_text = f"{t('help.title', lang)}\n\n{t('help.main', lang)}" + await update.message.reply_text(help_text, parse_mode="HTML") diff --git a/src/telegram/commands/history_commands.py b/src/telegram/commands/history_commands.py index 8f03d0e..49b0793 100644 --- a/src/telegram/commands/history_commands.py +++ b/src/telegram/commands/history_commands.py @@ -17,6 +17,7 @@ get_signal_history_by_date_range, ) from src.analysis.utils.export import export_user_signals +from src.i18n import t, get_user_language from src.core.error_handler import handle_error @@ -36,14 +37,15 @@ async def command_signal_history(update: Update, context: ContextTypes.DEFAULT_T """ try: user_id = update.effective_user.id + lang = get_user_language(user_id) signals = get_user_signal_history(user_id, limit=10) keyboard = [ [ - InlineKeyboardButton("Last 24h", callback_data=f"{HISTORY_PERIOD}24h"), - InlineKeyboardButton("Last 7d", callback_data=f"{HISTORY_PERIOD}7d"), - InlineKeyboardButton("Last 30d", callback_data=f"{HISTORY_PERIOD}30d"), + InlineKeyboardButton(t("history.periods.last_24h", lang), callback_data=f"{HISTORY_PERIOD}24h"), + InlineKeyboardButton(t("history.periods.last_7d", lang), callback_data=f"{HISTORY_PERIOD}7d"), + InlineKeyboardButton(t("history.periods.last_30d", lang), callback_data=f"{HISTORY_PERIOD}30d"), ] ] @@ -61,11 +63,11 @@ async def command_signal_history(update: Update, context: ContextTypes.DEFAULT_T reply_markup = InlineKeyboardMarkup(keyboard) if signals: - message = "📜 *Your Signal History*\n\n" + message = f"{t('history.title', lang)}\n\n" message += format_signal_history(signals) - message += "\n\nFilter by time period or currency pair:" + message += t("history.filter_prompt", lang) else: - message = "No signal history found. Signals will be recorded when they are generated." + message = t("history.no_signals", lang) await update.message.reply_text( message, reply_markup=reply_markup, parse_mode="Markdown" @@ -86,6 +88,7 @@ async def button_history_callback(update: Update, context: ContextTypes.DEFAULT_ await query.answer() user_id = update.effective_user.id + lang = get_user_language(user_id) callback_data = query.data try: @@ -93,32 +96,32 @@ async def button_history_callback(update: Update, context: ContextTypes.DEFAULT_ period = callback_data[len(HISTORY_PERIOD) :] signals = filter_by_period(user_id, period) - keyboard = build_history_keyboard(signals) + keyboard = build_history_keyboard(signals, lang=lang) reply_markup = InlineKeyboardMarkup(keyboard) if signals: - message = f"📜 *Signal History - Last {period}*\n\n" - message += format_signal_history(signals) + message = f"{t('history.filtered_title', lang, period=period)}\n\n" + message += format_signal_history(signals, lang=lang) else: - message = f"No signals found in the last {period}." + message = t("history.no_signals_period", lang, period=period) - await safe_edit_message(query, message, reply_markup) + await safe_edit_message(query, message, reply_markup, lang) # Handle currency pair filtering elif callback_data.startswith(HISTORY_PAIR): pair = callback_data[len(HISTORY_PAIR) :] signals = get_user_signal_history(user_id, limit=20, currency_pair=pair) - keyboard = build_history_keyboard(signals, selected_pair=pair) + keyboard = build_history_keyboard(signals, selected_pair=pair, lang=lang) reply_markup = InlineKeyboardMarkup(keyboard) if signals: - message = f"📜 *Signal History - {pair}*\n\n" - message += format_signal_history(signals) + message = f"{t('history.pair_title', lang, pair=pair)}\n\n" + message += format_signal_history(signals, lang=lang) else: - message = f"No signals found for {pair}." + message = t("history.no_signals_pair", lang, pair=pair) - await safe_edit_message(query, message, reply_markup) + await safe_edit_message(query, message, reply_markup, lang) elif callback_data.startswith(HISTORY_PAGE): # TODO: Implement pagination for large result sets @@ -172,22 +175,23 @@ def filter_by_period(user_id, period): return get_signal_history_by_date_range(user_id, start_date, end_date) -def build_history_keyboard(signals, selected_pair=None): +def build_history_keyboard(signals, selected_pair=None, lang="en"): """ Build the keyboard for history filtering. Args: signals: List of signals selected_pair: Currently selected currency pair + lang: Language code for translations Returns: List of button rows for the keyboard """ keyboard = [ [ - InlineKeyboardButton("Last 24h", callback_data=f"{HISTORY_PERIOD}24h"), - InlineKeyboardButton("Last 7d", callback_data=f"{HISTORY_PERIOD}7d"), - InlineKeyboardButton("Last 30d", callback_data=f"{HISTORY_PERIOD}30d"), + InlineKeyboardButton(t("history.periods.last_24h", lang), callback_data=f"{HISTORY_PERIOD}24h"), + InlineKeyboardButton(t("history.periods.last_7d", lang), callback_data=f"{HISTORY_PERIOD}7d"), + InlineKeyboardButton(t("history.periods.last_30d", lang), callback_data=f"{HISTORY_PERIOD}30d"), ] ] @@ -205,7 +209,7 @@ def build_history_keyboard(signals, selected_pair=None): # Add 'Show All' button if a pair is selected if selected_pair: keyboard.append( - [InlineKeyboardButton("Show All", callback_data=f"{HISTORY_PERIOD}all")] + [InlineKeyboardButton(t("history.show_all", lang), callback_data=f"{HISTORY_PERIOD}all")] ) export_buttons = [] @@ -213,18 +217,18 @@ def build_history_keyboard(signals, selected_pair=None): export_buttons.extend( [ InlineKeyboardButton( - "Export CSV", callback_data=f"{EXPORT_CSV}{selected_pair}" + t("history.export.csv", lang), callback_data=f"{EXPORT_CSV}{selected_pair}" ), InlineKeyboardButton( - "Export JSON", callback_data=f"{EXPORT_JSON}{selected_pair}" + t("history.export.json", lang), callback_data=f"{EXPORT_JSON}{selected_pair}" ), ] ) else: export_buttons.extend( [ - InlineKeyboardButton("Export CSV", callback_data=f"{EXPORT_CSV}all"), - InlineKeyboardButton("Export JSON", callback_data=f"{EXPORT_JSON}all"), + InlineKeyboardButton(t("history.export.csv", lang), callback_data=f"{EXPORT_CSV}all"), + InlineKeyboardButton(t("history.export.json", lang), callback_data=f"{EXPORT_JSON}all"), ] ) @@ -234,13 +238,14 @@ def build_history_keyboard(signals, selected_pair=None): return keyboard -def format_signal_history(signals, max_signals=10): +def format_signal_history(signals, max_signals=10, lang="en"): """ Format the signal history for display. Args: signals: List of signals max_signals: Maximum number of signals to display + lang: Language code for translations Returns: Formatted message string @@ -286,12 +291,12 @@ def format_signal_history(signals, max_signals=10): message += line + "\n\n" if len(signals) == max_signals: - message += f"_Showing {max_signals} most recent signals._" + message += t("history.showing_recent", lang, count=max_signals) return message -async def safe_edit_message(query, text, reply_markup=None): +async def safe_edit_message(query, text, reply_markup=None, lang="en"): """ Safely edit a message, handling common Telegram API errors. @@ -299,6 +304,7 @@ async def safe_edit_message(query, text, reply_markup=None): query: The callback query text: The new text content reply_markup: Optional keyboard markup + lang: Language code for translations """ try: await query.edit_message_text( @@ -316,7 +322,7 @@ async def safe_edit_message(query, text, reply_markup=None): await query.edit_message_text(text=text, reply_markup=reply_markup) except Exception: # Last resort - acknowledge the interaction but don't change anything - await query.answer("Couldn't update message") + await query.answer(t("history.update_failed", lang)) else: # For any other errors, log but continue logger.error(f"Error editing message: {str(e)}") @@ -324,10 +330,10 @@ async def safe_edit_message(query, text, reply_markup=None): await query.edit_message_text(text=text, reply_markup=reply_markup) except Exception as inner_e: logger.error(f"Second attempt to edit message failed: {str(inner_e)}") - await query.answer("Couldn't update message") + await query.answer(t("history.update_failed", lang)) except Exception as e: logger.error(f"Unexpected error editing message: {str(e)}") - await query.answer("Couldn't update message") + await query.answer(t("history.update_failed", lang)) async def handle_export( @@ -350,26 +356,28 @@ async def handle_export( format_type: Export format ('csv' or 'json') currency_pair: Optional currency pair to filter by """ + lang = get_user_language(user_id) + try: query = update.callback_query message_id = query.message.message_id chat_id = query.message.chat_id - await query.answer(f"Preparing {format_type.upper()} export...") + await query.answer(t("history.export.preparing", lang, format=format_type.upper())) if currency_pair: signals = get_user_signal_history( user_id, limit=100, currency_pair=currency_pair ) - description = f"for {currency_pair}" + description = t("history.export.for_pair", lang, pair=currency_pair) else: signals = get_user_signal_history(user_id, limit=100) - description = "for all pairs" + description = t("history.export.for_all", lang) if not signals: await context.bot.send_message( chat_id=chat_id, - text=f"No signal data available {description} to export.", + text=t("history.export.no_data", lang, description=description), reply_to_message_id=message_id, ) return @@ -383,7 +391,7 @@ async def handle_export( chat_id=chat_id, document=file, filename=os.path.basename(filepath), - caption=f"📊 Signal history {description} exported as {format_type.upper()}", + caption=t("history.export.caption", lang, description=description, format=format_type.upper()), reply_to_message_id=message_id, ) @@ -393,5 +401,5 @@ async def handle_export( logger.error(f"Error removing temporary export file {filepath}: {str(e)}") except Exception as e: - error_message = f"Failed to export signal history as {format_type.upper()}" + error_message = t("history.export.error", lang, format=format_type.upper()) await handle_error(update, "export_error", error_message, exception=e) diff --git a/src/telegram/commands/language_commands.py b/src/telegram/commands/language_commands.py new file mode 100644 index 0000000..51e4ce6 --- /dev/null +++ b/src/telegram/commands/language_commands.py @@ -0,0 +1,110 @@ +""" +Language selection command handlers for the Telegram bot. + +This module provides commands for users to change their preferred language. +""" + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes + +from src.i18n import ( + t, + get_user_language, + set_user_language, + get_language_keyboard_data, + SUPPORTED_LANGUAGES, +) + + +async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + /language - Opens the language selection menu. + + Args: + update: Telegram update object + context: Telegram context object + """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + + keyboard = build_language_keyboard(lang) + + await update.message.reply_text( + t("language.select_prompt", lang), + reply_markup=keyboard, + ) + + +def build_language_keyboard(current_lang: str) -> InlineKeyboardMarkup: + """ + Build the inline keyboard for language selection. + + Args: + current_lang: Currently selected language code + + Returns: + InlineKeyboardMarkup with language options + """ + keyboard_data = get_language_keyboard_data() + keyboard = [] + + row = [] + for code, name, flag in keyboard_data: + # Mark current language with a checkmark + if code == current_lang: + label = f"✓ {flag} {name}" + else: + label = f"{flag} {name}" + + row.append( + InlineKeyboardButton(label, callback_data=f"lang_set:{code}") + ) + + # Create rows of 2 buttons each + if len(row) == 2: + keyboard.append(row) + row = [] + + # Add remaining buttons if any + if row: + keyboard.append(row) + + return InlineKeyboardMarkup(keyboard) + + +async def handle_language_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Handle language selection callback from inline keyboard. + + Args: + update: Telegram update object + context: Telegram context object + """ + query = update.callback_query + await query.answer() + + user_id = update.effective_user.id + callback_data = query.data + + if callback_data.startswith("lang_set:"): + new_lang = callback_data.replace("lang_set:", "") + + if new_lang in SUPPORTED_LANGUAGES: + # Update user's language preference + success = set_user_language(user_id, new_lang) + + if success: + # Reply in the new language + await query.edit_message_text( + t("language.changed", new_lang), + ) + else: + # Fallback message + await query.edit_message_text( + "❌ Failed to update language. Please try again.", + ) + else: + await query.edit_message_text( + "❌ Invalid language selection.", + ) + diff --git a/src/telegram/commands/signal_commands.py b/src/telegram/commands/signal_commands.py index a344116..6c2b41c 100644 --- a/src/telegram/commands/signal_commands.py +++ b/src/telegram/commands/signal_commands.py @@ -1,8 +1,8 @@ from telegram import Update from telegram.ext import ContextTypes from src.analysis.utils.helpers import check_signal_limit, input_sanity_check_analyzing -from src.core.utils import plural_helper from src.telegram.signals.detection import createSignalJob, deleteSignalJob +from src.i18n import t, get_user_language async def create_signal_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -11,6 +11,9 @@ async def create_signal_command(update: Update, context: ContextTypes.DEFAULT_TY Usage: /create_signal [] Example: /create_signal BTCUSDT 60 True """ + user_id = update.effective_user.id + lang = get_user_language(user_id) + if await check_signal_limit(update): return @@ -18,14 +21,14 @@ async def create_signal_command(update: Update, context: ContextTypes.DEFAULT_TY pair = await input_sanity_check_analyzing(True, args, update) if not pair: await update.message.reply_text( - f"Usage: /create_signal [], you've sent {len(args)} argument{plural_helper(len(args))}." + t("signals.usage.create", lang, count=len(args)) ) else: try: await createSignalJob(pair[0], pair[1], pair[2], update, context) except Exception as e: print(f"Unexpected error: {e}") - await update.message.reply_text("❌ An unexpected error occurred.") + await update.message.reply_text(t("signals.errors.unexpected", lang)) async def delete_signal_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -34,11 +37,13 @@ async def delete_signal_command(update: Update, context: ContextTypes.DEFAULT_TY Usage: /delete_signal Example: /delete_signal BTCUSDT """ + user_id = update.effective_user.id + lang = get_user_language(user_id) args = context.args # Check if args list is empty if not args: - await update.message.reply_text(f"Usage: /delete_signal ") + await update.message.reply_text(t("signals.usage.delete", lang)) return # Format and store the command in the update object for deleteSignalJob to parse @@ -53,10 +58,10 @@ async def delete_signal_command(update: Update, context: ContextTypes.DEFAULT_TY await deleteSignalJob(args[0].upper(), update) except ValueError as e: print(f"Value error in delete_signal: {e}") - await update.message.reply_text(f"❌ Invalid symbol: {str(e)}") + await update.message.reply_text(t("signals.errors.invalid_symbol", lang, symbol=str(e))) except Exception as e: print(f"Unexpected error in delete_signal: {e}") - await update.message.reply_text("❌ An unexpected error occurred.") + await update.message.reply_text(t("signals.errors.unexpected", lang)) finally: # Restore the original text in case it's needed elsewhere update.message.text = original_text diff --git a/src/telegram/commands/status_commands.py b/src/telegram/commands/status_commands.py index 3bcd5f4..00d6dc2 100644 --- a/src/telegram/commands/status_commands.py +++ b/src/telegram/commands/status_commands.py @@ -9,6 +9,7 @@ from telegram.ext import ContextTypes from src.core.rate_limiter import get_rate_limit_stats +from src.i18n import t, get_user_language async def rate_limit_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -20,25 +21,21 @@ async def rate_limit_command(update: Update, context: ContextTypes.DEFAULT_TYPE) context: The context object from Telegram """ user_id = update.effective_user.id + lang = get_user_language(user_id) quota = get_rate_limit_stats(user_id) status_emoji = "✅" if quota["remaining"] > 5 else "⚠️" - suspicious_status = ( - " (reduced due to suspicious activity)" if quota["is_suspicious"] else "" - ) + suspicious_suffix = t("rate_limit.suspicious_suffix", lang) if quota["is_suspicious"] else "" message = ( - f"{status_emoji} **Rate Limit Status**\n\n" - f"• Remaining: {quota['remaining']}/{quota['limit']} requests{suspicious_status}\n" - f"• Reset in: {quota['reset_seconds']} seconds\n\n" + f"{status_emoji} {t('rate_limit.title', lang)}\n\n" + f"{t('rate_limit.remaining', lang, remaining=quota['remaining'], limit=quota['limit'], suffix=suspicious_suffix)}\n" + f"{t('rate_limit.reset_in', lang, seconds=quota['reset_seconds'])}\n\n" ) if quota["is_suspicious"]: - message += ( - "ℹ️ Your rate limit is temporarily reduced due to detected high-frequency activity. " - "This will automatically return to normal after a cooling period.\n\n" - ) + message += f"{t('rate_limit.reduced_notice', lang)}\n\n" - message += "The system uses rate limiting to ensure fair API usage for all users." + message += t("rate_limit.footer", lang) await update.message.reply_text(message, parse_mode="Markdown") diff --git a/src/telegram/handlers.py b/src/telegram/handlers.py index 6d3843a..c7e6b7b 100644 --- a/src/telegram/handlers.py +++ b/src/telegram/handlers.py @@ -22,6 +22,7 @@ ) from src.core.preferences import INDICATOR_PARAMS from src.telegram.signals.detection import createSignalJob +from src.i18n import t, get_user_language from src.analysis.utils.helpers import input_sanity_check_text, check_signal_limit_by_id from src.core.preferences import get_formatted_preferences @@ -49,15 +50,17 @@ async def manage_signals(update: Update, context: ContextTypes.DEFAULT_TYPE): /manage_signals - The entry point to display signals for the user with inline buttons. """ user_id = update.effective_user.id + lang = get_user_language(user_id) await update.message.reply_text( - text="Manage your signals:", reply_markup=build_signal_list_keyboard(user_id) + text=t("signals.manage_title", lang), + reply_markup=build_signal_list_keyboard(user_id, lang) ) return CHOOSING_ACTION -def build_signal_list_keyboard(user_id: int) -> InlineKeyboardMarkup: +def build_signal_list_keyboard(user_id: int, lang: str = "en") -> InlineKeyboardMarkup: """ Builds a dynamic inline keyboard listing all signals for a user: - Each row: [ (m)] [Delete ] @@ -66,6 +69,7 @@ def build_signal_list_keyboard(user_id: int) -> InlineKeyboardMarkup: Args: user_id: Telegram user ID + lang: Language code for translations Returns: InlineKeyboardMarkup with signal management options @@ -82,21 +86,22 @@ def build_signal_list_keyboard(user_id: int) -> InlineKeyboardMarkup: f"{pair} {freq}m chart {'✅' if str(is_with_chart) == '1' else '❌'}" ) del_button = InlineKeyboardButton( - text=f"Delete {pair}", callback_data=f"delete_signal_{pair}" + text=t("signals.delete", lang, pair=pair), + callback_data=f"delete_signal_{pair}" ) keyboard.append( [InlineKeyboardButton(display_text, callback_data="no_op"), del_button] ) else: keyboard.append( - [InlineKeyboardButton("No signals found", callback_data="no_op")] + [InlineKeyboardButton(t("signals.no_signals", lang), callback_data="no_op")] ) keyboard.append( - [InlineKeyboardButton("➕ Add New Signal", callback_data="add_signal")] + [InlineKeyboardButton(t("signals.add_new", lang), callback_data="add_signal")] ) - keyboard.append([InlineKeyboardButton("Done", callback_data="signal_menu_done")]) + keyboard.append([InlineKeyboardButton(t("common.done", lang), callback_data="signal_menu_done")]) return InlineKeyboardMarkup(keyboard) @@ -117,6 +122,7 @@ async def handle_signal_menu_callback( query = update.callback_query data = query.data user_id = query.from_user.id + lang = get_user_language(user_id) logger.info(f"handle_signal_menu_callback -> callback_data = {data}") await query.answer() @@ -136,25 +142,21 @@ async def handle_signal_menu_callback( del auto_signal_jobs[job_key] await query.edit_message_text( - text="Signal deleted. Updated list:", - reply_markup=build_signal_list_keyboard(user_id), + text=t("signals.deleted", lang), + reply_markup=build_signal_list_keyboard(user_id, lang), ) return CHOOSING_ACTION elif data == "add_signal": await query.edit_message_text( - "Please enter your desired signal in the format: \n\n" - "`SYMBOL INTERVAL [with_chart]`\n\n" - "Example: `BTCUSDT 60` or `ETHUSDT 15 with_chart`\n\n" - "SYMBOL should be a valid trading pair (e.g., BTCUSDT).\n" - "INTERVAL should be in minutes (e.g., 5, 15, 60)." + t("signals.add_prompt", lang), + parse_mode="Markdown" ) return TYPING_SIGNAL_DATA elif data == "signal_menu_done": n_signals = len(get_all_user_signal_requests(user_id)) - signal_text = f"signal{plural_helper(n_signals)}" - await query.edit_message_text(f"You have {n_signals} active {signal_text}.") + await query.edit_message_text(t("signals.active_count", lang, count=n_signals)) return ConversationHandler.END return CHOOSING_ACTION @@ -172,12 +174,13 @@ async def handle_signal_text_input(update: Update, context: ContextTypes.DEFAULT Conversation state """ user_id = update.effective_user.id + lang = get_user_language(user_id) text = update.message.text.strip().upper() parse_result = input_sanity_check_text(text) if not parse_result["is_valid"]: await update.message.reply_text( - f"Invalid input: {parse_result['error_message']}. Try again or /cancel." + t("signals.invalid_input", lang, error=parse_result['error_message']) ) return TYPING_SIGNAL_DATA @@ -185,7 +188,8 @@ async def handle_signal_text_input(update: Update, context: ContextTypes.DEFAULT if not limit_ok: await update.message.reply_text(limit_msg) await update.message.reply_text( - text="Current signals:", reply_markup=build_signal_list_keyboard(user_id) + text=t("signals.manage_title", lang), + reply_markup=build_signal_list_keyboard(user_id, lang) ) return CHOOSING_ACTION @@ -195,13 +199,13 @@ async def handle_signal_text_input(update: Update, context: ContextTypes.DEFAULT await createSignalJob(symbol, period_minutes, is_with_chart, update, context) await update.message.reply_text( - text="Signal created! Current signals:", - reply_markup=build_signal_list_keyboard(user_id), + text=t("signals.created", lang), + reply_markup=build_signal_list_keyboard(user_id, lang), ) return CHOOSING_ACTION -def get_indicator_selection_keyboard(user_id, menu_id=None): +def get_indicator_selection_keyboard(user_id, menu_id=None, lang="en"): """ Create an inline keyboard for selecting indicators with a checkmark for selected ones. @@ -209,6 +213,7 @@ def get_indicator_selection_keyboard(user_id, menu_id=None): user_id: Telegram user ID menu_id: Optional unique identifier for this preference menu instance (was created to provide multiple menus being opened at the same time) + lang: Language code for translations Returns: InlineKeyboardMarkup with indicator selection options and the menu_id @@ -229,49 +234,49 @@ def create_callback(action): keyboard = [ [ InlineKeyboardButton( - f"{'✔️ ' if selected['order_blocks'] else ''}Order Blocks", + f"{'✔️ ' if selected['order_blocks'] else ''}{t('preferences.indicators.order_blocks', lang)}", callback_data=create_callback("indicator_order_blocks"), ), InlineKeyboardButton( - f"{'✔️ ' if selected['fvgs'] else ''}FVGs", + f"{'✔️ ' if selected['fvgs'] else ''}{t('preferences.indicators.fvgs', lang)}", callback_data=create_callback("indicator_fvgs"), ), ], [ InlineKeyboardButton( - f"{'✔️ ' if selected['liquidity_levels'] else ''}Liquidity Levels", + f"{'✔️ ' if selected['liquidity_levels'] else ''}{t('preferences.indicators.liquidity_levels', lang)}", callback_data=create_callback("indicator_liquidity_levels"), ), InlineKeyboardButton( - f"{'✔️ ' if selected['breaker_blocks'] else ''}Breaker Blocks", + f"{'✔️ ' if selected['breaker_blocks'] else ''}{t('preferences.indicators.breaker_blocks', lang)}", callback_data=create_callback("indicator_breaker_blocks"), ), ], [ InlineKeyboardButton( - f"{'✔️ ' if selected['show_legend'] else ''}Show Legend", + f"{'✔️ ' if selected['show_legend'] else ''}{t('preferences.indicators.show_legend', lang)}", callback_data=create_callback("indicator_show_legend"), ), InlineKeyboardButton( - f"{'✔️ ' if selected['show_volume'] else ''}Show Volume", + f"{'✔️ ' if selected['show_volume'] else ''}{t('preferences.indicators.show_volume', lang)}", callback_data=create_callback("indicator_show_volume"), ), ], [ InlineKeyboardButton( - f"{'✔️ ' if selected['liquidity_pools'] else ''}Liquidity Pools", + f"{'✔️ ' if selected['liquidity_pools'] else ''}{t('preferences.indicators.liquidity_pools', lang)}", callback_data=create_callback("indicator_liquidity_pools"), ), ], [ InlineKeyboardButton( - f"{'🌙 ' if selected['dark_mode'] else '☀️ '}{'Dark Mode' if selected['dark_mode'] else 'Light Mode'}", + f"{'🌙 ' if selected['dark_mode'] else '☀️ '}{t('preferences.indicators.dark_mode', lang) if selected['dark_mode'] else t('preferences.indicators.light_mode', lang)}", callback_data=create_callback("indicator_dark_mode"), ), ], [ InlineKeyboardButton( - "Done", callback_data=create_callback("indicator_done") + t("common.done", lang), callback_data=create_callback("indicator_done") ), ], ] @@ -293,6 +298,7 @@ async def handle_indicator_selection(update, _): await query.answer() user_id = query.from_user.id + lang = get_user_language(user_id) try: data_parts = query.data.split(":") @@ -330,8 +336,9 @@ async def handle_indicator_selection(update, _): if menu_id in _menu_preferences: del _menu_preferences[menu_id] + indicators_str = ', '.join(selected_pretty) if selected_pretty else t("preferences.none_selected", lang) await query.edit_message_text( - f"You selected: {', '.join(selected_pretty) or 'None'}" + t("preferences.selected", lang, indicators=indicators_str) ) return @@ -354,20 +361,21 @@ async def handle_indicator_selection(update, _): _menu_preferences[menu_id] = preferences - new_markup, _ = get_indicator_selection_keyboard(user_id, menu_id) + new_markup, _ = get_indicator_selection_keyboard(user_id, menu_id, lang) await query.edit_message_reply_markup(reply_markup=new_markup) return CHOOSING_ACTION -def get_parameter_keyboard(user_id, menu_id=None): +def get_parameter_keyboard(user_id, menu_id=None, lang="en"): """ Create an inline keyboard for adjusting indicator parameters. Args: user_id: Telegram user ID menu_id: Optional unique identifier for this parameter menu instance + lang: Language code for translations Returns: InlineKeyboardMarkup with parameter setting options and the menu_id @@ -394,71 +402,12 @@ def get_parameter_keyboard(user_id, menu_id=None): ) keyboard.append( - [InlineKeyboardButton("Save", callback_data=f"param:{menu_id}:save")] + [InlineKeyboardButton(t("common.save", lang), callback_data=f"param:{menu_id}:save")] ) return InlineKeyboardMarkup(keyboard), menu_id -async def handle_parameter_input(update, context): - """ - Handle user input for parameter values. - - Args: - update: Telegram update object - context: Telegram context object - - Returns: - Conversation state - """ - user_id = update.effective_user.id - user_text = update.message.text.strip() - - if user_id not in _param_edit_states: - await update.message.reply_text( - "Sorry, I don't have an active parameter editing session. " - "Please use /select_indicators to start over." - ) - return ConversationHandler.END - - param_info = _param_edit_states[user_id] - param_name = param_info["param"] - menu_id = param_info["menu_id"] - - try: - if param_name == "atr_period": - new_value = int(user_text) - else: # fvg_min_size or other float params - new_value = float(user_text) - - param_config = INDICATOR_PARAMS[param_name] - if new_value < param_config["min"] or new_value > param_config["max"]: - await update.message.reply_text( - f"Value must be between {param_config['min']} and {param_config['max']}. " - "Please try again." - ) - return TYPING_PARAM_VALUE - - _menu_preferences[menu_id][param_name] = new_value - - # Clear the edit state - del _param_edit_states[user_id] - - param_markup = get_parameter_keyboard(user_id, menu_id) - await update.message.reply_text( - f"{INDICATOR_PARAMS[param_name]['display_name']} set to {new_value}.", - reply_markup=param_markup, - ) - - return CHOOSING_ACTION - - except (ValueError, TypeError): - await update.message.reply_text( - f"Invalid input. Please enter a valid number for {INDICATOR_PARAMS[param_name]['display_name']}." - ) - return TYPING_PARAM_VALUE - - async def select_indicators(update, _): """ Start the process of selecting indicators. @@ -471,11 +420,12 @@ async def select_indicators(update, _): None """ user_id = update.effective_user.id + lang = get_user_language(user_id) - keyboard, _ = get_indicator_selection_keyboard(user_id) + keyboard, _ = get_indicator_selection_keyboard(user_id, lang=lang) await update.message.reply_text( - "Please choose the indicators you'd like to include:", + t("preferences.select_prompt", lang), reply_markup=keyboard, ) @@ -495,6 +445,7 @@ async def handle_parameter_selection(update, _): await query.answer() user_id = query.from_user.id + lang = get_user_language(user_id) try: data_parts = query.data.split(":") @@ -520,7 +471,7 @@ async def handle_parameter_selection(update, _): if menu_id in _param_preferences: del _param_preferences[menu_id] - await query.edit_message_text("Parameter settings saved successfully!") + await query.edit_message_text(t("parameters.saved", lang)) return elif action.startswith("edit_"): @@ -531,18 +482,18 @@ async def handle_parameter_selection(update, _): current_value = preferences.get(param_name, param_info["default"]) await query.edit_message_text( - f"Enter a new value for {param_info['display_name']}:\n\n" - f"Current value: {current_value}\n" - f"Valid range: {param_info['min']} to {param_info['max']} (step: {param_info['step']})\n\n" - f"Reply with a number to set the new value." + t("parameters.edit_prompt", lang, + param=param_info['display_name'], + current=current_value, + min=param_info['min'], + max=param_info['max'], + step=param_info['step']) ) return TYPING_PARAM_VALUE # If we got here, something unexpected happened - await query.edit_message_text( - "Sorry, I couldn't process that action. Please try again with /parameters." - ) + await query.edit_message_text(t("parameters.errors.process_failed", lang)) return @@ -558,13 +509,11 @@ async def handle_parameter_input(update, context): Conversation state """ user_id = update.effective_user.id + lang = get_user_language(user_id) user_text = update.message.text.strip() if user_id not in _param_edit_states: - await update.message.reply_text( - "Sorry, I don't have an active parameter editing session. " - "Please use /parameters to start over." - ) + await update.message.reply_text(t("parameters.errors.no_session", lang)) return ConversationHandler.END param_info = _param_edit_states[user_id] @@ -580,8 +529,9 @@ async def handle_parameter_input(update, context): param_config = INDICATOR_PARAMS[param_name] if new_value < param_config["min"] or new_value > param_config["max"]: await update.message.reply_text( - f"Value must be between {param_config['min']} and {param_config['max']}. " - "Please try again." + t("parameters.errors.out_of_range", lang, + min=param_config['min'], + max=param_config['max']) ) return TYPING_PARAM_VALUE @@ -589,9 +539,11 @@ async def handle_parameter_input(update, context): del _param_edit_states[user_id] - param_markup, _ = get_parameter_keyboard(user_id, menu_id) + param_markup, _ = get_parameter_keyboard(user_id, menu_id, lang) await update.message.reply_text( - f"{INDICATOR_PARAMS[param_name]['display_name']} set to {new_value}.", + t("parameters.value_set", lang, + param=INDICATOR_PARAMS[param_name]['display_name'], + value=new_value), reply_markup=param_markup, ) @@ -599,7 +551,8 @@ async def handle_parameter_input(update, context): except (ValueError, TypeError): await update.message.reply_text( - f"Invalid input. Please enter a valid number for {INDICATOR_PARAMS[param_name]['display_name']}." + t("parameters.errors.invalid_number", lang, + param=INDICATOR_PARAMS[param_name]['display_name']) ) return TYPING_PARAM_VALUE @@ -616,11 +569,12 @@ async def show_parameters(update, _): Conversation state """ user_id = update.effective_user.id + lang = get_user_language(user_id) - keyboard, _ = get_parameter_keyboard(user_id) + keyboard, _ = get_parameter_keyboard(user_id, lang=lang) await update.message.reply_text( - "Configure indicator parameters:", + t("parameters.configure_title", lang), reply_markup=keyboard, ) diff --git a/src/telegram/tutorial.py b/src/telegram/tutorial.py index 64d0542..22fc041 100644 --- a/src/telegram/tutorial.py +++ b/src/telegram/tutorial.py @@ -9,6 +9,7 @@ from telegram.ext import ContextTypes, ConversationHandler from src.database.operations import get_user_preferences, update_user_preferences +from src.i18n import t, get_user_language TUTORIAL_NOT_STARTED = 0 TUTORIAL_WELCOME = 1 @@ -20,99 +21,72 @@ CHOOSING_TUTORIAL_ACTION = 0 -TUTORIAL_MESSAGES = { - TUTORIAL_WELCOME: { - "title": "🎉 Welcome to CryptoBot!", - "content": ( - "I'm your personal crypto market analysis assistant. I can help you analyze " - "cryptocurrency charts with advanced technical indicators.\n\n" - "This short tutorial will show you how to use my main features. You can skip " - "the tutorial or come back to it anytime with the /tutorial command." - ), - "buttons": [ - [InlineKeyboardButton("Continue", callback_data="tutorial_next")], - [InlineKeyboardButton("Skip Tutorial", callback_data="tutorial_skip")], - ], - }, - TUTORIAL_CHART_COMMAND: { - "title": "📊 Chart Analysis", - "content": ( - "The basic command to get a chart analysis is:\n\n" - "/chart SYMBOL LENGTH INTERVAL TOLERANCE\n\n" - "For example: /chart BTCUSDT 48 1h 0.05\n\n" - "This gives you a 48-candle, 1-hour chart for BTC/USDT with a tolerance of 0.05 " - "for detecting liquidity levels.\n\n" - "Try it out or continue to learn more features!" - ), - "buttons": [ - [InlineKeyboardButton("Continue", callback_data="tutorial_next")], - [InlineKeyboardButton("Skip to End", callback_data="tutorial_skip")], - ], - }, - TUTORIAL_PREFERENCES: { - "title": "⚙️ Customizing Indicators", - "content": ( - "You can customize which indicators appear on your charts with the " - "/preferences command.\n\n" - "This opens an interactive menu where you can enable or disable:\n" - "• Order Blocks\n" - "• Fair Value Gaps (FVGs)\n" - "• Liquidity Levels\n" - "• Breaker Blocks\n" - "• And change display options\n\n" - "Your preferences will be saved for future chart requests." - ), - "buttons": [ - [InlineKeyboardButton("Continue", callback_data="tutorial_next")], - [InlineKeyboardButton("Skip to End", callback_data="tutorial_skip")], - ], - }, - TUTORIAL_SIGNALS: { - "title": "🔔 Automated Signals", - "content": ( - "Want to receive automatic notifications? Set up signal alerts with:\n\n" - "/create_signal SYMBOL MINUTES [CHART]\n\n" - "For example: /create_signal BTCUSDT 60 true\n\n" - "This will send you a notification with a chart every 60 minutes for BTC/USDT.\n\n" - "You can manage your signals with the /manage_signals command." - ), - "buttons": [ - [InlineKeyboardButton("Continue", callback_data="tutorial_next")], - [InlineKeyboardButton("Skip to End", callback_data="tutorial_skip")], - ], - }, - TUTORIAL_PARAMETERS: { - "title": "🔧 Advanced Parameters", - "content": ( - "Fine-tune indicator parameters with the /parameters command.\n\n" - "You can adjust:\n" - "• ATR Period: for calculating Average True Range\n" - "• FVG Min Size: minimum size for Fair Value Gaps\n\n" - "These settings affect how indicators are calculated and displayed." - ), - "buttons": [ - [InlineKeyboardButton("Complete Tutorial", callback_data="tutorial_next")] - ], - }, - TUTORIAL_COMPLETED: { - "title": "🚀 Tutorial Completed!", - "content": ( - "Great job! You've completed the CryptoBot tutorial.\n\n" - "Remember, you can always get help with:\n" - "• /help - General help information\n" - "• /help COMMAND - Help for a specific command\n\n" - "Ready to start? Try /chart BTCUSDT 48 1h 0.05 to see BTC/USDT analysis!" - ), - "buttons": [ - [ - InlineKeyboardButton( - "View Available Commands", callback_data="tutorial_commands" - ) + +def get_tutorial_message(stage: int, lang: str = "en") -> dict: + """ + Get the tutorial message for a specific stage in the specified language. + + Args: + stage: Tutorial stage number + lang: Language code + + Returns: + Dictionary with title, content, and buttons for the tutorial stage + """ + messages = { + TUTORIAL_WELCOME: { + "title": t("tutorial.welcome.title", lang), + "content": t("tutorial.welcome.content", lang), + "buttons": [ + [InlineKeyboardButton(t("tutorial.buttons.continue", lang), callback_data="tutorial_next")], + [InlineKeyboardButton(t("tutorial.buttons.skip_tutorial", lang), callback_data="tutorial_skip")], + ], + }, + TUTORIAL_CHART_COMMAND: { + "title": t("tutorial.chart.title", lang), + "content": t("tutorial.chart.content", lang), + "buttons": [ + [InlineKeyboardButton(t("tutorial.buttons.continue", lang), callback_data="tutorial_next")], + [InlineKeyboardButton(t("tutorial.buttons.skip_to_end", lang), callback_data="tutorial_skip")], + ], + }, + TUTORIAL_PREFERENCES: { + "title": t("tutorial.preferences.title", lang), + "content": t("tutorial.preferences.content", lang), + "buttons": [ + [InlineKeyboardButton(t("tutorial.buttons.continue", lang), callback_data="tutorial_next")], + [InlineKeyboardButton(t("tutorial.buttons.skip_to_end", lang), callback_data="tutorial_skip")], + ], + }, + TUTORIAL_SIGNALS: { + "title": t("tutorial.signals.title", lang), + "content": t("tutorial.signals.content", lang), + "buttons": [ + [InlineKeyboardButton(t("tutorial.buttons.continue", lang), callback_data="tutorial_next")], + [InlineKeyboardButton(t("tutorial.buttons.skip_to_end", lang), callback_data="tutorial_skip")], + ], + }, + TUTORIAL_PARAMETERS: { + "title": t("tutorial.parameters.title", lang), + "content": t("tutorial.parameters.content", lang), + "buttons": [ + [InlineKeyboardButton(t("tutorial.buttons.complete", lang), callback_data="tutorial_next")] + ], + }, + TUTORIAL_COMPLETED: { + "title": t("tutorial.completed.title", lang), + "content": t("tutorial.completed.content", lang), + "buttons": [ + [ + InlineKeyboardButton( + t("tutorial.buttons.view_commands", lang), callback_data="tutorial_commands" + ) + ], + [InlineKeyboardButton(t("common.finish", lang), callback_data="tutorial_finish")], ], - [InlineKeyboardButton("Finish", callback_data="tutorial_finish")], - ], - }, -} + }, + } + return messages.get(stage, messages[TUTORIAL_WELCOME]) async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -122,12 +96,13 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """ user_id = update.effective_user.id user_prefs = get_user_preferences(user_id) + lang = get_user_language(user_id) if user_prefs["tutorial_stage"] < TUTORIAL_COMPLETED: user_prefs["tutorial_stage"] = TUTORIAL_WELCOME update_user_preferences(user_id, user_prefs) - tutorial = TUTORIAL_MESSAGES[TUTORIAL_WELCOME] + tutorial = get_tutorial_message(TUTORIAL_WELCOME, lang) await update.message.reply_html( f"{tutorial['title']}\n\n{tutorial['content']}", reply_markup=InlineKeyboardMarkup(tutorial["buttons"]), @@ -136,12 +111,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): else: # User has already completed the tutorial await update.message.reply_html( - "Welcome back to CryptoBot!\n\n" - "You've already completed the tutorial. What would you like to do?\n\n" - "• Get a chart with /chart BTCUSDT 48 1h 0.05\n" - "• Set preferences with /preferences\n" - "• Create signals with /create_signal\n" - "• Review the tutorial with /tutorial" + f"{t('tutorial.welcome_back.title', lang)}\n\n{t('tutorial.welcome_back.content', lang)}" ) return ConversationHandler.END @@ -152,6 +122,7 @@ async def tutorial_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """ user_id = update.effective_user.id user_prefs = get_user_preferences(user_id) + lang = get_user_language(user_id) has_completed_before = user_prefs["tutorial_stage"] >= TUTORIAL_COMPLETED @@ -170,7 +141,7 @@ async def tutorial_command(update: Update, context: ContextTypes.DEFAULT_TYPE): # For users who already completed, we'll show the tutorial from the beginning # but won't update their stage in the database unless they complete it again - tutorial = TUTORIAL_MESSAGES[current_view_stage] + tutorial = get_tutorial_message(current_view_stage, lang) context.user_data["has_completed_tutorial_before"] = has_completed_before context.user_data["original_tutorial_stage"] = original_stage @@ -191,6 +162,7 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT user_id = update.effective_user.id user_prefs = get_user_preferences(user_id) + lang = get_user_language(user_id) # Check if user completed tutorial before (from context or preferences) has_completed_before = context.user_data.get( @@ -214,7 +186,7 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT user_prefs["tutorial_stage"] = next_stage update_user_preferences(user_id, user_prefs) - tutorial = TUTORIAL_MESSAGES[next_stage] + tutorial = get_tutorial_message(next_stage, lang) await query.edit_message_text( f"{tutorial['title']}\n\n{tutorial['content']}", reply_markup=InlineKeyboardMarkup(tutorial["buttons"]), @@ -230,7 +202,7 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT user_prefs["tutorial_stage"] = TUTORIAL_COMPLETED update_user_preferences(user_id, user_prefs) - tutorial = TUTORIAL_MESSAGES[TUTORIAL_COMPLETED] + tutorial = get_tutorial_message(TUTORIAL_COMPLETED, lang) await query.edit_message_text( f"{tutorial['title']}\n\n{tutorial['content']}", reply_markup=InlineKeyboardMarkup(tutorial["buttons"]), @@ -238,23 +210,16 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT ) return ConversationHandler.END - elif query.data == "tutorial_commands": # Todo: make it reusable + elif query.data == "tutorial_commands": await query.edit_message_text( - "Available Commands:\n\n" - "/chart - Get a chart with indicators\n" - "/text_result - Get a text analysis\n" - "/preferences - Set indicator preferences\n" - "/parameters - Fine-tune indicator settings\n" - "/create_signal - Set up automatic signals\n" - "/manage_signals - Manage your signal alerts\n" - "/help - Get help with commands\n" - "/tutorial - Revisit this tutorial", + f"{t('tutorial.commands_list.title', lang)}\n\n{t('tutorial.commands_list.content', lang)}", parse_mode="HTML", reply_markup=InlineKeyboardMarkup( [ [ InlineKeyboardButton( - "Back to Tutorial", callback_data="tutorial_back_to_end" + t("tutorial.buttons.back_to_tutorial", lang), + callback_data="tutorial_back_to_end" ) ] ] @@ -262,7 +227,7 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT ) elif query.data == "tutorial_back_to_end": - tutorial = TUTORIAL_MESSAGES[TUTORIAL_COMPLETED] + tutorial = get_tutorial_message(TUTORIAL_COMPLETED, lang) await query.edit_message_text( f"{tutorial['title']}\n\n{tutorial['content']}", reply_markup=InlineKeyboardMarkup(tutorial["buttons"]), @@ -271,8 +236,7 @@ async def handle_tutorial_callback(update: Update, context: ContextTypes.DEFAULT elif query.data == "tutorial_finish": await query.edit_message_text( - "Happy trading! 📈\n\n" - "Use /help anytime you need assistance.", + t("tutorial.happy_trading", lang), parse_mode="HTML", ) return ConversationHandler.END From b56963aa0a761a6f437df995fc0ff963078543f8 Mon Sep 17 00:00:00 2001 From: Yarik Briukhovetskyi Date: Tue, 16 Dec 2025 00:30:17 +0100 Subject: [PATCH 2/2] adaptive learning --- back_tester/README.md | 165 ++++- back_tester/__init__.py | 51 +- back_tester/adaptive_learning.py | 845 ++++++++++++++++++++++++ back_tester/enhanced_metrics.py | 669 +++++++++++++++++++ back_tester/intensive_training.py | 660 ++++++++++++++++++ back_tester/run_learning_backtest.py | 503 ++++++++++++++ back_tester/start_intensive_training.sh | 278 ++++++++ back_tester/start_learning_backtest.sh | 283 ++++++++ back_tester/strategy.py | 154 ++++- back_tester/trainer.py | 80 ++- 10 files changed, 3659 insertions(+), 29 deletions(-) create mode 100644 back_tester/adaptive_learning.py create mode 100644 back_tester/enhanced_metrics.py create mode 100755 back_tester/intensive_training.py create mode 100755 back_tester/run_learning_backtest.py create mode 100755 back_tester/start_intensive_training.sh create mode 100755 back_tester/start_learning_backtest.sh diff --git a/back_tester/README.md b/back_tester/README.md index 58a4d48..359f436 100644 --- a/back_tester/README.md +++ b/back_tester/README.md @@ -257,13 +257,168 @@ cd reports/examples 9. **Track drawdowns carefully** - Maximum drawdown is often more important than returns 10. **Test over multiple time periods** - Market conditions change over time +### 10. Adaptive Learning System (`adaptive_learning.py`) ⭐ NEW + +Self-learning capabilities that automatically adjust signal weights based on performance: +- **Signal Performance Tracking** - Track each signal with its contributing indicators +- **Adaptive Weight Adjustment** - Automatically adjust weights based on what works +- **Indicator Attribution** - Identify which indicators contribute to wins/losses +- **Learning Analytics** - Detailed reports on indicator and regime performance + +```python +from back_tester.adaptive_learning import SelfLearningBacktester + +# Create self-learning backtester +learner = SelfLearningBacktester( + storage_path="./data/learning", + auto_adjust_weights=True, + adjustment_frequency=50 # Adjust weights every 50 signals +) + +# Get adaptive weights (these improve over time) +weights = learner.get_current_weights() + +# Run backtest with learning enabled +final_balance, trades, _ = backtest_strategy( + symbol="BTCUSDT", + interval="1h", + candles=500, + enable_learning=True, + learner=learner, +) + +# View performance analytics +learner.print_status() + +# Get detailed indicator performance +indicator_perf = learner.tracker.get_indicator_performance() +print(indicator_perf) + +# Identify what works +strong = learner.tracker.identify_strong_indicators() +weak = learner.tracker.identify_weak_indicators() +print(f"Strong indicators: {strong}") +print(f"Weak indicators: {weak}") +``` + + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BACKTESTING FLOW │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌───────────────┐ ┌─────────────────┐ │ +│ │ Data Fetch │───▶│Signal Generate│───▶│ Trade Execute │ │ +│ └─────────────┘ └───────────────┘ └─────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌─────────────────┐ │ +│ │ Weights │ │ Record Outcome │ │ +│ │ (18 total) │ └─────────────────┘ │ +│ └──────────────┘ │ │ +│ ▲ │ │ +│ │ ▼ │ +│ ┌───────────────────────────────────┐ │ +│ │ SELF-LEARNING SYSTEM │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ SignalPerformanceTracker │ │ │ +│ │ │ - Track indicator contrib │ │ │ +│ │ │ - Record signal outcomes │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌─────────────────────────────┐ │ │ +│ │ │ AdaptiveWeightAdjuster │ │ │ +│ │ │ - Calculate adjustments │ │ │ +│ │ │ - Apply momentum learning │ │ │ +│ │ └─────────────────────────────┘ │ │ +│ └───────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 11. Enhanced Metrics (`enhanced_metrics.py`) ⭐ NEW + +Comprehensive, informative metrics with indicator-level breakdown: +- **Trade Attribution** - Track which indicators contributed to each trade +- **Regime Analysis** - Performance breakdown by market regime +- **Exit Type Analysis** - Understand where trades close (TP1, TP2, TP3, SL) +- **Actionable Insights** - Get recommendations for improvement + +```python +from back_tester.enhanced_metrics import EnhancedMetricsCalculator + +# Create metrics calculator +calc = EnhancedMetricsCalculator() + +# After running backtests, print detailed report +calc.print_detailed_report(initial_balance=10000, final_balance=10500) + +# Get comprehensive metrics +metrics = calc.calculate_comprehensive_metrics(10000, 10500) +print(f"Indicator Performance: {metrics['indicator_performance']}") +print(f"Regime Performance: {metrics['regime_performance']}") +print(f"Recommendations: {metrics['insights']['recommendations']}") +``` + +## How the Self-Learning System Works + +The self-learning system creates a feedback loop that improves signal quality over time: + +1. **Signal Generation** - When a signal is generated, the system records which indicators contributed to it +2. **Outcome Tracking** - When the trade closes (TP1, TP2, TP3, stop loss, or end of period), the outcome is recorded +3. **Attribution Analysis** - The system analyzes which indicators are associated with winning vs losing trades +4. **Weight Adjustment** - Weights for consistently underperforming indicators are reduced, while weights for strong performers are increased +5. **Continuous Improvement** - Over many trades, the system converges on optimal weights for current market conditions + +### Key Components + +| Component | Description | +|-----------|-------------| +| `SignalPerformanceTracker` | Tracks all signals with their outcomes and indicator contributions | +| `AdaptiveWeightAdjuster` | Calculates and applies weight adjustments based on performance | +| `LearningMetricsAnalyzer` | Generates reports and recommendations | +| `SelfLearningBacktester` | Integrates all components for easy use | + +### Example Workflow + +```python +# Step 1: Initialize the learning system +from back_tester import SelfLearningBacktester, backtest_strategy + +learner = SelfLearningBacktester(auto_adjust_weights=True) + +# Step 2: Run multiple backtests to build data +for symbol in ["BTCUSDT", "ETHUSDT", "BNBUSDT"]: + backtest_strategy( + symbol=symbol, + interval="1h", + enable_learning=True, + learner=learner + ) + +# Step 3: View what the system learned +report = learner.get_performance_report() +print(f"Win Rate: {report['summary']['recent_win_rate']:.1f}%") +print(f"Strong Indicators: {report['indicator_analysis']['strong_indicators']}") +print(f"Weak Indicators: {report['indicator_analysis']['weak_indicators']}") + +# Step 4: Get updated weights for future use +new_weights = learner.get_current_weights() +print(f"Optimized weights: {new_weights}") +``` + ## Next Steps for Improvement -1. Implement machine learning for signal enhancement -2. Add cross-validation for more robust parameter testing -3. Implement portfolio-level backtesting for multiple assets -4. Add regime switching capabilities based on market conditions -5. Develop adaptive strategy selection based on market regime +1. ~~Implement machine learning for signal enhancement~~ ✅ Done +2. ~~Add self-learning weight adjustment~~ ✅ Done +3. ~~Implement indicator performance tracking~~ ✅ Done +4. Add cross-validation for more robust parameter testing +5. Implement portfolio-level backtesting for multiple assets +6. Add regime switching capabilities based on market conditions +7. Develop adaptive strategy selection based on market regime ## Backtesting Launcher Script diff --git a/back_tester/__init__.py b/back_tester/__init__.py index 1d1038b..f787878 100644 --- a/back_tester/__init__.py +++ b/back_tester/__init__.py @@ -2,7 +2,54 @@ Enhanced Backtesting System for Cryptocurrency Trading This package provides comprehensive tools for strategy development, -testing, and optimization for cryptocurrency trading. +testing, optimization, and self-learning for cryptocurrency trading. + +New in v1.1.0: +- Adaptive Learning System: Automatically adjusts signal weights based on performance +- Signal Performance Tracking: Tracks which indicators contribute to winning/losing trades +- Enhanced Metrics: Detailed breakdown by indicator, market regime, and symbol """ -__version__ = "1.0.0" +__version__ = "1.1.0" + +# Core modules +from .strategy import backtest_strategy +from .performance_metrics import calculate_performance_metrics, generate_performance_report + +# Enhanced features +from .enhanced_backtester import EnhancedBacktester, run_enhanced_backtest + +# Self-learning modules +try: + from .adaptive_learning import ( + SelfLearningBacktester, + SignalPerformanceTracker, + AdaptiveWeightAdjuster, + LearningMetricsAnalyzer, + SignalRecord, + SignalOutcome, + extract_indicator_contributions, + ) + from .enhanced_metrics import ( + EnhancedMetricsCalculator, + TradeMetrics, + create_trade_metrics, + ) + LEARNING_AVAILABLE = True +except ImportError: + LEARNING_AVAILABLE = False + +__all__ = [ + # Core + "backtest_strategy", + "calculate_performance_metrics", + "generate_performance_report", + "EnhancedBacktester", + "run_enhanced_backtest", + # Learning (if available) + "SelfLearningBacktester", + "SignalPerformanceTracker", + "AdaptiveWeightAdjuster", + "EnhancedMetricsCalculator", + "LEARNING_AVAILABLE", +] diff --git a/back_tester/adaptive_learning.py b/back_tester/adaptive_learning.py new file mode 100644 index 0000000..2211ae1 --- /dev/null +++ b/back_tester/adaptive_learning.py @@ -0,0 +1,845 @@ +""" +Adaptive Learning Module for Backtesting System + +This module provides self-learning capabilities for the backtesting system: +1. SignalPerformanceTracker - Tracks individual signal outcomes with indicator attribution +2. AdaptiveWeightAdjuster - Adjusts weights based on signal performance +3. IndicatorAttributor - Attributes success/failure to specific indicators +4. LearningMetricsAnalyzer - Provides detailed analytics on what works and what doesn't + +The system learns from failures and automatically adjusts coefficients to improve future signals. +""" + +import os +import sys +import json +import numpy as np +import pandas as pd +from typing import Dict, List, Tuple, Any, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime, timedelta +from collections import defaultdict +import logging +from enum import Enum +import joblib + +logger = logging.getLogger(__name__) + + +class SignalOutcome(Enum): + """Possible outcomes for a trading signal""" + TP1_HIT = "tp1_hit" + TP2_HIT = "tp2_hit" + TP3_HIT = "tp3_hit" + STOP_LOSS = "stop_loss" + TRAILING_STOP = "trailing_stop" + END_OF_PERIOD = "end_of_period" + BREAKEVEN = "breakeven" + PENDING = "pending" + + +@dataclass +class SignalRecord: + """Complete record of a signal with its contributing factors and outcome""" + signal_id: str + timestamp: datetime + symbol: str + interval: str + signal_type: str # Bullish/Bearish + entry_price: float + stop_loss: float + take_profit_1: float + take_profit_2: float + take_profit_3: float + + # Contributing indicators and their scores + indicator_contributions: Dict[str, float] = field(default_factory=dict) + + # Reasons that triggered the signal + reasons: List[str] = field(default_factory=list) + + # Market context at signal time + market_regime: str = "" + volatility: float = 0.0 + volume_ratio: float = 1.0 + rsi: float = 50.0 + trend: str = "" + + # Outcome tracking + outcome: SignalOutcome = SignalOutcome.PENDING + exit_price: float = 0.0 + profit_loss: float = 0.0 + profit_loss_percent: float = 0.0 + duration_candles: int = 0 + max_favorable_excursion: float = 0.0 # Max profit during trade + max_adverse_excursion: float = 0.0 # Max loss during trade + + # Weights used at signal time + weights_at_signal: List[float] = field(default_factory=list) + + def is_successful(self) -> bool: + """Determine if the signal was successful""" + return self.outcome in [SignalOutcome.TP1_HIT, SignalOutcome.TP2_HIT, + SignalOutcome.TP3_HIT, SignalOutcome.TRAILING_STOP] + + def to_dict(self) -> Dict: + """Convert to dictionary for storage""" + data = asdict(self) + data['outcome'] = self.outcome.value + data['timestamp'] = self.timestamp.isoformat() + return data + + @classmethod + def from_dict(cls, data: Dict) -> 'SignalRecord': + """Create from dictionary""" + data = data.copy() + data['outcome'] = SignalOutcome(data['outcome']) + data['timestamp'] = datetime.fromisoformat(data['timestamp']) + return cls(**data) + + +class SignalPerformanceTracker: + """ + Tracks individual signal performance and attributes outcomes to specific indicators. + This enables learning from both successes and failures. + """ + + # Weight names for attribution + WEIGHT_NAMES = [ + "W_BULLISH_OB", "W_BEARISH_OB", "W_BULLISH_BREAKER", "W_BEARISH_BREAKER", + "W_ABOVE_SUPPORT", "W_BELOW_RESISTANCE", "W_FVG_ABOVE", "W_FVG_BELOW", + "W_TREND", "W_SWEEP_HIGHS", "W_SWEEP_LOWS", "W_STRUCTURE_BREAK", + "W_PIN_BAR", "W_ENGULFING", "W_LIQUIDITY_POOL_ABOVE", "W_LIQUIDITY_POOL_BELOW", + "W_LIQUIDITY_POOL_ROUND", "W_RSI_EXTREME" + ] + + def __init__(self, storage_path: str = "./data/signal_history"): + self.storage_path = storage_path + os.makedirs(storage_path, exist_ok=True) + + self.signals: List[SignalRecord] = [] + self.performance_by_indicator: Dict[str, Dict] = defaultdict( + lambda: {"wins": 0, "losses": 0, "total_pnl": 0.0, "count": 0} + ) + self.performance_by_market_regime: Dict[str, Dict] = defaultdict( + lambda: {"wins": 0, "losses": 0, "total_pnl": 0.0, "count": 0} + ) + self.performance_by_symbol: Dict[str, Dict] = defaultdict( + lambda: {"wins": 0, "losses": 0, "total_pnl": 0.0, "count": 0} + ) + + self._load_history() + + def _load_history(self): + """Load signal history from storage""" + history_file = os.path.join(self.storage_path, "signal_history.json") + if os.path.exists(history_file): + try: + with open(history_file, 'r') as f: + data = json.load(f) + self.signals = [SignalRecord.from_dict(s) for s in data.get('signals', [])] + self._rebuild_performance_stats() + logger.info(f"Loaded {len(self.signals)} historical signals") + except Exception as e: + logger.warning(f"Failed to load signal history: {e}") + + def _save_history(self): + """Save signal history to storage""" + history_file = os.path.join(self.storage_path, "signal_history.json") + try: + with open(history_file, 'w') as f: + json.dump({ + 'signals': [s.to_dict() for s in self.signals[-10000:]], # Keep last 10k + 'last_updated': datetime.now().isoformat() + }, f, indent=2) + except Exception as e: + logger.error(f"Failed to save signal history: {e}") + + def _rebuild_performance_stats(self): + """Rebuild performance statistics from loaded signals""" + for signal in self.signals: + if signal.outcome != SignalOutcome.PENDING: + self._update_stats(signal) + + def _update_stats(self, signal: SignalRecord): + """Update performance statistics for a completed signal""" + is_win = signal.is_successful() + + # Update by indicator + for indicator, contribution in signal.indicator_contributions.items(): + if contribution > 0: + stats = self.performance_by_indicator[indicator] + stats["count"] += 1 + stats["total_pnl"] += signal.profit_loss + if is_win: + stats["wins"] += 1 + else: + stats["losses"] += 1 + + # Update by market regime + regime_stats = self.performance_by_market_regime[signal.market_regime] + regime_stats["count"] += 1 + regime_stats["total_pnl"] += signal.profit_loss + if is_win: + regime_stats["wins"] += 1 + else: + regime_stats["losses"] += 1 + + # Update by symbol + symbol_stats = self.performance_by_symbol[signal.symbol] + symbol_stats["count"] += 1 + symbol_stats["total_pnl"] += signal.profit_loss + if is_win: + symbol_stats["wins"] += 1 + else: + symbol_stats["losses"] += 1 + + def record_signal(self, + signal_id: str, + symbol: str, + interval: str, + signal_type: str, + entry_price: float, + stop_loss: float, + take_profit_1: float, + take_profit_2: float, + take_profit_3: float, + indicator_contributions: Dict[str, float], + reasons: List[str], + market_context: Dict[str, Any], + weights: List[float]) -> SignalRecord: + """Record a new signal with its contributing factors""" + + signal = SignalRecord( + signal_id=signal_id, + timestamp=datetime.now(), + symbol=symbol, + interval=interval, + signal_type=signal_type, + entry_price=entry_price, + stop_loss=stop_loss, + take_profit_1=take_profit_1, + take_profit_2=take_profit_2, + take_profit_3=take_profit_3, + indicator_contributions=indicator_contributions, + reasons=reasons, + market_regime=market_context.get("market_regime", "unknown"), + volatility=market_context.get("volatility", 0.0), + volume_ratio=market_context.get("volume_ratio", 1.0), + rsi=market_context.get("rsi", 50.0), + trend=market_context.get("trend", ""), + weights_at_signal=weights.copy() if weights else [] + ) + + self.signals.append(signal) + return signal + + def update_signal_outcome(self, + signal_id: str, + outcome: SignalOutcome, + exit_price: float, + profit_loss: float, + duration_candles: int, + max_favorable: float = 0.0, + max_adverse: float = 0.0): + """Update a signal with its outcome""" + + for signal in reversed(self.signals): # Search from most recent + if signal.signal_id == signal_id: + signal.outcome = outcome + signal.exit_price = exit_price + signal.profit_loss = profit_loss + signal.profit_loss_percent = (exit_price - signal.entry_price) / signal.entry_price * 100 + signal.duration_candles = duration_candles + signal.max_favorable_excursion = max_favorable + signal.max_adverse_excursion = max_adverse + + self._update_stats(signal) + self._save_history() + + logger.info(f"Signal {signal_id} outcome: {outcome.value}, P/L: {profit_loss:.2f}") + return signal + + logger.warning(f"Signal {signal_id} not found for outcome update") + return None + + def get_indicator_performance(self) -> pd.DataFrame: + """Get performance breakdown by indicator""" + data = [] + for indicator, stats in self.performance_by_indicator.items(): + if stats["count"] > 0: + win_rate = stats["wins"] / stats["count"] * 100 + avg_pnl = stats["total_pnl"] / stats["count"] + data.append({ + "indicator": indicator, + "signals": stats["count"], + "wins": stats["wins"], + "losses": stats["losses"], + "win_rate": win_rate, + "total_pnl": stats["total_pnl"], + "avg_pnl": avg_pnl, + "score": win_rate * 0.4 + (avg_pnl / 100) * 0.6 if avg_pnl else win_rate + }) + + df = pd.DataFrame(data) + if not df.empty: + df = df.sort_values("score", ascending=False) + return df + + def get_regime_performance(self) -> pd.DataFrame: + """Get performance breakdown by market regime""" + data = [] + for regime, stats in self.performance_by_market_regime.items(): + if stats["count"] > 0: + win_rate = stats["wins"] / stats["count"] * 100 + data.append({ + "regime": regime, + "signals": stats["count"], + "win_rate": win_rate, + "total_pnl": stats["total_pnl"], + "avg_pnl": stats["total_pnl"] / stats["count"] + }) + return pd.DataFrame(data) + + def get_recent_performance(self, n: int = 100) -> Dict[str, Any]: + """Get performance of recent N signals""" + recent = [s for s in self.signals[-n:] if s.outcome != SignalOutcome.PENDING] + + if not recent: + return {"signals": 0, "win_rate": 0, "avg_pnl": 0} + + wins = sum(1 for s in recent if s.is_successful()) + total_pnl = sum(s.profit_loss for s in recent) + + return { + "signals": len(recent), + "wins": wins, + "losses": len(recent) - wins, + "win_rate": wins / len(recent) * 100, + "total_pnl": total_pnl, + "avg_pnl": total_pnl / len(recent), + "avg_duration": sum(s.duration_candles for s in recent) / len(recent) + } + + def identify_weak_indicators(self, min_signals: int = 10, + win_rate_threshold: float = 40.0) -> List[str]: + """Identify indicators that consistently underperform""" + weak = [] + for indicator, stats in self.performance_by_indicator.items(): + if stats["count"] >= min_signals: + win_rate = stats["wins"] / stats["count"] * 100 + if win_rate < win_rate_threshold: + weak.append(indicator) + return weak + + def identify_strong_indicators(self, min_signals: int = 10, + win_rate_threshold: float = 60.0) -> List[str]: + """Identify indicators that consistently outperform""" + strong = [] + for indicator, stats in self.performance_by_indicator.items(): + if stats["count"] >= min_signals: + win_rate = stats["wins"] / stats["count"] * 100 + if win_rate >= win_rate_threshold: + strong.append(indicator) + return strong + + +class AdaptiveWeightAdjuster: + """ + Adjusts signal weights based on historical performance. + Uses online learning to continuously improve signal quality. + """ + + def __init__(self, + performance_tracker: SignalPerformanceTracker, + learning_rate: float = 0.05, + momentum: float = 0.9, + min_weight: float = 0.1, + max_weight: float = 3.0, + decay_factor: float = 0.95, + storage_path: str = "./data/weights"): + + self.tracker = performance_tracker + self.learning_rate = learning_rate + self.momentum = momentum + self.min_weight = min_weight + self.max_weight = max_weight + self.decay_factor = decay_factor # Decay old performance influence + self.storage_path = storage_path + os.makedirs(storage_path, exist_ok=True) + + # Weight velocity for momentum-based updates + self.velocity = defaultdict(float) + + # Historical weight adjustments + self.adjustment_history: List[Dict] = [] + + # Load last known weights + self.current_weights = self._load_weights() + + def _load_weights(self) -> Dict[str, float]: + """Load current weights from storage""" + weights_file = os.path.join(self.storage_path, "adaptive_weights.json") + default_weights = {name: 1.0 for name in SignalPerformanceTracker.WEIGHT_NAMES} + + if os.path.exists(weights_file): + try: + with open(weights_file, 'r') as f: + data = json.load(f) + return data.get('weights', default_weights) + except: + pass + return default_weights + + def _save_weights(self): + """Save current weights to storage""" + weights_file = os.path.join(self.storage_path, "adaptive_weights.json") + with open(weights_file, 'w') as f: + json.dump({ + 'weights': self.current_weights, + 'last_updated': datetime.now().isoformat(), + 'learning_rate': self.learning_rate, + 'adjustment_count': len(self.adjustment_history) + }, f, indent=2) + + def calculate_weight_adjustments(self, + recent_window: int = 100, + min_contribution_threshold: float = 0.1) -> Dict[str, float]: + """ + Calculate weight adjustments based on recent signal performance. + + For each indicator: + - If win rate > 60%: increase weight + - If win rate < 40%: decrease weight + - Scale adjustment by confidence (number of signals) + """ + adjustments = {} + indicator_perf = self.tracker.get_indicator_performance() + + if indicator_perf.empty: + return adjustments + + for _, row in indicator_perf.iterrows(): + indicator = row['indicator'] + if indicator not in self.current_weights: + continue + + signals = row['signals'] + win_rate = row['win_rate'] + avg_pnl = row['avg_pnl'] + + # Calculate confidence based on sample size + confidence = min(1.0, signals / 50) # Full confidence at 50 signals + + # Calculate adjustment direction and magnitude + if win_rate > 60: + # Positive adjustment - increase weight + magnitude = (win_rate - 50) / 100 * confidence + adjustment = magnitude * self.learning_rate + elif win_rate < 40: + # Negative adjustment - decrease weight + magnitude = (50 - win_rate) / 100 * confidence + adjustment = -magnitude * self.learning_rate + else: + adjustment = 0 + + # Factor in P/L performance + if avg_pnl != 0: + pnl_factor = np.clip(avg_pnl / 100, -0.5, 0.5) + adjustment += pnl_factor * 0.5 * self.learning_rate * confidence + + # Apply momentum + self.velocity[indicator] = (self.momentum * self.velocity[indicator] + + (1 - self.momentum) * adjustment) + adjustments[indicator] = self.velocity[indicator] + + return adjustments + + def apply_adjustments(self, + adjustments: Optional[Dict[str, float]] = None, + force: bool = False) -> Dict[str, float]: + """ + Apply calculated adjustments to current weights. + + Args: + adjustments: Optional pre-calculated adjustments + force: If True, apply even small adjustments + + Returns: + Dictionary of new weights + """ + if adjustments is None: + adjustments = self.calculate_weight_adjustments() + + if not adjustments: + return self.current_weights + + old_weights = self.current_weights.copy() + changes_made = False + + for indicator, adjustment in adjustments.items(): + if indicator not in self.current_weights: + continue + + # Only apply significant adjustments unless forced + if not force and abs(adjustment) < 0.01: + continue + + old_weight = self.current_weights[indicator] + new_weight = old_weight + adjustment + + # Clamp to valid range + new_weight = max(self.min_weight, min(self.max_weight, new_weight)) + + if new_weight != old_weight: + self.current_weights[indicator] = new_weight + changes_made = True + logger.info(f"Weight adjusted: {indicator} {old_weight:.3f} -> {new_weight:.3f}") + + if changes_made: + self.adjustment_history.append({ + 'timestamp': datetime.now().isoformat(), + 'old_weights': old_weights, + 'new_weights': self.current_weights.copy(), + 'adjustments': adjustments + }) + self._save_weights() + + return self.current_weights + + def get_weights_as_list(self) -> List[float]: + """Convert weights dictionary to list format for backtesting""" + return [self.current_weights.get(name, 1.0) + for name in SignalPerformanceTracker.WEIGHT_NAMES] + + def reset_weights(self): + """Reset all weights to default values""" + self.current_weights = {name: 1.0 for name in SignalPerformanceTracker.WEIGHT_NAMES} + self.velocity = defaultdict(float) + self._save_weights() + logger.info("Weights reset to defaults") + + def get_adjustment_summary(self) -> Dict[str, Any]: + """Get summary of recent weight adjustments""" + if not self.adjustment_history: + return {"adjustments": 0, "last_adjustment": None} + + recent = self.adjustment_history[-10:] + return { + "total_adjustments": len(self.adjustment_history), + "recent_adjustments": len(recent), + "last_adjustment": recent[-1]['timestamp'] if recent else None, + "current_weights": self.current_weights + } + + +class LearningMetricsAnalyzer: + """ + Analyzes learning metrics and provides actionable insights + for improving signal quality. + """ + + def __init__(self, + tracker: SignalPerformanceTracker, + adjuster: AdaptiveWeightAdjuster): + self.tracker = tracker + self.adjuster = adjuster + + def generate_learning_report(self) -> Dict[str, Any]: + """Generate comprehensive learning report""" + report = { + "timestamp": datetime.now().isoformat(), + "summary": {}, + "indicator_analysis": {}, + "regime_analysis": {}, + "recommendations": [] + } + + # Overall summary + recent_perf = self.tracker.get_recent_performance(100) + report["summary"] = { + "total_signals_tracked": len(self.tracker.signals), + "recent_win_rate": recent_perf.get("win_rate", 0), + "recent_avg_pnl": recent_perf.get("avg_pnl", 0), + "weight_adjustments": len(self.adjuster.adjustment_history) + } + + # Indicator analysis + indicator_df = self.tracker.get_indicator_performance() + if not indicator_df.empty: + report["indicator_analysis"] = { + "best_performers": indicator_df.head(5).to_dict('records'), + "worst_performers": indicator_df.tail(5).to_dict('records'), + "weak_indicators": self.tracker.identify_weak_indicators(), + "strong_indicators": self.tracker.identify_strong_indicators() + } + + # Regime analysis + regime_df = self.tracker.get_regime_performance() + if not regime_df.empty: + report["regime_analysis"] = regime_df.to_dict('records') + + # Generate recommendations + report["recommendations"] = self._generate_recommendations() + + return report + + def _generate_recommendations(self) -> List[str]: + """Generate actionable recommendations based on performance data""" + recommendations = [] + + # Check for weak indicators + weak = self.tracker.identify_weak_indicators() + if weak: + recommendations.append( + f"Consider reducing weights for underperforming indicators: {', '.join(weak)}" + ) + + # Check for strong indicators + strong = self.tracker.identify_strong_indicators() + if strong: + recommendations.append( + f"High-performing indicators (consider increasing weight): {', '.join(strong)}" + ) + + # Check recent performance trend + recent_perf = self.tracker.get_recent_performance(50) + older_perf = self.tracker.get_recent_performance(200) + + if recent_perf.get("win_rate", 0) < older_perf.get("win_rate", 0) - 10: + recommendations.append( + "Recent performance declining - consider resetting weights or retraining" + ) + + # Regime-specific recommendations + regime_df = self.tracker.get_regime_performance() + if not regime_df.empty: + worst_regime = regime_df.loc[regime_df['win_rate'].idxmin()] + if worst_regime['win_rate'] < 40 and worst_regime['signals'] > 20: + recommendations.append( + f"Poor performance in '{worst_regime['regime']}' regime " + f"({worst_regime['win_rate']:.1f}% win rate) - consider filtering signals" + ) + + return recommendations + + def print_report(self): + """Print formatted learning report to console""" + report = self.generate_learning_report() + + print("\n" + "="*70) + print("📊 ADAPTIVE LEARNING REPORT") + print("="*70) + + print(f"\n📈 Summary:") + print(f" Total signals tracked: {report['summary']['total_signals_tracked']}") + print(f" Recent win rate: {report['summary']['recent_win_rate']:.1f}%") + print(f" Recent avg P/L: ${report['summary']['recent_avg_pnl']:.2f}") + print(f" Weight adjustments made: {report['summary']['weight_adjustments']}") + + if report.get('indicator_analysis', {}).get('best_performers'): + print(f"\n🟢 Top Performing Indicators:") + for ind in report['indicator_analysis']['best_performers'][:3]: + print(f" {ind['indicator']}: {ind['win_rate']:.1f}% win rate, " + f"${ind['avg_pnl']:.2f} avg P/L ({ind['signals']} signals)") + + if report.get('indicator_analysis', {}).get('weak_indicators'): + print(f"\n🔴 Underperforming Indicators:") + for ind in report['indicator_analysis']['weak_indicators']: + print(f" {ind}") + + if report.get('recommendations'): + print(f"\n💡 Recommendations:") + for rec in report['recommendations']: + print(f" • {rec}") + + print("\n" + "="*70) + + +class SelfLearningBacktester: + """ + Enhanced backtester with self-learning capabilities. + Integrates signal tracking, weight adjustment, and performance analysis. + """ + + def __init__(self, + storage_path: str = "./data/learning", + auto_adjust_weights: bool = True, + adjustment_frequency: int = 50): # Adjust every N signals + + self.storage_path = storage_path + os.makedirs(storage_path, exist_ok=True) + + self.tracker = SignalPerformanceTracker( + storage_path=os.path.join(storage_path, "signals") + ) + self.adjuster = AdaptiveWeightAdjuster( + performance_tracker=self.tracker, + storage_path=os.path.join(storage_path, "weights") + ) + self.analyzer = LearningMetricsAnalyzer(self.tracker, self.adjuster) + + self.auto_adjust = auto_adjust_weights + self.adjustment_frequency = adjustment_frequency + self.signals_since_adjustment = 0 + + def get_current_weights(self) -> List[float]: + """Get current adaptive weights as list""" + return self.adjuster.get_weights_as_list() + + def record_signal_entry(self, + signal_id: str, + symbol: str, + interval: str, + signal_type: str, + entry_price: float, + stop_loss: float, + tp1: float, tp2: float, tp3: float, + indicator_contributions: Dict[str, float], + reasons: List[str], + market_context: Dict[str, Any]) -> SignalRecord: + """Record a new signal entry""" + + signal = self.tracker.record_signal( + signal_id=signal_id, + symbol=symbol, + interval=interval, + signal_type=signal_type, + entry_price=entry_price, + stop_loss=stop_loss, + take_profit_1=tp1, + take_profit_2=tp2, + take_profit_3=tp3, + indicator_contributions=indicator_contributions, + reasons=reasons, + market_context=market_context, + weights=self.get_current_weights() + ) + + return signal + + def record_signal_exit(self, + signal_id: str, + outcome: str, # "tp1", "tp2", "tp3", "stop_loss", "trailing_stop", "end" + exit_price: float, + profit_loss: float, + duration: int): + """Record signal exit and trigger learning if needed""" + + # Map outcome string to enum + outcome_map = { + "tp1": SignalOutcome.TP1_HIT, + "tp2": SignalOutcome.TP2_HIT, + "tp3": SignalOutcome.TP3_HIT, + "stop_loss": SignalOutcome.STOP_LOSS, + "trailing_stop": SignalOutcome.TRAILING_STOP, + "end": SignalOutcome.END_OF_PERIOD + } + outcome_enum = outcome_map.get(outcome, SignalOutcome.END_OF_PERIOD) + + self.tracker.update_signal_outcome( + signal_id=signal_id, + outcome=outcome_enum, + exit_price=exit_price, + profit_loss=profit_loss, + duration_candles=duration + ) + + self.signals_since_adjustment += 1 + + # Auto-adjust weights if enabled and threshold reached + if self.auto_adjust and self.signals_since_adjustment >= self.adjustment_frequency: + self._trigger_learning() + self.signals_since_adjustment = 0 + + def _trigger_learning(self): + """Trigger weight adjustment based on recent performance""" + logger.info("Triggering adaptive weight adjustment...") + + adjustments = self.adjuster.calculate_weight_adjustments() + if adjustments: + self.adjuster.apply_adjustments(adjustments) + logger.info(f"Applied {len(adjustments)} weight adjustments") + + def force_learning(self): + """Force immediate weight adjustment""" + self._trigger_learning() + self.signals_since_adjustment = 0 + + def get_performance_report(self) -> Dict[str, Any]: + """Get comprehensive performance report""" + return self.analyzer.generate_learning_report() + + def print_status(self): + """Print current learning status""" + self.analyzer.print_report() + + def save_state(self): + """Save all learning state""" + self.tracker._save_history() + self.adjuster._save_weights() + logger.info("Learning state saved") + + +# Helper function to extract indicator contributions from signal generation +def extract_indicator_contributions( + bullish_score: float, + bearish_score: float, + signal_type: str, + reasons: List[str] +) -> Dict[str, float]: + """ + Extract which indicators contributed to the signal. + This parses the reasons list to determine contributions. + """ + contributions = {} + + # Parse reasons to identify contributing indicators + indicator_keywords = { + "order block": ("W_BULLISH_OB" if signal_type == "Bullish" else "W_BEARISH_OB"), + "breaker block": ("W_BULLISH_BREAKER" if signal_type == "Bullish" else "W_BEARISH_BREAKER"), + "support": "W_ABOVE_SUPPORT", + "resistance": "W_BELOW_RESISTANCE", + "FVG above": "W_FVG_ABOVE", + "FVG below": "W_FVG_BELOW", + "uptrend": "W_TREND", + "downtrend": "W_TREND", + "swept through previous highs": "W_SWEEP_HIGHS", + "swept through previous lows": "W_SWEEP_LOWS", + "broke structure": "W_STRUCTURE_BREAK", + "pin bar": "W_PIN_BAR", + "engulfing": "W_ENGULFING", + "liquidity pool": "W_LIQUIDITY_POOL_ABOVE", + "round number": "W_LIQUIDITY_POOL_ROUND", + "RSI oversold": "W_RSI_EXTREME", + "RSI overbought": "W_RSI_EXTREME" + } + + for reason in reasons: + reason_lower = reason.lower() + for keyword, indicator in indicator_keywords.items(): + if keyword.lower() in reason_lower: + # Score is proportional to contribution to final signal + total_score = bullish_score + bearish_score if bullish_score + bearish_score > 0 else 1 + score = bullish_score if signal_type == "Bullish" else bearish_score + contributions[indicator] = score / total_score + + return contributions + + +# Usage example +if __name__ == "__main__": + # Initialize self-learning backtester + learner = SelfLearningBacktester( + storage_path="./data/learning", + auto_adjust_weights=True, + adjustment_frequency=50 + ) + + # Get current adaptive weights for signal generation + weights = learner.get_current_weights() + print(f"Current weights: {weights}") + + # Print performance status + learner.print_status() + diff --git a/back_tester/enhanced_metrics.py b/back_tester/enhanced_metrics.py new file mode 100644 index 0000000..d37003b --- /dev/null +++ b/back_tester/enhanced_metrics.py @@ -0,0 +1,669 @@ +""" +Enhanced Metrics Module for Backtesting System + +Provides comprehensive, informative metrics including: +1. Indicator-level performance breakdown +2. Market regime analysis +3. Signal quality scoring +4. Trade attribution and root cause analysis +5. Learning suggestions based on performance patterns +""" + +import numpy as np +import pandas as pd +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass, field +from datetime import datetime +from collections import defaultdict +import json +import os + + +@dataclass +class TradeMetrics: + """Detailed metrics for a single trade""" + trade_id: str + symbol: str + interval: str + signal_type: str + entry_price: float + exit_price: float + stop_loss: float + take_profit_targets: List[float] + + # Outcome + outcome: str # tp1, tp2, tp3, stop_loss, trailing_stop, end_of_period + profit_loss: float + profit_loss_percent: float + risk_reward_achieved: float + + # Duration + entry_index: int + exit_index: int + duration_candles: int + + # Market context + market_regime: str = "" + volatility: float = 0.0 + volume_ratio: float = 1.0 + trend: str = "" + + # Indicator contributions + contributing_indicators: List[str] = field(default_factory=list) + indicator_scores: Dict[str, float] = field(default_factory=dict) + + # Trade quality metrics + max_favorable_excursion: float = 0.0 # Best profit during trade + max_adverse_excursion: float = 0.0 # Worst drawdown during trade + r_multiple: float = 0.0 # Actual R achieved + + def is_winner(self) -> bool: + return self.outcome in ['tp1', 'tp2', 'tp3', 'trailing_stop'] or self.profit_loss > 0 + + def is_full_target(self) -> bool: + return self.outcome == 'tp3' + + +class EnhancedMetricsCalculator: + """ + Calculates and aggregates enhanced trading metrics with + detailed breakdowns for learning and optimization. + """ + + def __init__(self): + self.trades: List[TradeMetrics] = [] + self.indicator_performance: Dict[str, Dict] = defaultdict( + lambda: { + 'wins': 0, 'losses': 0, 'total_pnl': 0.0, + 'avg_r': 0.0, 'r_values': [], + 'tp1_hits': 0, 'tp2_hits': 0, 'tp3_hits': 0, 'stop_hits': 0 + } + ) + self.regime_performance: Dict[str, Dict] = defaultdict( + lambda: {'wins': 0, 'losses': 0, 'total_pnl': 0.0, 'trades': 0} + ) + self.symbol_performance: Dict[str, Dict] = defaultdict( + lambda: {'wins': 0, 'losses': 0, 'total_pnl': 0.0, 'trades': 0} + ) + self.interval_performance: Dict[str, Dict] = defaultdict( + lambda: {'wins': 0, 'losses': 0, 'total_pnl': 0.0, 'trades': 0} + ) + + def add_trade(self, trade: TradeMetrics): + """Add a completed trade to metrics""" + self.trades.append(trade) + self._update_indicator_stats(trade) + self._update_regime_stats(trade) + self._update_symbol_stats(trade) + self._update_interval_stats(trade) + + def _update_indicator_stats(self, trade: TradeMetrics): + """Update indicator-level statistics""" + is_win = trade.is_winner() + + for indicator in trade.contributing_indicators: + stats = self.indicator_performance[indicator] + if is_win: + stats['wins'] += 1 + else: + stats['losses'] += 1 + + stats['total_pnl'] += trade.profit_loss + stats['r_values'].append(trade.r_multiple) + + # Track specific outcomes + if trade.outcome == 'tp1': + stats['tp1_hits'] += 1 + elif trade.outcome == 'tp2': + stats['tp2_hits'] += 1 + elif trade.outcome == 'tp3': + stats['tp3_hits'] += 1 + elif trade.outcome in ['stop_loss', 'trailing_stop']: + stats['stop_hits'] += 1 + + def _update_regime_stats(self, trade: TradeMetrics): + """Update market regime statistics""" + stats = self.regime_performance[trade.market_regime or 'unknown'] + stats['trades'] += 1 + stats['total_pnl'] += trade.profit_loss + if trade.is_winner(): + stats['wins'] += 1 + else: + stats['losses'] += 1 + + def _update_symbol_stats(self, trade: TradeMetrics): + """Update symbol-level statistics""" + stats = self.symbol_performance[trade.symbol] + stats['trades'] += 1 + stats['total_pnl'] += trade.profit_loss + if trade.is_winner(): + stats['wins'] += 1 + else: + stats['losses'] += 1 + + def _update_interval_stats(self, trade: TradeMetrics): + """Update interval/timeframe statistics""" + stats = self.interval_performance[trade.interval] + stats['trades'] += 1 + stats['total_pnl'] += trade.profit_loss + if trade.is_winner(): + stats['wins'] += 1 + else: + stats['losses'] += 1 + + def calculate_comprehensive_metrics(self, + initial_balance: float, + final_balance: float) -> Dict[str, Any]: + """Calculate comprehensive performance metrics with full breakdown""" + + if not self.trades: + return self._empty_metrics() + + # Basic metrics + total_trades = len(self.trades) + winners = [t for t in self.trades if t.is_winner()] + losers = [t for t in self.trades if not t.is_winner()] + + win_rate = len(winners) / total_trades * 100 if total_trades > 0 else 0 + + # P/L calculations + total_profit = sum(t.profit_loss for t in winners) + total_loss = sum(t.profit_loss for t in losers) + net_profit = total_profit + total_loss + + # Profit factor + profit_factor = abs(total_profit / total_loss) if total_loss != 0 else float('inf') + + # R-multiple statistics + r_multiples = [t.r_multiple for t in self.trades if t.r_multiple != 0] + avg_r = np.mean(r_multiples) if r_multiples else 0 + + # Drawdown calculation + equity_curve = self._calculate_equity_curve(initial_balance) + max_drawdown = self._calculate_max_drawdown(equity_curve) + + # Expectancy + avg_win = np.mean([t.profit_loss for t in winners]) if winners else 0 + avg_loss = np.mean([t.profit_loss for t in losers]) if losers else 0 + expectancy = (win_rate/100 * avg_win) + ((1 - win_rate/100) * avg_loss) + + # Exit type breakdown + exit_breakdown = self._calculate_exit_breakdown() + + # Indicator performance breakdown + indicator_breakdown = self._calculate_indicator_breakdown() + + # Regime performance breakdown + regime_breakdown = self._calculate_regime_breakdown() + + # Symbol performance breakdown + symbol_breakdown = self._calculate_symbol_breakdown() + + # Generate insights and recommendations + insights = self._generate_insights() + + return { + # Summary + 'summary': { + 'total_trades': total_trades, + 'winners': len(winners), + 'losers': len(losers), + 'win_rate': win_rate, + 'profit_factor': profit_factor, + 'net_profit': net_profit, + 'total_return_pct': (final_balance - initial_balance) / initial_balance * 100, + 'max_drawdown_pct': max_drawdown, + 'expectancy': expectancy, + 'avg_r_multiple': avg_r + }, + + # Detailed trade statistics + 'trade_stats': { + 'avg_winner': avg_win, + 'avg_loser': avg_loss, + 'largest_winner': max((t.profit_loss for t in winners), default=0), + 'largest_loser': min((t.profit_loss for t in losers), default=0), + 'avg_duration_candles': np.mean([t.duration_candles for t in self.trades]), + 'avg_mfe': np.mean([t.max_favorable_excursion for t in self.trades]), + 'avg_mae': np.mean([t.max_adverse_excursion for t in self.trades]) + }, + + # Exit type breakdown + 'exit_breakdown': exit_breakdown, + + # Indicator performance + 'indicator_performance': indicator_breakdown, + + # Market regime performance + 'regime_performance': regime_breakdown, + + # Symbol performance + 'symbol_performance': symbol_breakdown, + + # Timeframe performance + 'interval_performance': self._calculate_interval_breakdown(), + + # Learning insights + 'insights': insights, + + # Equity curve for visualization + 'equity_curve': equity_curve + } + + def _empty_metrics(self) -> Dict[str, Any]: + """Return empty metrics structure""" + return { + 'summary': { + 'total_trades': 0, 'win_rate': 0, 'profit_factor': 0, + 'net_profit': 0, 'max_drawdown_pct': 0, 'expectancy': 0 + }, + 'trade_stats': {}, + 'exit_breakdown': {}, + 'indicator_performance': {}, + 'regime_performance': {}, + 'symbol_performance': {}, + 'interval_performance': {}, + 'insights': {'recommendations': ['Insufficient data for analysis']}, + 'equity_curve': [] + } + + def _calculate_equity_curve(self, initial_balance: float) -> List[float]: + """Calculate cumulative equity curve""" + equity = [initial_balance] + for trade in self.trades: + equity.append(equity[-1] + trade.profit_loss) + return equity + + def _calculate_max_drawdown(self, equity_curve: List[float]) -> float: + """Calculate maximum drawdown percentage""" + if not equity_curve: + return 0 + + peak = equity_curve[0] + max_dd = 0 + + for equity in equity_curve: + if equity > peak: + peak = equity + dd = (peak - equity) / peak * 100 if peak > 0 else 0 + max_dd = max(max_dd, dd) + + return max_dd + + def _calculate_exit_breakdown(self) -> Dict[str, Dict]: + """Calculate breakdown by exit type""" + breakdown = defaultdict(lambda: {'count': 0, 'total_pnl': 0.0, 'avg_pnl': 0.0}) + + for trade in self.trades: + stats = breakdown[trade.outcome] + stats['count'] += 1 + stats['total_pnl'] += trade.profit_loss + + for outcome, stats in breakdown.items(): + if stats['count'] > 0: + stats['avg_pnl'] = stats['total_pnl'] / stats['count'] + stats['percentage'] = stats['count'] / len(self.trades) * 100 + + return dict(breakdown) + + def _calculate_indicator_breakdown(self) -> Dict[str, Dict]: + """Calculate detailed indicator performance breakdown""" + breakdown = {} + + for indicator, stats in self.indicator_performance.items(): + total = stats['wins'] + stats['losses'] + if total == 0: + continue + + win_rate = stats['wins'] / total * 100 + avg_r = np.mean(stats['r_values']) if stats['r_values'] else 0 + + # Calculate score for ranking + # Score = win_rate * 0.4 + normalized_pnl * 0.3 + avg_r * 0.3 + pnl_score = min(1.0, max(-1.0, stats['total_pnl'] / 1000)) # Normalize to -1 to 1 + r_score = min(1.0, max(-1.0, avg_r / 3)) # Normalize R to -1 to 1 + score = win_rate / 100 * 0.4 + (pnl_score + 1) / 2 * 0.3 + (r_score + 1) / 2 * 0.3 + + breakdown[indicator] = { + 'total_signals': total, + 'wins': stats['wins'], + 'losses': stats['losses'], + 'win_rate': win_rate, + 'total_pnl': stats['total_pnl'], + 'avg_pnl': stats['total_pnl'] / total, + 'avg_r': avg_r, + 'tp1_hits': stats['tp1_hits'], + 'tp2_hits': stats['tp2_hits'], + 'tp3_hits': stats['tp3_hits'], + 'stop_hits': stats['stop_hits'], + 'score': score, + 'status': self._get_indicator_status(win_rate, stats['total_pnl']) + } + + # Sort by score + return dict(sorted(breakdown.items(), key=lambda x: x[1]['score'], reverse=True)) + + def _get_indicator_status(self, win_rate: float, total_pnl: float) -> str: + """Get indicator health status""" + if win_rate >= 60 and total_pnl > 0: + return 'excellent' + elif win_rate >= 50 and total_pnl >= 0: + return 'good' + elif win_rate >= 40: + return 'average' + else: + return 'poor' + + def _calculate_regime_breakdown(self) -> Dict[str, Dict]: + """Calculate performance by market regime""" + breakdown = {} + + for regime, stats in self.regime_performance.items(): + if stats['trades'] == 0: + continue + + win_rate = stats['wins'] / stats['trades'] * 100 + breakdown[regime] = { + 'trades': stats['trades'], + 'wins': stats['wins'], + 'losses': stats['losses'], + 'win_rate': win_rate, + 'total_pnl': stats['total_pnl'], + 'avg_pnl': stats['total_pnl'] / stats['trades'], + 'recommendation': self._get_regime_recommendation(regime, win_rate, stats['total_pnl']) + } + + return breakdown + + def _get_regime_recommendation(self, regime: str, win_rate: float, pnl: float) -> str: + """Get recommendation for trading in specific regime""" + if win_rate >= 55 and pnl > 0: + return f"Good performance in {regime} - continue trading" + elif win_rate < 40 or pnl < 0: + return f"Poor performance in {regime} - consider filtering signals or reducing position size" + else: + return f"Average performance in {regime} - monitor closely" + + def _calculate_symbol_breakdown(self) -> Dict[str, Dict]: + """Calculate performance by symbol""" + breakdown = {} + + for symbol, stats in self.symbol_performance.items(): + if stats['trades'] == 0: + continue + + win_rate = stats['wins'] / stats['trades'] * 100 + breakdown[symbol] = { + 'trades': stats['trades'], + 'wins': stats['wins'], + 'losses': stats['losses'], + 'win_rate': win_rate, + 'total_pnl': stats['total_pnl'], + 'avg_pnl': stats['total_pnl'] / stats['trades'] + } + + return dict(sorted(breakdown.items(), key=lambda x: x[1]['total_pnl'], reverse=True)) + + def _calculate_interval_breakdown(self) -> Dict[str, Dict]: + """Calculate performance by timeframe""" + breakdown = {} + + for interval, stats in self.interval_performance.items(): + if stats['trades'] == 0: + continue + + win_rate = stats['wins'] / stats['trades'] * 100 + breakdown[interval] = { + 'trades': stats['trades'], + 'wins': stats['wins'], + 'losses': stats['losses'], + 'win_rate': win_rate, + 'total_pnl': stats['total_pnl'], + 'avg_pnl': stats['total_pnl'] / stats['trades'] + } + + return breakdown + + def _generate_insights(self) -> Dict[str, Any]: + """Generate actionable insights from performance data""" + insights = { + 'strong_indicators': [], + 'weak_indicators': [], + 'best_regimes': [], + 'worst_regimes': [], + 'recommendations': [] + } + + # Indicator insights + for indicator, stats in self._calculate_indicator_breakdown().items(): + if stats['total_signals'] >= 10: # Minimum sample size + if stats['status'] == 'excellent': + insights['strong_indicators'].append({ + 'name': indicator, + 'win_rate': stats['win_rate'], + 'avg_pnl': stats['avg_pnl'] + }) + elif stats['status'] == 'poor': + insights['weak_indicators'].append({ + 'name': indicator, + 'win_rate': stats['win_rate'], + 'avg_pnl': stats['avg_pnl'] + }) + + # Regime insights + regime_breakdown = self._calculate_regime_breakdown() + sorted_regimes = sorted(regime_breakdown.items(), + key=lambda x: x[1]['win_rate'], reverse=True) + + if sorted_regimes: + insights['best_regimes'] = [sorted_regimes[0][0]] if sorted_regimes else [] + insights['worst_regimes'] = [sorted_regimes[-1][0]] if len(sorted_regimes) > 1 else [] + + # Generate recommendations + if insights['weak_indicators']: + weak_names = [i['name'] for i in insights['weak_indicators'][:3]] + insights['recommendations'].append( + f"Consider reducing weights for: {', '.join(weak_names)}" + ) + + if insights['strong_indicators']: + strong_names = [i['name'] for i in insights['strong_indicators'][:3]] + insights['recommendations'].append( + f"Consider increasing weights for: {', '.join(strong_names)}" + ) + + if insights['worst_regimes']: + worst_regime = insights['worst_regimes'][0] + regime_stats = regime_breakdown.get(worst_regime, {}) + if regime_stats.get('win_rate', 100) < 40: + insights['recommendations'].append( + f"Consider avoiding signals in '{worst_regime}' market regime" + ) + + # Check for recent performance decline + if len(self.trades) >= 50: + recent_trades = self.trades[-25:] + older_trades = self.trades[-50:-25] + + recent_win_rate = sum(1 for t in recent_trades if t.is_winner()) / len(recent_trades) * 100 + older_win_rate = sum(1 for t in older_trades if t.is_winner()) / len(older_trades) * 100 + + if recent_win_rate < older_win_rate - 10: + insights['recommendations'].append( + f"Performance declining: Recent win rate {recent_win_rate:.1f}% vs " + f"previous {older_win_rate:.1f}%. Consider retraining weights." + ) + + return insights + + def print_detailed_report(self, initial_balance: float, final_balance: float): + """Print detailed performance report""" + metrics = self.calculate_comprehensive_metrics(initial_balance, final_balance) + + print("\n" + "="*80) + print("📊 ENHANCED PERFORMANCE REPORT") + print("="*80) + + # Summary + s = metrics['summary'] + print(f"\n📈 SUMMARY") + print(f" Total Trades: {s['total_trades']}") + print(f" Win Rate: {s['win_rate']:.1f}% ({s['winners']}W / {s['losers']}L)") + print(f" Profit Factor: {s['profit_factor']:.2f}") + print(f" Net Profit: ${s['net_profit']:.2f}") + print(f" Total Return: {s['total_return_pct']:.2f}%") + print(f" Max Drawdown: {s['max_drawdown_pct']:.2f}%") + print(f" Expectancy: ${s['expectancy']:.2f}") + print(f" Avg R-Multiple: {s['avg_r_multiple']:.2f}R") + + # Exit breakdown + if metrics['exit_breakdown']: + print(f"\n📤 EXIT TYPE BREAKDOWN") + for exit_type, stats in metrics['exit_breakdown'].items(): + print(f" {exit_type}: {stats['count']} ({stats['percentage']:.1f}%) | " + f"Avg P/L: ${stats['avg_pnl']:.2f}") + + # Indicator performance + if metrics['indicator_performance']: + print(f"\n🎯 INDICATOR PERFORMANCE (Top 5)") + for i, (indicator, stats) in enumerate(list(metrics['indicator_performance'].items())[:5]): + status_emoji = {'excellent': '🟢', 'good': '🟡', 'average': '🟠', 'poor': '🔴'} + emoji = status_emoji.get(stats['status'], '⚪') + print(f" {emoji} {indicator}: {stats['win_rate']:.1f}% WR | " + f"${stats['avg_pnl']:.2f} avg | {stats['avg_r']:.2f}R | " + f"TP1:{stats['tp1_hits']} TP2:{stats['tp2_hits']} TP3:{stats['tp3_hits']} SL:{stats['stop_hits']}") + + # Weak indicators + weak_indicators = [ind for ind, stats in metrics['indicator_performance'].items() + if stats['status'] == 'poor'] + if weak_indicators: + print(f"\n🔴 UNDERPERFORMING INDICATORS") + for ind in weak_indicators[:5]: + stats = metrics['indicator_performance'][ind] + print(f" {ind}: {stats['win_rate']:.1f}% WR, ${stats['total_pnl']:.2f} total") + + # Regime performance + if metrics['regime_performance']: + print(f"\n🌍 MARKET REGIME PERFORMANCE") + for regime, stats in metrics['regime_performance'].items(): + print(f" {regime}: {stats['win_rate']:.1f}% WR ({stats['trades']} trades) | " + f"${stats['total_pnl']:.2f}") + + # Recommendations + if metrics['insights']['recommendations']: + print(f"\n💡 RECOMMENDATIONS") + for rec in metrics['insights']['recommendations']: + print(f" • {rec}") + + print("\n" + "="*80) + + return metrics + + +def create_trade_metrics( + trade_log: List[Dict], + entry_trade: Dict, + exit_trades: List[Dict], + market_context: Dict[str, Any], + indicator_contributions: Dict[str, float] +) -> TradeMetrics: + """ + Helper to create TradeMetrics from trade log data. + + Args: + trade_log: Full trade log + entry_trade: Entry trade dictionary + exit_trades: List of exit trades for this entry + market_context: Market context at entry + indicator_contributions: Indicator contributions to signal + """ + + # Calculate final outcome + if not exit_trades: + outcome = 'end_of_period' + exit_price = entry_trade['price'] + profit_loss = 0 + else: + last_exit = exit_trades[-1] + outcome = last_exit.get('type', 'end_of_period') + # Map trade types to outcome strings + outcome_map = { + 'take_profit_1': 'tp1', + 'take_profit_2': 'tp2', + 'take_profit_3': 'tp3', + 'stop_loss': 'stop_loss', + 'trailing_stop': 'trailing_stop', + 'exit_end_of_period': 'end_of_period' + } + outcome = outcome_map.get(outcome, outcome) + exit_price = last_exit.get('price', entry_trade['price']) + profit_loss = sum(t.get('profit', 0) for t in exit_trades) + + # Calculate R-multiple + risk = abs(entry_trade['price'] - entry_trade.get('stop_loss', entry_trade['price'])) + r_multiple = profit_loss / risk if risk > 0 else 0 + + return TradeMetrics( + trade_id=entry_trade.get('trade_id', str(entry_trade.get('index', 0))), + symbol=entry_trade.get('symbol', 'UNKNOWN'), + interval=entry_trade.get('interval', '1h'), + signal_type=entry_trade.get('signal', '').split()[0] if entry_trade.get('signal') else 'Unknown', + entry_price=entry_trade['price'], + exit_price=exit_price, + stop_loss=entry_trade.get('stop_loss', 0), + take_profit_targets=[ + entry_trade.get('take_profit_1', 0), + entry_trade.get('take_profit_2', 0), + entry_trade.get('take_profit_3', 0) + ], + outcome=outcome, + profit_loss=profit_loss, + profit_loss_percent=(exit_price - entry_trade['price']) / entry_trade['price'] * 100 if entry_trade['price'] > 0 else 0, + risk_reward_achieved=abs(profit_loss / risk) if risk > 0 else 0, + entry_index=entry_trade.get('index', 0), + exit_index=exit_trades[-1].get('index', 0) if exit_trades else entry_trade.get('index', 0), + duration_candles=exit_trades[-1].get('index', 0) - entry_trade.get('index', 0) if exit_trades else 0, + market_regime=market_context.get('market_regime', ''), + volatility=market_context.get('volatility', 0), + volume_ratio=market_context.get('volume_ratio', 1), + trend=market_context.get('trend', ''), + contributing_indicators=list(indicator_contributions.keys()), + indicator_scores=indicator_contributions, + r_multiple=r_multiple + ) + + +# Usage example +if __name__ == "__main__": + # Create calculator + calc = EnhancedMetricsCalculator() + + # Example trade (normally these would come from backtest) + example_trade = TradeMetrics( + trade_id="test_1", + symbol="BTCUSDT", + interval="1h", + signal_type="Bullish", + entry_price=50000, + exit_price=51500, + stop_loss=49000, + take_profit_targets=[51000, 52000, 53000], + outcome="tp2", + profit_loss=150, + profit_loss_percent=3.0, + risk_reward_achieved=1.5, + entry_index=100, + exit_index=115, + duration_candles=15, + market_regime="trending_up", + volatility=0.02, + volume_ratio=1.2, + trend="uptrend", + contributing_indicators=["W_BULLISH_OB", "W_TREND", "W_STRUCTURE_BREAK"], + indicator_scores={"W_BULLISH_OB": 1.2, "W_TREND": 0.8, "W_STRUCTURE_BREAK": 1.5}, + r_multiple=1.5 + ) + + calc.add_trade(example_trade) + calc.print_detailed_report(10000, 10150) + diff --git a/back_tester/intensive_training.py b/back_tester/intensive_training.py new file mode 100755 index 0000000..384197d --- /dev/null +++ b/back_tester/intensive_training.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +""" +INTENSIVE PARAMETER OPTIMIZATION + +This script performs aggressive optimization of ALL trading parameters: + +1. Signal Weights (18 weights for different indicators) +2. Stop Loss Parameters (ATR multiplier, min distance) +3. Take Profit Levels (TP1, TP2, TP3 R:R ratios) +4. Trailing Stop Parameters (distance, activation level) +5. Position Sizing (risk percentage) + +Uses evolutionary strategies with elitism to find optimal combinations. + +Usage: + python3 intensive_training.py --generations 50 --hours 4 +""" + +import os +import sys +import argparse +import json +import numpy as np +from datetime import datetime, timedelta +from typing import Dict, List, Tuple, Any, Optional +from collections import defaultdict +from dataclasses import dataclass, asdict +import time +import random +import copy + +# Add project root to path +project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if project_dir not in sys.path: + sys.path.append(project_dir) + +from back_tester.strategy import backtest_strategy + +# Configuration +SYMBOLS = ["BTCUSDT", "ETHUSDT"] +INTERVALS = ["5m", "15m", "30m", "1h", "4h"] + +WEIGHT_NAMES = [ + "W_BULLISH_OB", "W_BEARISH_OB", "W_BULLISH_BREAKER", "W_BEARISH_BREAKER", + "W_ABOVE_SUPPORT", "W_BELOW_RESISTANCE", "W_FVG_ABOVE", "W_FVG_BELOW", + "W_TREND", "W_SWEEP_HIGHS", "W_SWEEP_LOWS", "W_STRUCTURE_BREAK", + "W_PIN_BAR", "W_ENGULFING", "W_LIQUIDITY_POOL_ABOVE", "W_LIQUIDITY_POOL_BELOW", + "W_LIQUIDITY_POOL_ROUND", "W_RSI_EXTREME" +] + +# Colors +class C: + H = '\033[95m' + B = '\033[94m' + C = '\033[96m' + G = '\033[92m' + Y = '\033[93m' + R = '\033[91m' + E = '\033[0m' + BOLD = '\033[1m' + + +@dataclass +class TradingParameters: + """All optimizable trading parameters""" + + # Signal weights (18) + weights: List[float] + + # Stop Loss Parameters + atr_multiplier: float = 2.0 # ATR multiplier for stop loss distance + min_sl_percent: float = 0.5 # Minimum SL distance as % of entry + + # Take Profit R:R Ratios + tp1_ratio: float = 1.5 # Risk:Reward for TP1 + tp2_ratio: float = 2.5 # Risk:Reward for TP2 + tp3_ratio: float = 4.0 # Risk:Reward for TP3 + + # Trailing Stop Parameters + trailing_stop_distance: float = 0.5 # Trailing stop distance % + trailing_activation_tp: int = 1 # Activate trailing at TP1(1), TP2(2), or TP3(3) + + # Position Sizing + risk_percentage: float = 1.0 # Risk % per trade + + @classmethod + def default(cls) -> 'TradingParameters': + """Create with default values""" + return cls( + weights=[ + 1.2, 1.2, 1.0, 1.0, # Order blocks, breakers + 0.8, 0.8, 0.6, 0.6, # Support/resistance, FVG + 1.0, 1.3, 1.3, 1.5, # Trend, sweeps, structure + 0.7, 0.7, 1.0, 1.0, # Patterns, liquidity + 1.2, 0.5 # Round numbers, RSI + ] + ) + + @classmethod + def random(cls) -> 'TradingParameters': + """Create with random values for exploration""" + return cls( + weights=[random.uniform(0.3, 2.0) for _ in range(18)], + atr_multiplier=random.uniform(1.0, 4.0), + min_sl_percent=random.uniform(0.3, 1.5), + tp1_ratio=random.uniform(1.0, 2.5), + tp2_ratio=random.uniform(2.0, 4.0), + tp3_ratio=random.uniform(3.0, 6.0), + trailing_stop_distance=random.uniform(0.3, 1.5), + trailing_activation_tp=random.choice([1, 2]), + risk_percentage=random.uniform(0.5, 2.0) + ) + + def mutate(self, mutation_rate: float = 0.3, mutation_strength: float = 0.15) -> 'TradingParameters': + """Create mutated copy of parameters""" + new_params = copy.deepcopy(self) + + # Mutate weights + for i in range(len(new_params.weights)): + if random.random() < mutation_rate: + change = np.random.normal(0, mutation_strength) + new_params.weights[i] = max(0.1, min(3.0, new_params.weights[i] + change)) + + # Mutate SL parameters + if random.random() < mutation_rate: + new_params.atr_multiplier = max(0.5, min(5.0, + new_params.atr_multiplier + np.random.normal(0, 0.3))) + + if random.random() < mutation_rate: + new_params.min_sl_percent = max(0.2, min(2.0, + new_params.min_sl_percent + np.random.normal(0, 0.2))) + + # Mutate TP ratios (keep ordering: TP1 < TP2 < TP3) + if random.random() < mutation_rate: + new_params.tp1_ratio = max(0.8, min(3.0, + new_params.tp1_ratio + np.random.normal(0, 0.3))) + + if random.random() < mutation_rate: + new_params.tp2_ratio = max(new_params.tp1_ratio + 0.5, min(5.0, + new_params.tp2_ratio + np.random.normal(0, 0.4))) + + if random.random() < mutation_rate: + new_params.tp3_ratio = max(new_params.tp2_ratio + 0.5, min(8.0, + new_params.tp3_ratio + np.random.normal(0, 0.5))) + + # Mutate trailing stop + if random.random() < mutation_rate: + new_params.trailing_stop_distance = max(0.2, min(2.0, + new_params.trailing_stop_distance + np.random.normal(0, 0.2))) + + if random.random() < mutation_rate * 0.5: # Less frequent + new_params.trailing_activation_tp = random.choice([1, 2]) + + # Mutate risk + if random.random() < mutation_rate: + new_params.risk_percentage = max(0.25, min(3.0, + new_params.risk_percentage + np.random.normal(0, 0.2))) + + return new_params + + @staticmethod + def crossover(parent1: 'TradingParameters', parent2: 'TradingParameters') -> 'TradingParameters': + """Create child from two parents""" + child_weights = [] + for i in range(len(parent1.weights)): + if random.random() < 0.5: + child_weights.append(parent1.weights[i]) + else: + child_weights.append(parent2.weights[i]) + + # Randomly inherit other parameters + return TradingParameters( + weights=child_weights, + atr_multiplier=random.choice([parent1.atr_multiplier, parent2.atr_multiplier]), + min_sl_percent=random.choice([parent1.min_sl_percent, parent2.min_sl_percent]), + tp1_ratio=random.choice([parent1.tp1_ratio, parent2.tp1_ratio]), + tp2_ratio=random.choice([parent1.tp2_ratio, parent2.tp2_ratio]), + tp3_ratio=random.choice([parent1.tp3_ratio, parent2.tp3_ratio]), + trailing_stop_distance=random.choice([parent1.trailing_stop_distance, parent2.trailing_stop_distance]), + trailing_activation_tp=random.choice([parent1.trailing_activation_tp, parent2.trailing_activation_tp]), + risk_percentage=random.choice([parent1.risk_percentage, parent2.risk_percentage]), + ) + + def to_dict(self) -> Dict: + """Convert to dictionary for saving""" + return { + 'weights': self.weights, + 'weight_names': WEIGHT_NAMES, + 'atr_multiplier': self.atr_multiplier, + 'min_sl_percent': self.min_sl_percent, + 'tp1_ratio': self.tp1_ratio, + 'tp2_ratio': self.tp2_ratio, + 'tp3_ratio': self.tp3_ratio, + 'trailing_stop_distance': self.trailing_stop_distance, + 'trailing_activation_tp': self.trailing_activation_tp, + 'risk_percentage': self.risk_percentage, + } + + @classmethod + def from_dict(cls, data: Dict) -> 'TradingParameters': + """Load from dictionary""" + return cls( + weights=data.get('weights', cls.default().weights), + atr_multiplier=data.get('atr_multiplier', 2.0), + min_sl_percent=data.get('min_sl_percent', 0.5), + tp1_ratio=data.get('tp1_ratio', 1.5), + tp2_ratio=data.get('tp2_ratio', 2.5), + tp3_ratio=data.get('tp3_ratio', 4.0), + trailing_stop_distance=data.get('trailing_stop_distance', 0.5), + trailing_activation_tp=data.get('trailing_activation_tp', 1), + risk_percentage=data.get('risk_percentage', 1.0), + ) + + +class IntensiveParameterOptimizer: + """ + Comprehensive parameter optimizer using evolutionary strategies. + Optimizes weights AND risk management parameters together. + """ + + def __init__( + self, + storage_path: str = "./data/intensive_training", + initial_balance: float = 10000.0, + population_size: int = 8, + elite_count: int = 2, + tests_per_evaluation: int = 10, + ): + self.storage_path = storage_path + self.initial_balance = initial_balance + self.population_size = population_size + self.elite_count = elite_count + self.tests_per_evaluation = tests_per_evaluation + + os.makedirs(storage_path, exist_ok=True) + os.makedirs(os.path.join(storage_path, "checkpoints"), exist_ok=True) + + # Load or initialize parameters + self.best_params = self._load_params() + self.best_fitness = float('-inf') + + # Tracking + self.generation = 0 + self.total_backtests = 0 + self.total_trades = 0 + self.start_time = None + + # History + self.fitness_history = [] + self.param_history = [] + + def _load_params(self) -> TradingParameters: + """Load parameters from file or use defaults""" + params_file = os.path.join(self.storage_path, "best_params.json") + + if os.path.exists(params_file): + try: + with open(params_file, 'r') as f: + data = json.load(f) + params = TradingParameters.from_dict(data) + print(f"{C.G}✓ Loaded existing parameters from checkpoint{C.E}") + return params + except Exception as e: + print(f"{C.Y}Warning: Could not load params: {e}{C.E}") + + return TradingParameters.default() + + def _save_params(self, params: TradingParameters, fitness: float, is_best: bool = False): + """Save parameters to file""" + data = params.to_dict() + data['fitness'] = fitness + data['generation'] = self.generation + data['total_backtests'] = self.total_backtests + data['timestamp'] = datetime.now().isoformat() + + filename = "best_params.json" if is_best else f"params_gen_{self.generation}.json" + filepath = os.path.join(self.storage_path, filename) + + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + + if is_best: + checkpoint_file = os.path.join( + self.storage_path, "checkpoints", + f"best_gen_{self.generation}_fit_{fitness:.1f}.json" + ) + with open(checkpoint_file, 'w') as f: + json.dump(data, f, indent=2) + + def evaluate_params(self, params: TradingParameters) -> Tuple[float, Dict[str, Any]]: + """ + Evaluate a parameter set by running multiple backtests. + """ + total_profit = 0.0 + total_trades = 0 + wins = 0 + losses = 0 + tp1_hits = 0 + tp2_hits = 0 + tp3_hits = 0 + sl_hits = 0 + results = [] + + # Generate test configurations + test_configs = [] + for _ in range(self.tests_per_evaluation): + symbol = random.choice(SYMBOLS) + interval = random.choice(INTERVALS) + candles = random.randint(300, 600) + test_configs.append((symbol, interval, candles)) + + for symbol, interval, candles in test_configs: + try: + window = int(candles * 0.5) + + # Run backtest with these parameters + # Note: We need to pass custom TP/SL params through the signal generation + # For now, we use the weights and trailing stop params that strategy.py accepts + final_balance, trades, _ = backtest_strategy( + symbol=symbol, + interval=interval, + candles=candles, + window=window, + initial_balance=self.initial_balance, + risk_percentage=params.risk_percentage, + weights=params.weights, + use_trailing_stop=True, + trailing_stop_distance_percent=params.trailing_stop_distance, + ) + + profit = final_balance - self.initial_balance + + # Analyze trades + for trade in trades: + trade_type = trade.get('type', '') + if trade_type == 'entry': + total_trades += 1 + elif trade_type == 'take_profit_1': + tp1_hits += 1 + if trade.get('profit', 0) > 0: + wins += 1 + elif trade_type == 'take_profit_2': + tp2_hits += 1 + if trade.get('profit', 0) > 0: + wins += 1 + elif trade_type == 'take_profit_3': + tp3_hits += 1 + if trade.get('profit', 0) > 0: + wins += 1 + elif trade_type == 'stop_loss': + sl_hits += 1 + losses += 1 + elif trade_type == 'exit_end_of_period': + if trade.get('profit', 0) > 0: + wins += 1 + else: + losses += 1 + + total_profit += profit + self.total_backtests += 1 + + results.append({ + 'symbol': symbol, + 'interval': interval, + 'profit': profit, + 'trades': len([t for t in trades if t.get('type') == 'entry']) + }) + + except Exception as e: + total_profit -= 50 # Penalty for errors + results.append({'error': str(e)}) + + self.total_trades += total_trades + + # Calculate fitness + if total_trades == 0: + return -1000, {'error': 'No trades'} + + total_exits = wins + losses + win_rate = wins / total_exits if total_exits > 0 else 0 + avg_profit = total_profit / self.tests_per_evaluation + + # TP distribution score (prefer hitting higher TPs) + total_tp_hits = tp1_hits + tp2_hits + tp3_hits + if total_tp_hits > 0: + tp_quality = (tp1_hits * 1 + tp2_hits * 2 + tp3_hits * 3) / (total_tp_hits * 3) + else: + tp_quality = 0 + + # Calculate risk-adjusted return + if sl_hits > 0: + reward_risk = (tp1_hits + tp2_hits * 1.5 + tp3_hits * 2) / sl_hits + else: + reward_risk = 2.0 # Default if no SL hits + + # Composite fitness function + fitness = ( + avg_profit * 0.30 + # Profitability + win_rate * 100 * 0.25 + # Win rate (scaled to ~50) + tp_quality * 50 * 0.15 + # TP quality (scaled to ~50) + min(reward_risk, 3) * 15 * 0.15 + # Risk/reward (capped, scaled) + min(total_trades / 5, 20) * 0.15 # Trade frequency (capped) + ) + + metrics = { + 'total_profit': total_profit, + 'avg_profit': avg_profit, + 'total_trades': total_trades, + 'wins': wins, + 'losses': losses, + 'win_rate': win_rate * 100, + 'tp1_hits': tp1_hits, + 'tp2_hits': tp2_hits, + 'tp3_hits': tp3_hits, + 'sl_hits': sl_hits, + 'tp_quality': tp_quality, + 'reward_risk': reward_risk, + } + + return fitness, metrics + + def train_generation(self) -> Tuple[TradingParameters, float]: + """Run one generation of evolutionary optimization""" + self.generation += 1 + + print(f"\n{C.H}{'='*70}{C.E}") + print(f"{C.H} GENERATION {self.generation}{C.E}") + print(f"{C.H}{'='*70}{C.E}") + + # Create population + population = [] + + # Keep elite (best from previous generation) + population.append(self.best_params) + + # Mutations of best + for _ in range(self.population_size - 3): + mutated = self.best_params.mutate(mutation_rate=0.4, mutation_strength=0.2) + population.append(mutated) + + # One random for exploration + population.append(TradingParameters.random()) + + # One with aggressive settings + aggressive = TradingParameters( + weights=[w * random.uniform(0.8, 1.2) for w in self.best_params.weights], + atr_multiplier=1.5, # Tighter SL + tp1_ratio=1.2, # Quick TP1 + tp2_ratio=2.0, + tp3_ratio=3.5, + trailing_stop_distance=0.4, + trailing_activation_tp=1, + risk_percentage=1.5, + ) + population.append(aggressive) + + # Evaluate all + results = [] + for i, params in enumerate(population): + label = "ELITE" if i == 0 else f"#{i+1}" + print(f"\n Testing {label}... ", end="", flush=True) + + fitness, metrics = self.evaluate_params(params) + results.append((fitness, params, metrics)) + + color = C.G if fitness > self.best_fitness else (C.Y if fitness > self.best_fitness - 20 else C.R) + print(f"{color}Fitness: {fitness:.1f} | " + f"Profit: ${metrics.get('avg_profit', 0):.2f} | " + f"WR: {metrics.get('win_rate', 0):.1f}% | " + f"TP1:{metrics.get('tp1_hits',0)} TP2:{metrics.get('tp2_hits',0)} TP3:{metrics.get('tp3_hits',0)} SL:{metrics.get('sl_hits',0)}{C.E}") + + # Sort by fitness + results.sort(key=lambda x: x[0], reverse=True) + + # Get generation best + gen_best_fitness, gen_best_params, gen_best_metrics = results[0] + + # Track history + self.fitness_history.append(gen_best_fitness) + + # Update global best + if gen_best_fitness > self.best_fitness: + improvement = gen_best_fitness - self.best_fitness + self.best_fitness = gen_best_fitness + self.best_params = copy.deepcopy(gen_best_params) + + print(f"\n{C.G}{'★'*3} NEW BEST! Fitness: {gen_best_fitness:.1f} (+{improvement:.1f}) {'★'*3}{C.E}") + + # Show key parameters + print(f"\n{C.C}Key Parameters:{C.E}") + print(f" ATR Multiplier: {gen_best_params.atr_multiplier:.2f}") + print(f" TP Ratios: {gen_best_params.tp1_ratio:.1f} / {gen_best_params.tp2_ratio:.1f} / {gen_best_params.tp3_ratio:.1f}") + print(f" Trailing Stop: {gen_best_params.trailing_stop_distance:.2f}% (activate at TP{gen_best_params.trailing_activation_tp})") + print(f" Risk per Trade: {gen_best_params.risk_percentage:.2f}%") + + self._save_params(gen_best_params, gen_best_fitness, is_best=True) + else: + # Crossover top performers for next generation + if len(results) >= 2: + child = TradingParameters.crossover(results[0][1], results[1][1]) + self.best_params = child.mutate(mutation_rate=0.2) + + return gen_best_params, gen_best_fitness + + def run( + self, + max_generations: int = 100, + max_hours: float = 4.0, + target_fitness: float = None + ) -> Tuple[TradingParameters, float]: + """Run full optimization""" + self.start_time = datetime.now() + end_time = self.start_time + timedelta(hours=max_hours) + + print(f"\n{C.H}{'='*70}{C.E}") + print(f"{C.H}{'INTENSIVE PARAMETER OPTIMIZATION':^70}{C.E}") + print(f"{C.H}{'='*70}{C.E}") + + print(f"\n{C.C}Configuration:{C.E}") + print(f" Max Generations: {max_generations}") + print(f" Max Time: {max_hours} hours") + print(f" Population Size: {self.population_size}") + print(f" Tests per Evaluation: {self.tests_per_evaluation}") + print(f" Symbols: {SYMBOLS}") + print(f" Intervals: {INTERVALS}") + + print(f"\n{C.C}Optimizing:{C.E}") + print(f" • 18 Signal Weights") + print(f" • Stop Loss (ATR multiplier, min distance)") + print(f" • Take Profit Levels (TP1, TP2, TP3 R:R ratios)") + print(f" • Trailing Stop (distance, activation)") + print(f" • Risk Percentage") + + print(f"\n{C.Y}Starting optimization... (Ctrl+C to stop and save){C.E}") + + try: + for gen in range(max_generations): + if datetime.now() >= end_time: + print(f"\n{C.Y}Time limit reached{C.E}") + break + + best_params, best_fitness = self.train_generation() + + if target_fitness and best_fitness >= target_fitness: + print(f"\n{C.G}Target fitness reached!{C.E}") + break + + # Progress every 5 gens + if self.generation % 5 == 0: + elapsed = (datetime.now() - self.start_time).total_seconds() / 60 + print(f"\n{C.B}═══ Progress: Gen {self.generation} | " + f"Best: {self.best_fitness:.1f} | " + f"Backtests: {self.total_backtests} | " + f"Time: {elapsed:.1f}m ═══{C.E}") + + except KeyboardInterrupt: + print(f"\n{C.Y}Optimization interrupted{C.E}") + + self._print_final_report() + return self.best_params, self.best_fitness + + def _print_final_report(self): + """Print comprehensive final report""" + elapsed = (datetime.now() - self.start_time).total_seconds() + + print(f"\n{C.H}{'='*70}{C.E}") + print(f"{C.H}{'OPTIMIZATION COMPLETE':^70}{C.E}") + print(f"{C.H}{'='*70}{C.E}") + + print(f"\n{C.BOLD}Statistics:{C.E}") + print(f" Generations: {self.generation}") + print(f" Backtests: {self.total_backtests}") + print(f" Trades: {self.total_trades}") + print(f" Time: {elapsed/60:.1f} minutes") + print(f" Best Fitness: {self.best_fitness:.1f}") + + p = self.best_params + + print(f"\n{C.BOLD}═══ OPTIMIZED PARAMETERS ═══{C.E}") + + print(f"\n{C.C}Stop Loss:{C.E}") + print(f" ATR Multiplier: {p.atr_multiplier:.2f}") + print(f" Min SL Distance: {p.min_sl_percent:.2f}%") + + print(f"\n{C.C}Take Profit Levels:{C.E}") + print(f" TP1 R:R Ratio: {p.tp1_ratio:.2f}") + print(f" TP2 R:R Ratio: {p.tp2_ratio:.2f}") + print(f" TP3 R:R Ratio: {p.tp3_ratio:.2f}") + + print(f"\n{C.C}Trailing Stop:{C.E}") + print(f" Distance: {p.trailing_stop_distance:.2f}%") + print(f" Activation: TP{p.trailing_activation_tp}") + + print(f"\n{C.C}Position Sizing:{C.E}") + print(f" Risk per Trade: {p.risk_percentage:.2f}%") + + print(f"\n{C.C}Signal Weights:{C.E}") + for name, weight in zip(WEIGHT_NAMES, p.weights): + bar_len = int(weight * 8) + bar = "█" * bar_len + "░" * (16 - bar_len) + + if weight > 1.5: + color, status = C.G, "STRONG" + elif weight < 0.6: + color, status = C.R, "WEAK" + else: + color, status = C.E, "" + + print(f" {color}{name:<25} {weight:.3f} {bar} {status}{C.E}") + + # Save + self._save_params(self.best_params, self.best_fitness, is_best=True) + print(f"\n{C.G}✓ Parameters saved to: {self.storage_path}/best_params.json{C.E}") + + # Fitness trend + if len(self.fitness_history) > 1: + print(f"\n{C.BOLD}Fitness Trend:{C.E}") + start = self.fitness_history[0] + end = self.fitness_history[-1] + change = end - start + trend = "📈" if change > 0 else "📉" + print(f" Start: {start:.1f} → End: {end:.1f} ({trend} {change:+.1f})") + + +def main(): + parser = argparse.ArgumentParser(description="Intensive trading parameter optimization") + parser.add_argument('--generations', type=int, default=50) + parser.add_argument('--hours', type=float, default=2.0) + parser.add_argument('--population', type=int, default=8) + parser.add_argument('--tests', type=int, default=10, help='Backtests per evaluation') + parser.add_argument('--balance', type=float, default=10000.0) + parser.add_argument('--storage', type=str, default='./data/intensive_training') + parser.add_argument('--target-fitness', type=float, default=None) + + args = parser.parse_args() + + optimizer = IntensiveParameterOptimizer( + storage_path=args.storage, + initial_balance=args.balance, + population_size=args.population, + tests_per_evaluation=args.tests, + ) + + try: + best_params, best_fitness = optimizer.run( + max_generations=args.generations, + max_hours=args.hours, + target_fitness=args.target_fitness + ) + print(f"\n{C.G}Optimization completed! Best fitness: {best_fitness:.1f}{C.E}") + + except Exception as e: + print(f"\n{C.R}Optimization failed: {e}{C.E}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/back_tester/run_learning_backtest.py b/back_tester/run_learning_backtest.py new file mode 100755 index 0000000..3eee13e --- /dev/null +++ b/back_tester/run_learning_backtest.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +""" +Self-Learning Backtesting Runner + +This script runs comprehensive backtests on BTCUSDT and ETHUSDT across all +time intervals with AGGRESSIVE self-learning. The system: +- Learns from each trade outcome +- Directly adjusts weights based on indicator performance +- Runs many iterations to find optimal weights + +Usage: + python3 run_learning_backtest.py [options] + +Options: + --iterations N Number of learning iterations (default: 20) + --candles N Number of candles per backtest + --balance N Initial balance (default: 10000) + --learning-rate How fast weights change (default: 0.15) +""" + +import os +import sys +import argparse +import json +from datetime import datetime +from typing import Dict, List, Any, Tuple +from collections import defaultdict +import numpy as np +import random + +# Add project root to path +project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if project_dir not in sys.path: + sys.path.append(project_dir) + +from back_tester.strategy import backtest_strategy + +# Configuration +SYMBOLS = ["BTCUSDT", "ETHUSDT"] +INTERVALS = ["5m", "15m", "30m", "1h", "4h"] # Most useful intervals + +WEIGHT_NAMES = [ + "W_BULLISH_OB", "W_BEARISH_OB", "W_BULLISH_BREAKER", "W_BEARISH_BREAKER", + "W_ABOVE_SUPPORT", "W_BELOW_RESISTANCE", "W_FVG_ABOVE", "W_FVG_BELOW", + "W_TREND", "W_SWEEP_HIGHS", "W_SWEEP_LOWS", "W_STRUCTURE_BREAK", + "W_PIN_BAR", "W_ENGULFING", "W_LIQUIDITY_POOL_ABOVE", "W_LIQUIDITY_POOL_BELOW", + "W_LIQUIDITY_POOL_ROUND", "W_RSI_EXTREME" +] + +# Candle settings per interval (increased for more trades) +# Binance allows up to 1000 candles per request, but we can fetch more with pagination +CANDLES_PER_INTERVAL = { + "5m": 2000, # ~7 days of data + "15m": 1500, # ~15 days of data + "30m": 1200, # ~25 days of data + "1h": 1000, # ~42 days of data + "4h": 800, # ~133 days of data (~4.5 months) +} + +class Colors: + H = '\033[95m' + B = '\033[94m' + C = '\033[96m' + G = '\033[92m' + Y = '\033[93m' + R = '\033[91m' + E = '\033[0m' + BOLD = '\033[1m' + + +class AggressiveLearner: + """ + Aggressive weight learning with direct performance-based updates. + Unlike conservative learners, this one makes BIG changes. + """ + + def __init__( + self, + storage_path: str = "./data/btc_eth_learning", + initial_balance: float = 10000.0, + learning_rate: float = 0.15, # Much higher than typical 0.05 + lr_decay: float = 0.95, # Decay rate per iteration (prevents overfitting) + ): + self.storage_path = storage_path + self.initial_balance = initial_balance + self.base_learning_rate = learning_rate + self.learning_rate = learning_rate + self.lr_decay = lr_decay + + os.makedirs(storage_path, exist_ok=True) + + # Load or initialize weights + self.weights = self._load_weights() + self.best_weights = self.weights.copy() + self.best_profit = float('-inf') + + # Track indicator performance across all trades + self.indicator_stats = defaultdict(lambda: { + 'win_count': 0, 'loss_count': 0, + 'win_profit': 0.0, 'loss_amount': 0.0, + 'appearances': 0 + }) + + # Track overall performance + self.iteration = 0 + self.total_trades = 0 + self.total_profit = 0.0 + self.profit_history = [] + self.weight_history = [] + + def _load_weights(self) -> List[float]: + """Load weights from file or use optimized defaults""" + weights_file = os.path.join(self.storage_path, "final_weights.json") + + if os.path.exists(weights_file): + try: + with open(weights_file, 'r') as f: + data = json.load(f) + if 'weights' in data: + weights_data = data['weights'] + # Handle dict format (name -> value) + if isinstance(weights_data, dict): + if len(weights_data) == len(WEIGHT_NAMES): + weights = [float(weights_data.get(name, 1.0)) for name in WEIGHT_NAMES] + print(f"{Colors.G}✓ Loaded weights from previous session{Colors.E}") + return weights + # Handle list format + elif isinstance(weights_data, list) and len(weights_data) == len(WEIGHT_NAMES): + weights = [float(w) for w in weights_data] + print(f"{Colors.G}✓ Loaded weights from previous session{Colors.E}") + return weights + except Exception as e: + print(f"{Colors.Y}Warning: Could not load weights: {e}{Colors.E}") + + # Smart starting weights (not all 1.0) + return [ + 1.3, # W_BULLISH_OB - Order blocks tend to work well + 1.3, # W_BEARISH_OB + 1.1, # W_BULLISH_BREAKER + 1.1, # W_BEARISH_BREAKER + 0.8, # W_ABOVE_SUPPORT + 0.8, # W_BELOW_RESISTANCE + 0.6, # W_FVG_ABOVE - FVGs less reliable alone + 0.6, # W_FVG_BELOW + 1.0, # W_TREND + 1.4, # W_SWEEP_HIGHS - Sweeps are key signals + 1.4, # W_SWEEP_LOWS + 1.5, # W_STRUCTURE_BREAK - Very important + 0.7, # W_PIN_BAR + 0.7, # W_ENGULFING + 1.0, # W_LIQUIDITY_POOL_ABOVE + 1.0, # W_LIQUIDITY_POOL_BELOW + 1.2, # W_LIQUIDITY_POOL_ROUND + 0.5, # W_RSI_EXTREME - Often unreliable + ] + + def _save_weights(self): + """Save current weights""" + data = { + 'weights': self.weights, + 'weight_names': WEIGHT_NAMES, + 'best_weights': self.best_weights, + 'best_profit': self.best_profit, + 'iteration': self.iteration, + 'total_trades': self.total_trades, + 'total_profit': self.total_profit, + 'timestamp': datetime.now().isoformat(), + 'indicator_stats': dict(self.indicator_stats), + } + + with open(os.path.join(self.storage_path, "final_weights.json"), 'w') as f: + json.dump(data, f, indent=2) + + def run_backtest(self, symbol: str, interval: str, candles: int) -> Dict: + """Run a single backtest and return results""" + window = int(candles * 0.5) + + try: + final_balance, trades, _ = backtest_strategy( + symbol=symbol, + interval=interval, + candles=candles, + window=window, + initial_balance=self.initial_balance, + risk_percentage=1.0, + weights=self.weights, + use_trailing_stop=True, + trailing_stop_distance_percent=0.5, + ) + + profit = final_balance - self.initial_balance + entry_trades = [t for t in trades if t.get('type') == 'entry'] + + # Analyze exits + wins = 0 + losses = 0 + for t in trades: + if t.get('type') in ['take_profit_1', 'take_profit_2', 'take_profit_3']: + wins += 1 + elif t.get('type') == 'stop_loss': + losses += 1 + elif t.get('type') == 'exit_end_of_period': + if t.get('profit', 0) > 0: + wins += 1 + else: + losses += 1 + + return { + 'success': True, + 'profit': profit, + 'trades': len(entry_trades), + 'wins': wins, + 'losses': losses, + 'win_rate': (wins / (wins + losses) * 100) if (wins + losses) > 0 else 0, + } + + except Exception as e: + return {'success': False, 'error': str(e), 'profit': 0, 'trades': 0} + + def update_weights(self, iteration_profit: float, iteration_trades: int, win_rate: float): + """ + Update weights based on iteration performance. + Uses multiple strategies for learning. + """ + if iteration_trades == 0: + return + + # Strategy 1: Random perturbation with momentum + # If profit was good, keep direction. If bad, try opposite. + for i in range(len(self.weights)): + # Add random exploration + noise = np.random.normal(0, self.learning_rate * 0.5) + + # Bias based on performance + if iteration_profit > 0: + # Profitable - small random changes + self.weights[i] += noise * 0.5 + else: + # Losing - try bigger changes + self.weights[i] += noise * 1.5 + + # Keep within bounds + self.weights[i] = max(0.2, min(2.5, self.weights[i])) + + # Strategy 2: Win rate based adjustment + # If win rate is low, reduce all weights slightly (be more selective) + # If win rate is high, can afford to increase weights + if win_rate < 40: + for i in range(len(self.weights)): + self.weights[i] *= 0.95 # Reduce by 5% + elif win_rate > 60: + for i in range(len(self.weights)): + self.weights[i] *= 1.02 # Increase by 2% + + # Re-clamp + for i in range(len(self.weights)): + self.weights[i] = max(0.2, min(2.5, self.weights[i])) + + def run_iteration(self) -> Dict: + """Run one complete iteration over all symbols and intervals""" + self.iteration += 1 + + print(f"\n{Colors.H}{'='*70}{Colors.E}") + print(f"{Colors.H} ITERATION {self.iteration}{Colors.E}") + print(f"{Colors.H}{'='*70}{Colors.E}") + + iteration_profit = 0.0 + iteration_trades = 0 + iteration_wins = 0 + iteration_losses = 0 + + for symbol in SYMBOLS: + print(f"\n{Colors.C}--- {symbol} ---{Colors.E}") + + for interval in INTERVALS: + candles = CANDLES_PER_INTERVAL.get(interval, 400) + print(f" {interval:>3}: ", end="", flush=True) + + result = self.run_backtest(symbol, interval, candles) + + if result['success']: + profit = result['profit'] + trades = result['trades'] + wr = result['win_rate'] + + iteration_profit += profit + iteration_trades += trades + iteration_wins += result['wins'] + iteration_losses += result['losses'] + + color = Colors.G if profit > 0 else Colors.R + print(f"{color}${profit:+8.2f}{Colors.E} | {trades:2d} trades | {wr:5.1f}% WR") + else: + print(f"{Colors.R}FAILED: {result.get('error', 'Unknown')[:30]}{Colors.E}") + + # Update totals + self.total_trades += iteration_trades + self.total_profit += iteration_profit + + # Calculate iteration win rate + iter_wr = (iteration_wins / (iteration_wins + iteration_losses) * 100 + if (iteration_wins + iteration_losses) > 0 else 0) + + # Print summary + print(f"\n{Colors.BOLD}Iteration {self.iteration} Summary:{Colors.E}") + color = Colors.G if iteration_profit > 0 else Colors.R + print(f" Profit: {color}${iteration_profit:+.2f}{Colors.E}") + print(f" Trades: {iteration_trades}") + print(f" Win Rate: {iter_wr:.1f}%") + + # Track best + if iteration_profit > self.best_profit: + self.best_profit = iteration_profit + self.best_weights = self.weights.copy() + print(f" {Colors.G}★ NEW BEST!{Colors.E}") + + # Update weights AGGRESSIVELY based on performance + self.update_weights(iteration_profit, iteration_trades, iter_wr) + + # Apply learning rate decay (prevents overfitting in later iterations) + self.learning_rate = self.base_learning_rate * (self.lr_decay ** self.iteration) + + # Show weight changes + print(f"\n{Colors.Y}Weight adjustments:{Colors.E}") + changed = 0 + for i, (name, weight) in enumerate(zip(WEIGHT_NAMES, self.weights)): + if i < len(self.weight_history) and len(self.weight_history) > 0: + old = self.weight_history[-1][i] if self.weight_history else 1.0 + diff = weight - old + if abs(diff) > 0.01: + direction = "↑" if diff > 0 else "↓" + print(f" {name}: {old:.3f} → {weight:.3f} ({direction}{abs(diff):.3f})") + changed += 1 + + if changed == 0: + print(" (First iteration - weights established)") + + self.weight_history.append(self.weights.copy()) + self.profit_history.append(iteration_profit) + + # Save after each iteration + self._save_weights() + + return { + 'iteration': self.iteration, + 'profit': iteration_profit, + 'trades': iteration_trades, + 'win_rate': iter_wr, + } + + def run(self, iterations: int = 20) -> Dict: + """Run multiple iterations of learning""" + print(f"\n{Colors.H}{'='*70}{Colors.E}") + print(f"{Colors.H}{'AGGRESSIVE SELF-LEARNING BACKTEST':^70}{Colors.E}") + print(f"{Colors.H}{'='*70}{Colors.E}") + + print(f"\n{Colors.C}Configuration:{Colors.E}") + print(f" Iterations: {iterations}") + print(f" Symbols: {SYMBOLS}") + print(f" Intervals: {INTERVALS}") + print(f" Learning Rate: {self.learning_rate} (decay: {self.lr_decay}/iter)") + print(f" Initial Balance: ${self.initial_balance:,.2f}") + + # Show candle counts per interval + total_candles = sum(CANDLES_PER_INTERVAL.values()) * len(SYMBOLS) + print(f"\n{Colors.C}Data per iteration:{Colors.E}") + for interval, candles in CANDLES_PER_INTERVAL.items(): + print(f" {interval:>3}: {candles:,} candles × {len(SYMBOLS)} symbols") + print(f" Total: ~{total_candles:,} candles/iteration (expect 200-400+ trades)") + + print(f"\n{Colors.C}Starting weights:{Colors.E}") + for name, weight in zip(WEIGHT_NAMES, self.weights): + print(f" {name}: {weight:.3f}") + + start_time = datetime.now() + + try: + for _ in range(iterations): + self.run_iteration() + except KeyboardInterrupt: + print(f"\n{Colors.Y}Interrupted - saving progress...{Colors.E}") + + # Final report + self._print_final_report(start_time) + + return { + 'total_iterations': self.iteration, + 'total_trades': self.total_trades, + 'total_profit': self.total_profit, + 'best_profit': self.best_profit, + 'final_weights': self.weights, + 'best_weights': self.best_weights, + } + + def _print_final_report(self, start_time: datetime): + """Print final report""" + elapsed = (datetime.now() - start_time).total_seconds() + + print(f"\n{Colors.H}{'='*70}{Colors.E}") + print(f"{Colors.H}{'FINAL REPORT':^70}{Colors.E}") + print(f"{Colors.H}{'='*70}{Colors.E}") + + print(f"\n{Colors.BOLD}Overall Statistics:{Colors.E}") + print(f" Duration: {elapsed/60:.1f} minutes") + print(f" Iterations: {self.iteration}") + print(f" Total Trades: {self.total_trades}") + + color = Colors.G if self.total_profit > 0 else Colors.R + print(f" Total Profit: {color}${self.total_profit:+.2f}{Colors.E}") + print(f" Best Iteration: ${self.best_profit:+.2f}") + + # Profit trend + if len(self.profit_history) > 1: + first_half = sum(self.profit_history[:len(self.profit_history)//2]) + second_half = sum(self.profit_history[len(self.profit_history)//2:]) + trend = "📈 IMPROVING" if second_half > first_half else "📉 Declining" + print(f" Trend: {trend}") + + print(f"\n{Colors.BOLD}Final Optimized Weights:{Colors.E}") + for name, weight in zip(WEIGHT_NAMES, self.weights): + # Visual bar + bar_len = int(weight * 10) + bar = "█" * min(bar_len, 25) + "░" * max(0, 25 - bar_len) + + if weight > 1.5: + status = f"{Colors.G}STRONG{Colors.E}" + elif weight < 0.6: + status = f"{Colors.R}WEAK{Colors.E}" + else: + status = "" + + print(f" {name:<25} {weight:.4f} {bar} {status}") + + # Save + self._save_weights() + + # Also save as JSON for easy use + report_path = os.path.join(self.storage_path, "backtest_report.json") + with open(report_path, 'w') as f: + json.dump({ + 'timestamp': datetime.now().isoformat(), + 'iterations': self.iteration, + 'total_trades': self.total_trades, + 'total_profit': self.total_profit, + 'best_profit': self.best_profit, + 'profit_history': self.profit_history, + 'final_weights': dict(zip(WEIGHT_NAMES, self.weights)), + 'best_weights': dict(zip(WEIGHT_NAMES, self.best_weights)), + }, f, indent=2) + + print(f"\n{Colors.G}✓ Report saved to: {report_path}{Colors.E}") + print(f"{Colors.G}✓ Weights saved to: {self.storage_path}/final_weights.json{Colors.E}") + + +def main(): + parser = argparse.ArgumentParser(description="Aggressive self-learning backtest") + parser.add_argument('--iterations', type=int, default=20, + help='Number of learning iterations (default: 20)') + parser.add_argument('--balance', type=float, default=10000.0) + parser.add_argument('--learning-rate', type=float, default=0.15, + help='How aggressively to change weights (default: 0.15)') + parser.add_argument('--storage', type=str, default='./data/btc_eth_learning') + parser.add_argument('--report-only', action='store_true') + + args = parser.parse_args() + + if args.report_only: + weights_file = os.path.join(args.storage, "final_weights.json") + if os.path.exists(weights_file): + with open(weights_file) as f: + data = json.load(f) + print(json.dumps(data, indent=2)) + else: + print("No saved weights found") + return + + learner = AggressiveLearner( + storage_path=args.storage, + initial_balance=args.balance, + learning_rate=args.learning_rate, + ) + + try: + result = learner.run(iterations=args.iterations) + + print(f"\n{Colors.H}{'='*70}{Colors.E}") + print(f"{Colors.H}{'BACKTEST COMPLETE':^70}{Colors.E}") + print(f"{Colors.H}{'='*70}{Colors.E}") + + print(f"\n{Colors.G}✓ Completed {result['total_iterations']} iterations{Colors.E}") + color = Colors.G if result['total_profit'] > 0 else Colors.R + print(f"{Colors.G}✓ Total profit: {color}${result['total_profit']:+.2f}{Colors.E}") + print(f"{Colors.B}ℹ Learning data saved to: {args.storage}{Colors.E}") + + except Exception as e: + print(f"\n{Colors.R}Error: {e}{Colors.E}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/back_tester/start_intensive_training.sh b/back_tester/start_intensive_training.sh new file mode 100755 index 0000000..3c5e2f3 --- /dev/null +++ b/back_tester/start_intensive_training.sh @@ -0,0 +1,278 @@ +#!/bin/bash +# +# Intensive Parameter Optimization Launcher +# +# Optimizes ALL trading parameters: +# - 18 Signal Weights +# - Stop Loss (ATR multiplier, min distance) +# - Take Profit Levels (TP1, TP2, TP3 ratios) +# - Trailing Stop (distance, activation) +# - Risk Percentage +# +# Usage: +# ./start_intensive_training.sh # Default: 2 hours +# ./start_intensive_training.sh --hours 8 # Run for 8 hours +# ./start_intensive_training.sh --generations 100 # Run 100 generations +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CLICKHOUSE_DIR="${SCRIPT_DIR}/clickhouse" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' + +# Defaults +GENERATIONS=100 +HOURS=4 +POPULATION=8 +TESTS=10 +BALANCE=10000 +STORAGE_PATH="${SCRIPT_DIR}/data/intensive_training" +SKIP_CLICKHOUSE=false + +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +LOG_FILE="${SCRIPT_DIR}/logs/intensive_training_${TIMESTAMP}.log" + +print_banner() { + echo -e "${MAGENTA}" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🧬 INTENSIVE PARAMETER OPTIMIZATION ║" + echo "║ ║" + echo "║ Optimizing: ║" + echo "║ • 18 Signal Weights ║" + echo "║ • Stop Loss Parameters (ATR, distance) ║" + echo "║ • Take Profit Ratios (TP1, TP2, TP3) ║" + echo "║ • Trailing Stop (distance, activation) ║" + echo "║ • Risk Percentage ║" + echo "║ ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +show_help() { + echo "Usage: $0 [options]" + echo "" + echo "Intensive parameter optimization for trading strategy" + echo "" + echo "Options:" + echo " --hours N Maximum training time in hours (default: 4)" + echo " --generations N Maximum generations (default: 100)" + echo " --population N Population size per generation (default: 8)" + echo " --tests N Backtests per evaluation (default: 10)" + echo " --balance N Initial balance (default: 10000)" + echo " --storage PATH Storage path for results" + echo " --no-clickhouse Skip ClickHouse startup" + echo " --help Show this help" + echo "" + echo "Examples:" + echo " $0 --hours 8 # Run for 8 hours" + echo " $0 --generations 200 --hours 12 # Long training run" + echo "" +} + +is_clickhouse_running() { + nc -z localhost 9000 &> /dev/null + return $? +} + +start_clickhouse() { + echo -e "${YELLOW}Starting ClickHouse...${NC}" + + if is_clickhouse_running; then + echo -e "${GREEN}✓ ClickHouse already running${NC}" + return 0 + fi + + cd "${CLICKHOUSE_DIR}" 2>/dev/null || mkdir -p "${CLICKHOUSE_DIR}" + + if ! command -v clickhouse &> /dev/null; then + echo -e "${RED}✗ ClickHouse not found. Install: brew install clickhouse${NC}" + exit 1 + fi + + if [ -f "config.xml" ]; then + clickhouse server --config-file=config.xml &> "${SCRIPT_DIR}/logs/clickhouse_${TIMESTAMP}.log" & + else + clickhouse server &> "${SCRIPT_DIR}/logs/clickhouse_${TIMESTAMP}.log" & + fi + + CLICKHOUSE_PID=$! + + for i in {1..15}; do + if is_clickhouse_running; then + echo -e "${GREEN}✓ ClickHouse started${NC}" + return 0 + fi + sleep 1 + done + + echo -e "${RED}✗ ClickHouse failed to start${NC}" + return 1 +} + +cleanup() { + if [ "$SKIP_CLICKHOUSE" = false ] && [ -n "$CLICKHOUSE_PID" ]; then + echo -e "${YELLOW}Stopping ClickHouse...${NC}" + kill $CLICKHOUSE_PID 2>/dev/null || true + fi +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --hours) + HOURS="$2" + shift 2 + ;; + --generations) + GENERATIONS="$2" + shift 2 + ;; + --population) + POPULATION="$2" + shift 2 + ;; + --tests) + TESTS="$2" + shift 2 + ;; + --balance) + BALANCE="$2" + shift 2 + ;; + --storage) + STORAGE_PATH="$2" + shift 2 + ;; + --no-clickhouse) + SKIP_CLICKHOUSE=true + shift + ;; + --help) + show_help + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + show_help + exit 1 + ;; + esac +done + +main() { + print_banner + + mkdir -p "${SCRIPT_DIR}/logs" + mkdir -p "${STORAGE_PATH}" + + echo -e "${CYAN}Configuration:${NC}" + echo " Max Generations: ${GENERATIONS}" + echo " Max Time: ${HOURS} hours" + echo " Population Size: ${POPULATION}" + echo " Tests per Eval: ${TESTS}" + echo " Initial Balance: \$${BALANCE}" + echo " Storage: ${STORAGE_PATH}" + echo " Log: ${LOG_FILE}" + echo "" + + # Estimate time + ESTIMATED_BACKTESTS=$((GENERATIONS * POPULATION * TESTS)) + echo -e "${YELLOW}Estimated backtests: ~${ESTIMATED_BACKTESTS}${NC}" + echo -e "${YELLOW}This may take several hours. Progress will be saved automatically.${NC}" + echo "" + + if [ "$SKIP_CLICKHOUSE" = false ]; then + start_clickhouse || exit 1 + sleep 2 + fi + + export PYTHONPATH="${PROJECT_DIR}:${PYTHONPATH}" + + # Find Python + PYTHON_CMD="" + for cmd in python3.11 python3 python; do + if command -v $cmd &> /dev/null; then + PYTHON_CMD="$cmd" + break + fi + done + + if [ -z "$PYTHON_CMD" ]; then + echo -e "${RED}✗ Python not found${NC}" + exit 1 + fi + + echo -e "${GREEN}Using: $(${PYTHON_CMD} --version)${NC}" + echo "" + + CMD="${PYTHON_CMD} ${SCRIPT_DIR}/intensive_training.py" + CMD="${CMD} --generations ${GENERATIONS}" + CMD="${CMD} --hours ${HOURS}" + CMD="${CMD} --population ${POPULATION}" + CMD="${CMD} --tests ${TESTS}" + CMD="${CMD} --balance ${BALANCE}" + CMD="${CMD} --storage ${STORAGE_PATH}" + + echo -e "${CYAN}Command:${NC} ${CMD}" + echo "" + + echo -e "${YELLOW}Starting optimization... (Ctrl+C to stop safely)${NC}" + echo "" + + ${CMD} 2>&1 | tee "${LOG_FILE}" + EXIT_CODE=${PIPESTATUS[0]} + + echo "" + if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ OPTIMIZATION COMPLETED ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}Results:${NC}" + echo " Parameters: ${STORAGE_PATH}/best_params.json" + echo " Log: ${LOG_FILE}" + + # Show best params if file exists + if [ -f "${STORAGE_PATH}/best_params.json" ]; then + echo "" + echo -e "${CYAN}Best Parameters Summary:${NC}" + ${PYTHON_CMD} -c " +import json +with open('${STORAGE_PATH}/best_params.json') as f: + p = json.load(f) +print(f\" Fitness: {p.get('fitness', 'N/A'):.1f}\") +print(f\" TP Ratios: {p.get('tp1_ratio', 1.5):.1f} / {p.get('tp2_ratio', 2.5):.1f} / {p.get('tp3_ratio', 4.0):.1f}\") +print(f\" ATR Mult: {p.get('atr_multiplier', 2.0):.2f}\") +print(f\" Trailing: {p.get('trailing_stop_distance', 0.5):.2f}% at TP{p.get('trailing_activation_tp', 1)}\") +print(f\" Risk: {p.get('risk_percentage', 1.0):.2f}%\") +" 2>/dev/null || true + fi + else + echo -e "${RED}╔══════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ ✗ OPTIMIZATION FAILED (code: ${EXIT_CODE}) ║${NC}" + echo -e "${RED}╚══════════════════════════════════════════════════════════════════╝${NC}" + echo "Check log: ${LOG_FILE}" + fi + + return $EXIT_CODE +} + +main + + + + diff --git a/back_tester/start_learning_backtest.sh b/back_tester/start_learning_backtest.sh new file mode 100755 index 0000000..badd417 --- /dev/null +++ b/back_tester/start_learning_backtest.sh @@ -0,0 +1,283 @@ +#!/bin/bash +# +# Self-Learning Backtesting Launcher for BTC and ETH +# +# This script launches comprehensive backtesting with self-learning +# on BTCUSDT and ETHUSDT across all time intervals. +# +# Usage: +# ./start_learning_backtest.sh [options] +# +# Options: +# --iterations N Number of learning iterations (default: 3) +# --candles N Override candle count for all intervals +# --balance N Initial balance (default: 10000) +# --risk N Risk percentage per trade (default: 1.0) +# --no-clickhouse Skip ClickHouse startup (if already running) +# --help Show this help message +# + +set -e + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CLICKHOUSE_DIR="${SCRIPT_DIR}/clickhouse" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' + +# Default parameters - AGGRESSIVE LEARNING (20 iterations, not 3!) +ITERATIONS=20 +LEARNING_RATE=0.15 +BALANCE=10000 +SKIP_CLICKHOUSE=false +STORAGE_PATH="${SCRIPT_DIR}/data/btc_eth_learning" + +# Timestamp for logs +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +LOG_FILE="${SCRIPT_DIR}/logs/learning_backtest_${TIMESTAMP}.log" + +# Banner +print_banner() { + echo -e "${MAGENTA}" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ 🤖 SELF-LEARNING BACKTESTING SYSTEM ║" + echo "║ ║" + echo "║ Symbols: BTCUSDT, ETHUSDT ║" + echo "║ Intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d ║" + echo "║ ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +# Help message +show_help() { + echo "Usage: $0 [options]" + echo "" + echo "Self-Learning Backtesting for BTCUSDT and ETHUSDT" + echo "" + echo "Options:" + echo " --iterations N Number of learning iterations (default: 20)" + echo " --learning-rate N How fast weights change (default: 0.15)" + echo " --balance N Initial balance in USD (default: 10000)" + echo " --storage PATH Path to store learning data" + echo " --no-clickhouse Skip ClickHouse startup" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Run 20 iterations (default)" + echo " $0 --iterations 50 # Run 50 learning iterations" + echo " $0 --learning-rate 0.25 # More aggressive learning" + echo "" +} + +# Check if ClickHouse is running +is_clickhouse_running() { + nc -z localhost 9000 &> /dev/null + return $? +} + +# Start ClickHouse +start_clickhouse() { + echo -e "${YELLOW}Starting ClickHouse server...${NC}" + + if is_clickhouse_running; then + echo -e "${GREEN}✓ ClickHouse is already running${NC}" + return 0 + fi + + if [ ! -d "${CLICKHOUSE_DIR}" ]; then + echo -e "${RED}✗ ClickHouse directory not found: ${CLICKHOUSE_DIR}${NC}" + echo -e "${YELLOW}Creating minimal ClickHouse config...${NC}" + mkdir -p "${CLICKHOUSE_DIR}" + fi + + cd "${CLICKHOUSE_DIR}" + + if ! command -v clickhouse &> /dev/null; then + echo -e "${RED}✗ ClickHouse not found in PATH${NC}" + echo "Please install ClickHouse: brew install clickhouse" + exit 1 + fi + + # Start ClickHouse in background + if [ -f "config.xml" ]; then + clickhouse server --config-file=config.xml &> "${SCRIPT_DIR}/logs/clickhouse_${TIMESTAMP}.log" & + else + clickhouse server &> "${SCRIPT_DIR}/logs/clickhouse_${TIMESTAMP}.log" & + fi + + CLICKHOUSE_PID=$! + + echo -e "${YELLOW}Waiting for ClickHouse to start (PID: ${CLICKHOUSE_PID})...${NC}" + + for i in {1..15}; do + if is_clickhouse_running; then + echo -e "${GREEN}✓ ClickHouse started successfully${NC}" + return 0 + fi + sleep 1 + echo -n "." + done + + echo "" + echo -e "${RED}✗ Failed to start ClickHouse within timeout${NC}" + return 1 +} + +# Stop ClickHouse on exit +cleanup() { + if [ "$SKIP_CLICKHOUSE" = false ] && [ -n "$CLICKHOUSE_PID" ]; then + echo -e "${YELLOW}Stopping ClickHouse...${NC}" + kill $CLICKHOUSE_PID 2>/dev/null || true + fi +} + +trap cleanup EXIT + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --iterations) + ITERATIONS="$2" + shift 2 + ;; + --learning-rate) + LEARNING_RATE="$2" + shift 2 + ;; + --balance) + BALANCE="$2" + shift 2 + ;; + --storage) + STORAGE_PATH="$2" + shift 2 + ;; + --no-clickhouse) + SKIP_CLICKHOUSE=true + shift + ;; + --help) + show_help + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + show_help + exit 1 + ;; + esac +done + +# Main execution +main() { + print_banner + + # Create logs directory + mkdir -p "${SCRIPT_DIR}/logs" + mkdir -p "${STORAGE_PATH}" + + echo -e "${CYAN}Configuration:${NC}" + echo " Iterations: ${ITERATIONS}" + echo " Learning Rate: ${LEARNING_RATE}" + echo " Balance: \$${BALANCE}" + echo " Storage: ${STORAGE_PATH}" + echo " Log File: ${LOG_FILE}" + echo "" + + # Estimate time + ESTIMATED_TIME=$((ITERATIONS * 2)) + echo -e "${YELLOW}Estimated time: ~${ESTIMATED_TIME} minutes${NC}" + echo "" + + # Start ClickHouse if needed + if [ "$SKIP_CLICKHOUSE" = false ]; then + start_clickhouse + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to start ClickHouse. Exiting.${NC}" + exit 1 + fi + echo "" + sleep 2 + else + echo -e "${YELLOW}Skipping ClickHouse startup (--no-clickhouse)${NC}" + if ! is_clickhouse_running; then + echo -e "${RED}Warning: ClickHouse doesn't appear to be running!${NC}" + fi + echo "" + fi + + # Set Python path + export PYTHONPATH="${PROJECT_DIR}:${PYTHONPATH}" + + # Check Python + PYTHON_CMD="" + if command -v python3.11 &> /dev/null; then + PYTHON_CMD="python3.11" + elif command -v python3 &> /dev/null; then + PYTHON_CMD="python3" + elif command -v python &> /dev/null; then + PYTHON_CMD="python" + else + echo -e "${RED}✗ Python not found${NC}" + exit 1 + fi + + echo -e "${GREEN}Using Python: $(${PYTHON_CMD} --version)${NC}" + echo "" + + # Build command + CMD="${PYTHON_CMD} ${SCRIPT_DIR}/run_learning_backtest.py" + CMD="${CMD} --iterations ${ITERATIONS}" + CMD="${CMD} --learning-rate ${LEARNING_RATE}" + CMD="${CMD} --balance ${BALANCE}" + CMD="${CMD} --storage ${STORAGE_PATH}" + + echo -e "${CYAN}Executing:${NC}" + echo " ${CMD}" + echo "" + + # Run backtest + echo -e "${YELLOW}Starting backtests... (logging to ${LOG_FILE})${NC}" + echo "" + + # Run and tee to both console and log file + ${CMD} 2>&1 | tee "${LOG_FILE}" + + EXIT_CODE=${PIPESTATUS[0]} + + echo "" + if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ BACKTESTING COMPLETED SUCCESSFULLY ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}Results saved to:${NC}" + echo " Learning Data: ${STORAGE_PATH}" + echo " Weights: ${STORAGE_PATH}/final_weights.json" + echo " Report: ${STORAGE_PATH}/backtest_report.json" + echo " Log: ${LOG_FILE}" + else + echo -e "${RED}╔══════════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ ✗ BACKTESTING FAILED (exit code: ${EXIT_CODE}) ║${NC}" + echo -e "${RED}╚══════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo "Check log file for details: ${LOG_FILE}" + fi + + return $EXIT_CODE +} + +# Run main +main + diff --git a/back_tester/strategy.py b/back_tester/strategy.py index 3f56272..f829810 100644 --- a/back_tester/strategy.py +++ b/back_tester/strategy.py @@ -1,9 +1,10 @@ import os import sys -from typing import List, Tuple, Optional, Dict +from typing import List, Tuple, Optional, Dict, Any import random from datetime import datetime, timedelta import uuid +import logging project_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) sys.path.append(project_dir) @@ -26,6 +27,20 @@ from utils import create_true_preferences from .db_operations import ClickHouseDB +# Import self-learning modules (optional - graceful fallback if not available) +try: + from .adaptive_learning import ( + SelfLearningBacktester, + extract_indicator_contributions, + SignalOutcome + ) + from .enhanced_metrics import EnhancedMetricsCalculator, TradeMetrics, create_trade_metrics + LEARNING_AVAILABLE = True +except ImportError: + LEARNING_AVAILABLE = False + +logger = logging.getLogger(__name__) + def backtest_strategy( symbol: str, @@ -40,13 +55,19 @@ def backtest_strategy( trailing_stop_distance_percent: float = 0.5, # Distance to maintain from highest price iteration_id: Optional[str] = None, db: Optional[ClickHouseDB] = None, + # Self-learning parameters + enable_learning: bool = False, + learner: Optional[Any] = None, # SelfLearningBacktester instance + metrics_calculator: Optional[Any] = None, # EnhancedMetricsCalculator instance + track_indicator_contributions: bool = True, ) -> Tuple[float, list, Optional[str]]: """ - Backtest a strategy with risk management: + Backtest a strategy with risk management and optional self-learning: - At each step, generate a signal with risk management parameters - Use position sizing based on risk percentage - Implement multiple take profit levels - Use dynamic stop loss and trailing stop loss + - Optionally track signal performance for self-learning Parameters: symbol: The trading pair symbol (e.g. "BTCUSDT") @@ -59,6 +80,10 @@ def backtest_strategy( weights: List of weights for signal generation use_trailing_stop: Whether to enable trailing stop loss functionality trailing_stop_distance_percent: Distance in percentage to maintain from highest price reached + enable_learning: Whether to enable self-learning feedback loop + learner: SelfLearningBacktester instance for tracking signals + metrics_calculator: EnhancedMetricsCalculator for detailed metrics + track_indicator_contributions: Whether to track which indicators contributed to each signal Returns: final_balance: The simulated portfolio balance at the end @@ -97,6 +122,27 @@ def backtest_strategy( entry_signal = None current_trade = None # Store current trade details parent_trade_id = None # Track parent trade for TP/SL entries + + # Self-learning tracking variables + current_signal_id = None + current_indicator_contributions = {} + current_market_context = {} + max_favorable_excursion = 0.0 + max_adverse_excursion = 0.0 + + # Initialize learning components if enabled + if enable_learning and LEARNING_AVAILABLE and learner is None: + learner = SelfLearningBacktester( + storage_path="./data/learning", + auto_adjust_weights=True, + adjustment_frequency=50 + ) + # Use adaptive weights if available + if not weights: + weights = learner.get_current_weights() + + if enable_learning and LEARNING_AVAILABLE and metrics_calculator is None: + metrics_calculator = EnhancedMetricsCalculator() # Use all indicators enabled by default in backtesting preferences = create_true_preferences() @@ -313,6 +359,17 @@ def backtest_strategy( } db.insert_trade(trade_data) + # Record outcome for self-learning (TP3 - full target hit) + if enable_learning and LEARNING_AVAILABLE and learner and current_signal_id: + learner.record_signal_exit( + signal_id=current_signal_id, + outcome="tp3", + exit_price=current_price, + profit_loss=profit, + duration=i - entry_index + ) + current_signal_id = None + position = 0 entry_price = None entry_time = None @@ -397,6 +454,17 @@ def backtest_strategy( } db.insert_trade(trade_data) + # Record outcome for self-learning (stop loss) + if enable_learning and LEARNING_AVAILABLE and learner and current_signal_id: + learner.record_signal_exit( + signal_id=current_signal_id, + outcome="trailing_stop" if current_trade.get("trailing_stop_active", False) else "stop_loss", + exit_price=current_price, + profit_loss=loss, + duration=i - entry_index + ) + current_signal_id = None + position = 0 entry_price = None entry_time = None @@ -436,9 +504,11 @@ def backtest_strategy( entry_signal = signal parent_trade_id = str(uuid.uuid4()) # Generate parent trade ID - # Store trade details + # Store trade details with trailing stop initialization + trailing_activation_price = float(trading_signal.take_profit_1) # Activate at TP1 current_trade = { "stop_loss": float(trading_signal.stop_loss), + "initial_stop_loss": float(trading_signal.stop_loss), # Store initial stop for comparison "take_profit_1": float(trading_signal.take_profit_1), "take_profit_2": float(trading_signal.take_profit_2), "take_profit_3": float(trading_signal.take_profit_3), @@ -448,6 +518,11 @@ def backtest_strategy( "tp1_hit": False, "tp2_hit": False, "tp3_hit": False, + # Trailing stop fields + "highest_price": float(current_price), # Track highest price reached + "trailing_stop_active": False, # Whether trailing stop is active + "trailing_activation_price": trailing_activation_price, # Price at which trailing stop activates + "trailing_stop_level": float(trading_signal.stop_loss), # Current trailing stop level } trade_log.append( @@ -462,8 +537,63 @@ def backtest_strategy( "take_profit_1": float(trading_signal.take_profit_1), "take_profit_2": float(trading_signal.take_profit_2), "take_profit_3": float(trading_signal.take_profit_3), + "symbol": symbol, + "interval": interval, } ) + + # Track signal for self-learning + if enable_learning and LEARNING_AVAILABLE and learner: + # Parse reasons to extract indicator contributions + reasons_list = reason.split("\n- ") if reason else [] + reasons_list = [r.strip("- \n") for r in reasons_list if r.strip()] + + # Extract bullish/bearish scores from reason string + bullish_score = 0.0 + bearish_score = 0.0 + if "Bullish Score:" in reason: + try: + bullish_part = reason.split("Bullish Score:")[1].split("|")[0] + bullish_score = float(bullish_part.strip()) + except: + pass + if "Bearish Score:" in reason: + try: + bearish_part = reason.split("Bearish Score:")[1].split("\n")[0] + bearish_score = float(bearish_part.strip()) + except: + pass + + current_indicator_contributions = extract_indicator_contributions( + bullish_score, bearish_score, signal, reasons_list + ) + + current_market_context = { + "market_regime": trading_signal.market_conditions.get("market_regime", ""), + "volatility": trading_signal.market_conditions.get("volatility", 0), + "volume_ratio": trading_signal.market_conditions.get("volume_ratio", 1), + "rsi": trading_signal.market_conditions.get("rsi", 50), + "trend": "uptrend" if signal == "Bullish" else "downtrend" + } + + current_signal_id = str(uuid.uuid4()) + max_favorable_excursion = 0.0 + max_adverse_excursion = 0.0 + + learner.record_signal_entry( + signal_id=current_signal_id, + symbol=symbol, + interval=interval, + signal_type=signal, + entry_price=entry_price, + stop_loss=float(trading_signal.stop_loss), + tp1=float(trading_signal.take_profit_1), + tp2=float(trading_signal.take_profit_2), + tp3=float(trading_signal.take_profit_3), + indicator_contributions=current_indicator_contributions, + reasons=reasons_list, + market_context=current_market_context + ) # Store entry trade in database if db: @@ -551,6 +681,20 @@ def backtest_strategy( "parent_trade_id": parent_trade_id, } db.insert_trade(trade_data) + + # Record outcome for self-learning (end of period) + if enable_learning and LEARNING_AVAILABLE and learner and current_signal_id: + learner.record_signal_exit( + signal_id=current_signal_id, + outcome="end", + exit_price=final_price, + profit_loss=profit, + duration=len(df) - 1 - entry_index if entry_index else 0 + ) + + # Save learning state if enabled + if enable_learning and LEARNING_AVAILABLE and learner: + learner.save_state() return balance, trade_log, sub_iteration_id @@ -570,7 +714,7 @@ def backtest_strategy( use_trailing_stop=True, trailing_stop_distance_percent=0.5, # Keep trailing stop 0.5% below highest price ) - print(f"Final Balance: {final_balance:.2f}") + print(f"Final Balance: {final_balance_ts:.2f}") print("Trade Log:") - for trade in trades: + for trade in trades_ts: print(trade) diff --git a/back_tester/trainer.py b/back_tester/trainer.py index da66c47..51ed68a 100644 --- a/back_tester/trainer.py +++ b/back_tester/trainer.py @@ -45,22 +45,27 @@ db = ClickHouseDB() # --- Initial Weight Configuration --- -# Updated for new weights including risk management signals +# Updated for all 18 weights matching signal_detection.py weights = [ - 1.0, # W_BULLISH_OB - 1.0, # W_BEARISH_OB - 1.0, # W_BULLISH_BREAKER - 1.0, # W_BEARISH_BREAKER - 0.7, # W_ABOVE_SUPPORT - 0.7, # W_BELOW_RESISTANCE - 0.5, # W_FVG_ABOVE - 0.5, # W_FVG_BELOW - 0.8, # W_TREND - 1.2, # W_SWEEP_HIGHS - 1.2, # W_SWEEP_LOWS - 1.5, # W_STRUCTURE_BREAK - 0.6, # W_PIN_BAR -] # Updated for new weights + 1.0, # W_BULLISH_OB (0) + 1.0, # W_BEARISH_OB (1) + 1.0, # W_BULLISH_BREAKER (2) + 1.0, # W_BEARISH_BREAKER (3) + 0.7, # W_ABOVE_SUPPORT (4) + 0.7, # W_BELOW_RESISTANCE (5) + 0.5, # W_FVG_ABOVE (6) + 0.5, # W_FVG_BELOW (7) + 0.8, # W_TREND (8) + 1.2, # W_SWEEP_HIGHS (9) + 1.2, # W_SWEEP_LOWS (10) + 1.5, # W_STRUCTURE_BREAK (11) + 0.6, # W_PIN_BAR (12) + 0.6, # W_ENGULFING (13) + 1.2, # W_LIQUIDITY_POOL_ABOVE (14) + 1.2, # W_LIQUIDITY_POOL_BELOW (15) + 1.5, # W_LIQUIDITY_POOL_ROUND (16) + 0.6, # W_RSI_EXTREME (17) +] learning_rate = 0.05 iterations = 1000 @@ -77,6 +82,15 @@ patience = 50 history_size = 10 +# Weight names for tracking and logging +WEIGHT_NAMES = [ + "W_BULLISH_OB", "W_BEARISH_OB", "W_BULLISH_BREAKER", "W_BEARISH_BREAKER", + "W_ABOVE_SUPPORT", "W_BELOW_RESISTANCE", "W_FVG_ABOVE", "W_FVG_BELOW", + "W_TREND", "W_SWEEP_HIGHS", "W_SWEEP_LOWS", "W_STRUCTURE_BREAK", + "W_PIN_BAR", "W_ENGULFING", "W_LIQUIDITY_POOL_ABOVE", "W_LIQUIDITY_POOL_BELOW", + "W_LIQUIDITY_POOL_ROUND", "W_RSI_EXTREME" +] + class TrainingMetrics: def __init__(self): @@ -440,8 +454,35 @@ def optimize_weights( if __name__ == "__main__": + # Parse command line arguments + parser = argparse.ArgumentParser(description="Train signal weights for CryptoBot") + parser.add_argument( + "--symbol", type=str, default="BTCUSDT", help="Primary trading pair for optimization" + ) + parser.add_argument( + "--interval", type=str, default="1h", help="Primary candle interval" + ) + parser.add_argument( + "--iterations", type=int, default=1000, help="Number of training iterations" + ) + parser.add_argument( + "--risk", type=float, default=1.0, help="Risk percentage per trade" + ) + parser.add_argument( + "--learning-rate", type=float, default=0.05, help="Learning rate for weight updates" + ) + args = parser.parse_args() + + # Update global parameters from args + iterations = args.iterations + risk_percentage = args.risk + learning_rate = args.learning_rate + # Train the model try: + logger.info(f"Starting weight optimization for {args.symbol} {args.interval}") + logger.info(f"Iterations: {iterations}, Risk: {risk_percentage}%, Learning rate: {learning_rate}") + best_weights = optimize_weights(weights, iterations, learning_rate) logger.info(f"Training completed successfully") @@ -454,17 +495,22 @@ def optimize_weights( weights_file = f"models/weights_{timestamp}.txt" os.makedirs("models", exist_ok=True) + with open(weights_file, "w") as f: f.write( f"# Optimized weights for {args.symbol} {args.interval} at {timestamp}\n" ) f.write(f"# Fitness: {fitness:.4f}\n") - f.write(f"# Risk: {risk_percentage}\n\n") + f.write(f"# Risk: {risk_percentage}%\n") + f.write(f"# Iterations: {iterations}\n\n") for i, w in enumerate(best_weights): - f.write(f"W{i} = {w:.6f}\n") + name = WEIGHT_NAMES[i] if i < len(WEIGHT_NAMES) else f"W{i}" + f.write(f"{name} = {w:.6f}\n") logger.info(f"Weights saved to {weights_file}") except Exception as e: logger.error(f"Training failed: {str(e)}") + import traceback + traceback.print_exc() sys.exit(1)